diff --git a/openless -all/app/src-tauri/src/commands.rs b/openless -all/app/src-tauri/src/commands.rs index 449efb70..e2d65afb 100644 --- a/openless -all/app/src-tauri/src/commands.rs +++ b/openless -all/app/src-tauri/src/commands.rs @@ -8,7 +8,8 @@ use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; use crate::types::{ - CredentialsStatus, DictationSession, DictionaryEntry, PolishMode, UserPreferences, + CredentialsStatus, DictationSession, DictionaryEntry, HotkeyStatus, PolishMode, + UserPreferences, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -27,6 +28,11 @@ pub fn set_settings(coord: CoordinatorState<'_>, prefs: UserPreferences) -> Resu Ok(()) } +#[tauri::command] +pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus { + coord.hotkey_status() +} + #[tauri::command] pub fn get_credentials() -> CredentialsStatus { let snap = CredentialsVault::snapshot(); @@ -221,8 +227,17 @@ pub fn open_system_settings(pane: String) -> Result<(), String> { } #[cfg(not(target_os = "macos"))] { - let _ = pane; - Ok(()) + let uri = match pane.as_str() { + "microphone" => "ms-settings:privacy-microphone", + "sound" => "ms-settings:sound", + "accessibility" => "ms-settings:easeofaccess", + _ => "ms-settings:", + }; + std::process::Command::new("cmd") + .args(["/C", "start", "", uri]) + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) } } diff --git a/openless -all/app/src-tauri/src/coordinator.rs b/openless -all/app/src-tauri/src/coordinator.rs index c3273591..1e28a01b 100644 --- a/openless -all/app/src-tauri/src/coordinator.rs +++ b/openless -all/app/src-tauri/src/coordinator.rs @@ -23,8 +23,8 @@ use crate::persistence::{ use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::recorder::Recorder; use crate::types::{ - CapsulePayload, CapsuleState, DictationSession, HotkeyMode, InsertStatus, PolishMode, - UserPreferences, + CapsulePayload, CapsuleState, DictationSession, HotkeyMode, HotkeyStatus, + HotkeyStatusState, InsertStatus, PolishMode, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -63,6 +63,7 @@ struct Inner { asr: Mutex>>, recorder: Mutex>, hotkey: Mutex>, + hotkey_status: Mutex, } impl Coordinator { @@ -85,6 +86,7 @@ impl Coordinator { asr: Mutex::new(None), recorder: Mutex::new(None), hotkey: Mutex::new(None), + hotkey_status: Mutex::new(HotkeyStatus::default()), }), } } @@ -119,6 +121,10 @@ impl Coordinator { } } + pub fn hotkey_status(&self) -> HotkeyStatus { + self.inner.hotkey_status.lock().clone() + } + pub async fn start_dictation(&self) -> Result<(), String> { begin_session(&self.inner).await } @@ -147,11 +153,22 @@ fn hotkey_supervisor_loop(inner: Arc) { if inner.hotkey.lock().is_some() { return; } + *inner.hotkey_status.lock() = HotkeyStatus { + state: HotkeyStatusState::Starting, + message: Some(format!( + "正在安装全局快捷键监听(第 {} 次)", + attempts + 1 + )), + }; let (tx, rx) = mpsc::channel::(); let binding = inner.prefs.get().hotkey; match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { *inner.hotkey.lock() = Some(monitor); + *inner.hotkey_status.lock() = HotkeyStatus { + state: HotkeyStatusState::Installed, + message: None, + }; log::info!( "[coord] hotkey listener installed (after {} attempt(s))", attempts + 1 @@ -165,6 +182,10 @@ fn hotkey_supervisor_loop(inner: Arc) { } Err(e) => { attempts += 1; + *inner.hotkey_status.lock() = HotkeyStatus { + state: HotkeyStatusState::Failed, + message: Some(e.to_string()), + }; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] hotkey listener attempt #{attempts} failed: {e}; retrying in 3s" @@ -392,12 +413,22 @@ async fn end_session(inner: &Arc) -> Result<(), String> { log::error!("[coord] history append failed: {e}"); } + let done_message = match status { + InsertStatus::Inserted => None, + InsertStatus::CopiedFallback => Some(if cfg!(target_os = "windows") { + "已复制,请 Ctrl+V".to_string() + } else { + "已复制,请粘贴".to_string() + }), + InsertStatus::Failed => Some("插入失败".to_string()), + }; + emit_capsule( inner, CapsuleState::Done, 0.0, elapsed, - None, + done_message, Some(inserted_chars), ); diff --git a/openless -all/app/src-tauri/src/hotkey.rs b/openless -all/app/src-tauri/src/hotkey.rs index 0ff1acc4..6169deb2 100644 --- a/openless -all/app/src-tauri/src/hotkey.rs +++ b/openless -all/app/src-tauri/src/hotkey.rs @@ -305,9 +305,10 @@ mod platform { #[cfg(not(target_os = "macos"))] mod platform { - use std::sync::atomic::Ordering; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; use std::sync::Arc; + use std::time::Duration; use rdev::{listen, Event, EventType, Key}; @@ -319,13 +320,25 @@ mod platform { tx: Sender, status_tx: std::sync::mpsc::Sender, ) { - // rdev 没有"安装即可知"的 API;我们乐观汇报成功,Linux/Win 上一般直接生效。 - let _ = status_tx.send(true); + // rdev 没有"安装即可知"的 API。给 listen 一个短窗口: + // 如果 hook 立即失败,向 supervisor 汇报失败;否则视为已进入监听循环。 + let status_sent = Arc::new(AtomicBool::new(false)); + let ready_status_sent = Arc::clone(&status_sent); + let ready_status_tx = status_tx.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(350)); + if !ready_status_sent.swap(true, Ordering::SeqCst) { + let _ = ready_status_tx.send(true); + } + }); let cb_shared = Arc::clone(&shared); let result = listen(move |event: Event| { dispatch_event(&cb_shared, &tx, event); }); if let Err(err) = result { + if !status_sent.swap(true, Ordering::SeqCst) { + let _ = status_tx.send(false); + } log::error!("[hotkey] rdev::listen 启动失败: {:?}", err); } } diff --git a/openless -all/app/src-tauri/src/insertion.rs b/openless -all/app/src-tauri/src/insertion.rs index 58f9ef02..76a79d7d 100644 --- a/openless -all/app/src-tauri/src/insertion.rs +++ b/openless -all/app/src-tauri/src/insertion.rs @@ -27,13 +27,11 @@ impl TextInserter { if !copy_to_clipboard(text) { return InsertStatus::Failed; } - match simulate_paste() { - Ok(()) => InsertStatus::Inserted, - Err(err) => { - log::warn!("[insertion] simulated paste failed: {}", err); - InsertStatus::CopiedFallback - } + if let Err(err) = simulate_paste() { + log::warn!("[insertion] simulated paste failed: {}", err); + return InsertStatus::CopiedFallback; } + insertion_success_status() } } @@ -78,6 +76,17 @@ fn simulate_paste() -> Result<(), String> { Ok(()) } +#[cfg(target_os = "macos")] +fn insertion_success_status() -> InsertStatus { + InsertStatus::Inserted +} + +#[cfg(not(target_os = "macos"))] +fn insertion_success_status() -> InsertStatus { + // Windows/Linux 的 Ctrl+V 只能证明粘贴快捷键已发送,不能证明目标控件已接收。 + InsertStatus::CopiedFallback +} + // ─────────────────────────── macOS native CGEvent paste ─────────────────────────── #[cfg(target_os = "macos")] diff --git a/openless -all/app/src-tauri/src/lib.rs b/openless -all/app/src-tauri/src/lib.rs index d2d61028..50a94a8a 100644 --- a/openless -all/app/src-tauri/src/lib.rs +++ b/openless -all/app/src-tauri/src/lib.rs @@ -125,6 +125,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::get_settings, commands::set_settings, + commands::get_hotkey_status, commands::get_credentials, commands::set_credential, commands::list_history, diff --git a/openless -all/app/src-tauri/src/permissions.rs b/openless -all/app/src-tauri/src/permissions.rs index d6362792..e3ce4432 100644 --- a/openless -all/app/src-tauri/src/permissions.rs +++ b/openless -all/app/src-tauri/src/permissions.rs @@ -248,6 +248,7 @@ mod platform { #[cfg(not(target_os = "macos"))] mod platform { use super::PermissionStatus; + use cpal::traits::{DeviceTrait, HostTrait}; /// Windows / Linux 不存在 macOS 那种 Accessibility 概念;rdev 直接监听键盘。 pub fn check_accessibility() -> PermissionStatus { @@ -259,14 +260,35 @@ mod platform { } /// Windows 的麦克风权限走系统设置 → 隐私 → 麦克风; - /// 我们没法在用户态直接查授权状态,启动 cpal stream 时 Win10+ 会自动弹一次提示。 - /// 这里乐观返回 Granted;UI 上不需要展示 Denied 状态。 + /// 这里用 cpal 做最小可用性探测,避免 UI 在无设备或权限被拒时误报已授权。 pub fn check_microphone() -> PermissionStatus { - PermissionStatus::Granted + let host = cpal::default_host(); + let Some(device) = host.default_input_device() else { + log::warn!("[mic] no default input device"); + return PermissionStatus::Denied; + }; + match device.default_input_config() { + Ok(_) => PermissionStatus::Granted, + Err(err) => classify_audio_probe_error(err.to_string()), + } } pub fn request_microphone() -> PermissionStatus { - PermissionStatus::Granted + check_microphone() + } + + fn classify_audio_probe_error(message: String) -> PermissionStatus { + let lower = message.to_lowercase(); + log::warn!("[mic] default input config failed: {message}"); + if lower.contains("denied") + || lower.contains("permission") + || lower.contains("authoriz") + || lower.contains("access") + { + PermissionStatus::Denied + } else { + PermissionStatus::NotDetermined + } } } diff --git a/openless -all/app/src-tauri/src/types.rs b/openless -all/app/src-tauri/src/types.rs index 7fdaa8c8..e46b0223 100644 --- a/openless -all/app/src-tauri/src/types.rs +++ b/openless -all/app/src-tauri/src/types.rs @@ -130,6 +130,30 @@ pub struct HotkeyBinding { pub mode: HotkeyMode, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HotkeyStatus { + pub state: HotkeyStatusState, + pub message: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum HotkeyStatusState { + Starting, + Installed, + Failed, +} + +impl Default for HotkeyStatus { + fn default() -> Self { + Self { + state: HotkeyStatusState::Starting, + message: Some("正在安装全局快捷键监听".into()), + } + } +} + impl Default for HotkeyBinding { fn default() -> Self { // Right Option (mac) / Right Alt (win) — toggle by default per design. diff --git a/openless -all/app/src/components/Capsule.tsx b/openless -all/app/src/components/Capsule.tsx index 6f860ab9..071014d5 100644 --- a/openless -all/app/src/components/Capsule.tsx +++ b/openless -all/app/src/components/Capsule.tsx @@ -188,7 +188,7 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil ); break; case 'done': - center = ; + center = ; break; case 'cancelled': center = ; diff --git a/openless -all/app/src/components/FloatingShell.tsx b/openless -all/app/src/components/FloatingShell.tsx index 31007084..093a0f05 100644 --- a/openless -all/app/src/components/FloatingShell.tsx +++ b/openless -all/app/src/components/FloatingShell.tsx @@ -13,7 +13,7 @@ import { History } from '../pages/History'; import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { APP_VERSION_LABEL } from '../lib/appVersion'; -import { getCredentials } from '../lib/ipc'; +import { getCredentials, openExternal } from '../lib/ipc'; import { OL_DATA } from '../lib/mockData'; import { PROVIDER_SETUP_PROMPT_SEEN_KEY, @@ -56,6 +56,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const [settingsInitialSection, setSettingsInitialSection] = useState(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); const Page = (NAV.find((n) => n.id === currentTab) ?? NAV[0]).cmp; + const hotkeyLabel = os === 'win' ? '右 Alt' : '右 Option'; useEffect(() => { let cancelled = false; @@ -176,7 +177,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia border: '0.5px solid var(--ol-line-strong)', fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink)', boxShadow: '0 1px 0 rgba(0,0,0,.04)', - }}>右 Option + }}>{hotkeyLabel} 开始 / 停止 @@ -211,7 +212,11 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia }} >
- + {currentTab === 'overview' ? ( + setCurrentTab('history')} /> + ) : ( + + )}
@@ -231,15 +236,31 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia zIndex: 2, }}> - - + openSettings('提供商')} /> + openExternal('https://github.com/appergb/openless/issues')} /> openSettings()} /> - + openExternal('https://github.com/appergb/openless#readme')} />
版本 {APP_VERSION_LABEL} - 检查更新 +
{/* Settings modal — rendered inside this window */} diff --git a/openless -all/app/src/components/Onboarding.tsx b/openless -all/app/src/components/Onboarding.tsx index 0e6577c9..123984cd 100644 --- a/openless -all/app/src/components/Onboarding.tsx +++ b/openless -all/app/src/components/Onboarding.tsx @@ -12,6 +12,7 @@ import { requestMicrophonePermission, } from '../lib/ipc'; import type { PermissionStatus } from '../lib/types'; +import { detectOS } from './WindowChrome'; interface OnboardingProps { onComplete: () => void; @@ -21,6 +22,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); const [busy, setBusy] = useState(false); + const os = detectOS(); const refresh = async () => { const [a, m] = await Promise.all([ @@ -122,19 +124,23 @@ export function Onboarding({ onComplete }: OnboardingProps) { ({ id: `mock-${i}`, createdAt: new Date().toISOString(), @@ -80,6 +86,10 @@ export function setSettings(prefs: UserPreferences): Promise { return invokeOrMock('set_settings', { prefs }, () => undefined); } +export function getHotkeyStatus(): Promise { + return invokeOrMock('get_hotkey_status', undefined, () => mockHotkeyStatus); +} + // ── Credentials ──────────────────────────────────────────────────────── export function getCredentials(): Promise { return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus); @@ -144,8 +154,8 @@ export function cancelDictation(): Promise { } // ── Polish ───────────────────────────────────────────────────────────── -export function repolish(id: string, mode: PolishMode): Promise { - return invokeOrMock('repolish', { id, mode }, () => mockHistory[0]); +export function repolish(rawText: string, mode: PolishMode): Promise { + return invokeOrMock('repolish', { rawText, mode }, () => rawText); } export function setDefaultPolishMode(mode: PolishMode): Promise { @@ -181,4 +191,13 @@ export function triggerMicrophonePrompt(): Promise { return invokeOrMock('trigger_microphone_prompt', undefined, () => undefined); } +export async function openExternal(url: string): Promise { + if (!isTauri) { + window.open(url, '_blank', 'noopener,noreferrer'); + return; + } + const { open } = await import('@tauri-apps/plugin-shell'); + await open(url); +} + export { isTauri }; diff --git a/openless -all/app/src/lib/types.ts b/openless -all/app/src/lib/types.ts index 6ea9d2bb..7bf7c8f7 100644 --- a/openless -all/app/src/lib/types.ts +++ b/openless -all/app/src/lib/types.ts @@ -45,6 +45,13 @@ export interface HotkeyBinding { mode: HotkeyMode; } +export type HotkeyStatusState = 'starting' | 'installed' | 'failed'; + +export interface HotkeyStatus { + state: HotkeyStatusState; + message: string | null; +} + export interface UserPreferences { hotkey: HotkeyBinding; defaultMode: PolishMode; diff --git a/openless -all/app/src/pages/History.tsx b/openless -all/app/src/pages/History.tsx index 453b2abb..11b9c544 100644 --- a/openless -all/app/src/pages/History.tsx +++ b/openless -all/app/src/pages/History.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon'; +import { detectOS } from '../components/WindowChrome'; import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -27,6 +28,7 @@ export function History() { const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); + const hotkeyLabel = detectOS() === 'win' ? '右 Alt' : '右 Option'; const refresh = async () => { const data = await listHistory(); @@ -114,7 +116,7 @@ export function History() { {loading &&
加载中…
} {!loading && filtered.length === 0 && (
- 还没有历史记录。按 右 Option 录一段试试。 + 还没有历史记录。按 {hotkeyLabel} 录一段试试。
)} {filtered.map(s => ( @@ -181,7 +183,7 @@ export function History() { {item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && ( {item.dictionaryEntryCount} 个热词 )} - {item.insertStatus === 'inserted' ? '已插入' : item.insertStatus === 'copiedFallback' ? '已复制(需 ⌘V)' : '插入失败'} + {item.insertStatus === 'inserted' ? '已插入' : item.insertStatus === 'copiedFallback' ? `已复制(需 ${detectOS() === 'win' ? 'Ctrl+V' : '⌘V'})` : '插入失败'} ) : ( diff --git a/openless -all/app/src/pages/Overview.tsx b/openless -all/app/src/pages/Overview.tsx index 90c3c058..5455f7d3 100644 --- a/openless -all/app/src/pages/Overview.tsx +++ b/openless -all/app/src/pages/Overview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon'; +import { detectOS } from '../components/WindowChrome'; import { getCredentials, listHistory } from '../lib/ipc'; import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -13,12 +14,18 @@ const MODE_LABEL: Record = { formal: '正式表达', }; -export function Overview() { +interface OverviewProps { + onOpenHistory?: () => void; +} + +export function Overview({ onOpenHistory }: OverviewProps) { const [history, setHistory] = useState([]); const [creds, setCreds] = useState({ volcengineConfigured: false, arkConfigured: false, }); + const os = detectOS(); + const hotkeyLabel = os === 'win' ? '右 Alt' : '右 Option'; useEffect(() => { listHistory().then(setHistory); @@ -76,7 +83,7 @@ export function Overview() { background: '#fff', borderRadius: 5, border: '0.5px solid var(--ol-line-strong)', color: 'var(--ol-ink)', - }}>右 Option + }}>{hotkeyLabel} 开始录音 } @@ -119,12 +126,12 @@ export function Overview() {
最近识别 - 全部记录 → + 全部记录 →
{history.length === 0 && (
- 还没有记录。按 右 Option 开始第一次录音。 + 还没有记录。按 {hotkeyLabel} 开始第一次录音。
)} {history.slice(0, 5).map(s => ( diff --git a/openless -all/app/src/pages/Settings.tsx b/openless -all/app/src/pages/Settings.tsx index b1294640..e7d5ef74 100644 --- a/openless -all/app/src/pages/Settings.tsx +++ b/openless -all/app/src/pages/Settings.tsx @@ -4,11 +4,14 @@ import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; import { Icon } from '../components/Icon'; +import { detectOS } from '../components/WindowChrome'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { checkAccessibilityPermission, checkMicrophonePermission, + getHotkeyStatus, getSettings, + openExternal, openSystemSettings, readCredential, requestAccessibilityPermission, @@ -16,7 +19,7 @@ import { setCredential, setSettings, } from '../lib/ipc'; -import type { HotkeyMode, HotkeyTrigger, PermissionStatus, UserPreferences } from '../lib/types'; +import type { HotkeyMode, HotkeyStatus, HotkeyTrigger, PermissionStatus, UserPreferences } from '../lib/types'; import { Btn, Card, PageHeader, Pill } from './_atoms'; interface SettingsProps { @@ -101,7 +104,7 @@ const TRIGGER_LABEL: Record = { rightAlt: '右 Alt', }; -const TRIGGER_OPTIONS: HotkeyTrigger[] = [ +const MAC_TRIGGER_OPTIONS: HotkeyTrigger[] = [ 'rightOption', 'leftOption', 'rightControl', @@ -110,8 +113,17 @@ const TRIGGER_OPTIONS: HotkeyTrigger[] = [ 'fn', ]; +const WIN_TRIGGER_OPTIONS: HotkeyTrigger[] = [ + 'rightAlt', + 'rightControl', + 'leftControl', + 'rightCommand', +]; + function RecordingSection() { const [prefs, setPrefs] = useState(null); + const os = detectOS(); + const triggerOptions = os === 'win' ? WIN_TRIGGER_OPTIONS : MAC_TRIGGER_OPTIONS; useEffect(() => { getSettings().then(setPrefs); @@ -156,7 +168,7 @@ function RecordingSection() { fontFamily: 'var(--ol-font-mono)', }} > - {TRIGGER_OPTIONS.map(t => ( + {triggerOptions.map(t => ( ))} @@ -326,12 +338,13 @@ const iconBtnStyle: CSSProperties = { }; function ShortcutsSection() { + const os = detectOS(); const rows: Array<[string, string]> = [ - ['开始 / 停止录音', '右 Option'], + ['开始 / 停止录音', os === 'win' ? '右 Alt' : '右 Option'], ['取消本次录音', 'Esc'], ['胶囊确认插入', '点击右侧 ✓'], - ['切换上一次风格', '⌘ ⇧ S'], - ['打开 OpenLess', '⌘ ⇧ O'], + ['切换上一次风格', os === 'win' ? '暂未支持' : '⌘ ⇧ S'], + ['打开 OpenLess', os === 'win' ? '暂未支持' : '⌘ ⇧ O'], ]; return ( @@ -356,6 +369,7 @@ function ShortcutsSection() { function PermissionsSection() { const [accessibility, setAccessibility] = useState('loading'); const [microphone, setMicrophone] = useState('loading'); + const [hotkey, setHotkey] = useState(null); const refresh = async () => { const [a, m] = await Promise.all([ @@ -364,6 +378,7 @@ function PermissionsSection() { ]); setAccessibility(a); setMicrophone(m); + setHotkey(await getHotkeyStatus()); }; useEffect(() => { @@ -423,6 +438,16 @@ function PermissionsSection() { )}
+ +
+ + {hotkey?.message && ( + + {hotkey.message} + + )} +
+
可用 @@ -463,12 +488,25 @@ function AboutSection() {
自然说话,完美书写 · {APP_VERSION_LABEL}
- 检查 - openless.app/docs - GitHub Issues + openExternal('https://github.com/appergb/openless/releases')}>打开 Releases + openExternal('https://github.com/appergb/openless#readme')}>README + openExternal('https://github.com/appergb/openless/issues')}>GitHub Issues 本地优先
); } + +function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) { + if (!status) { + return 检查中…; + } + if (status.state === 'installed') { + return 已安装; + } + if (status.state === 'starting') { + return 安装中…; + } + return 监听失败; +}