fix(windows): 补齐运行时反馈和平台化交互

This commit is contained in:
Cooper-X-Oak committed 2026-04-29 20:25:47 +08:00
1 parent a8843802ec
commit e01552d2fc
15 files changed
+264 -49

No files matched your search

+18 -3
View File
@@ -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<Coordinator>>;
@@ -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())
}
}
+34 -3
View File
@@ -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<Option<Arc<VolcengineStreamingASR>>>,
recorder: Mutex<Option<Recorder>>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
}
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<Inner>) {
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::<HotkeyEvent>();
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<Inner>) {
}
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<Inner>) -> 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),
);
+16 -3
View File
@@ -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<HotkeyEvent>,
status_tx: std::sync::mpsc::Sender<bool>,
) {
// 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);
}
}
+15 -6
View File
@@ -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")]
+1
View File
@@ -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,
+26 -4
View File
@@ -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
}
}
}
+24
View File
@@ -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<String>,
}
#[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.
+1 -1
View File
@@ -188,7 +188,7 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
);
break;
case 'done':
center = <CenterText text={`已插入 ${insertedChars}`} />;
center = <CenterText text={message || `已插入 ${insertedChars}`} />;
break;
case 'cancelled':
center = <CenterText text="已取消" />;
@@ -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<SettingsSectionId | undefined>();
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</kbd>
}}>{hotkeyLabel}</kbd>
<span style={{ color: 'var(--ol-ink-4)' }}>开始 / 停止</span>
</div>
</div>
@@ -211,7 +212,11 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
}}
>
<div style={{ padding: '24px 28px 32px', flex: 1, minHeight: 0, overflow: 'auto' }}>
<Page />
{currentTab === 'overview' ? (
<Overview onOpenHistory={() => setCurrentTab('history')} />
) : (
<Page />
)}
</div>
</main>
</div>
@@ -231,15 +236,31 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
zIndex: 2,
}}>
<FooterIcon name="user" tip="账户" />
<FooterIcon name="mail" tip="反馈" />
<FooterIcon name="user" tip="账户" onClick={() => openSettings('提供商')} />
<FooterIcon name="mail" tip="反馈" onClick={() => openExternal('https://github.com/appergb/openless/issues')} />
<FooterIcon name="settings" tip="设置" active={settingsOpen} onClick={() => openSettings()} />
<FooterIcon name="help" tip="帮助" />
<FooterIcon name="help" tip="帮助" onClick={() => openExternal('https://github.com/appergb/openless#readme')} />
<div style={{ flex: 1 }} />
<span style={{ fontFamily: 'var(--ol-font-sans)' }}>版本 {APP_VERSION_LABEL}</span>
<a style={{ color: 'var(--ol-blue)', marginLeft: 8, textDecoration: 'none', fontWeight: 500 }}>检查更新</a>
<button
onClick={() => openExternal('https://github.com/appergb/openless/releases')}
style={{
color: 'var(--ol-blue)',
marginLeft: 8,
textDecoration: 'none',
fontWeight: 500,
border: 0,
background: 'transparent',
fontFamily: 'inherit',
fontSize: 11,
cursor: 'default',
padding: 0,
}}
>
检查更新
</button>
</div>
{/* Settings modal — rendered inside this window */}
@@ -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<PermissionStatus>('notDetermined');
const [microphone, setMicrophone] = useState<PermissionStatus>('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) {
<PermissionStep
index={1}
title="辅助功能"
desc="用于监听全局快捷键(默认 右 Option)并把识别结果写入光标位置。"
title={os === 'win' ? '全局快捷键' : '辅助功能'}
desc={os === 'win'
? 'Windows 不需要 macOS 辅助功能权限;这里用于确认全局快捷键监听可用。'
: '用于监听全局快捷键(默认 右 Option)并把识别结果写入光标位置。'}
status={accessibility}
actionLabel={
accessibility === 'granted'
accessibility === 'notApplicable'
? '无需授权'
: accessibility === 'granted'
? '已授权'
: accessibility === 'denied'
? '打开系统设置'
: '授权'
}
onAction={onGrantAccessibility}
disabled={busy || accessibility === 'granted'}
hint="授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。"
disabled={busy || accessibility === 'granted' || accessibility === 'notApplicable'}
hint={os === 'mac' ? '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。' : undefined}
/>
<PermissionStep
+21 -2
View File
@@ -6,6 +6,7 @@ import type {
CredentialsStatus,
DictationSession,
DictionaryEntry,
HotkeyStatus,
PermissionStatus,
PolishMode,
UserPreferences,
@@ -48,6 +49,11 @@ const mockCredentialsStatus: CredentialsStatus = {
arkConfigured: true,
};
const mockHotkeyStatus: HotkeyStatus = {
state: 'installed',
message: null,
};
const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({
id: `mock-${i}`,
createdAt: new Date().toISOString(),
@@ -80,6 +86,10 @@ export function setSettings(prefs: UserPreferences): Promise<void> {
return invokeOrMock('set_settings', { prefs }, () => undefined);
}
export function getHotkeyStatus(): Promise<HotkeyStatus> {
return invokeOrMock('get_hotkey_status', undefined, () => mockHotkeyStatus);
}
// ── Credentials ────────────────────────────────────────────────────────
export function getCredentials(): Promise<CredentialsStatus> {
return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus);
@@ -144,8 +154,8 @@ export function cancelDictation(): Promise<void> {
}
// ── Polish ─────────────────────────────────────────────────────────────
export function repolish(id: string, mode: PolishMode): Promise<DictationSession> {
return invokeOrMock('repolish', { id, mode }, () => mockHistory[0]);
export function repolish(rawText: string, mode: PolishMode): Promise<string> {
return invokeOrMock('repolish', { rawText, mode }, () => rawText);
}
export function setDefaultPolishMode(mode: PolishMode): Promise<void> {
@@ -181,4 +191,13 @@ export function triggerMicrophonePrompt(): Promise<void> {
return invokeOrMock('trigger_microphone_prompt', undefined, () => undefined);
}
export async function openExternal(url: string): Promise<void> {
if (!isTauri) {
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
const { open } = await import('@tauri-apps/plugin-shell');
await open(url);
}
export { isTauri };
+7
View File
@@ -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;
+4 -2
View File
@@ -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<DictationSession[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(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 && <div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>加载中…</div>}
{!loading && filtered.length === 0 && (
<div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>
还没有历史记录。按 右 Option 录一段试试。
还没有历史记录。按 {hotkeyLabel} 录一段试试。
</div>
)}
{filtered.map(s => (
@@ -181,7 +183,7 @@ export function History() {
{item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && (
<span>{item.dictionaryEntryCount} 个热词</span>
)}
<span>{item.insertStatus === 'inserted' ? '已插入' : item.insertStatus === 'copiedFallback' ? '已复制(需 ⌘V)' : '插入失败'}</span>
<span>{item.insertStatus === 'inserted' ? '已插入' : item.insertStatus === 'copiedFallback' ? `已复制(需 ${detectOS() === 'win' ? 'Ctrl+V' : '⌘V'})` : '插入失败'}</span>
</div>
</>
) : (
+11 -4
View File
@@ -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<PolishMode, string> = {
formal: '正式表达',
};
export function Overview() {
interface OverviewProps {
onOpenHistory?: () => void;
}
export function Overview({ onOpenHistory }: OverviewProps) {
const [history, setHistory] = useState<DictationSession[]>([]);
const [creds, setCreds] = useState<CredentialsStatus>({
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</kbd>
}}>{hotkeyLabel}</kbd>
开始录音
</div>
}
@@ -119,12 +126,12 @@ export function Overview() {
<Card padding={0}>
<div style={{ padding: '14px 18px', borderBottom: '0.5px solid var(--ol-line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>最近识别</span>
<Btn size="sm" variant="ghost">全部记录 →</Btn>
<Btn size="sm" variant="ghost" onClick={onOpenHistory}>全部记录 →</Btn>
</div>
<div>
{history.length === 0 && (
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
还没有记录。按 右 Option 开始第一次录音。
还没有记录。按 {hotkeyLabel} 开始第一次录音。
</div>
)}
{history.slice(0, 5).map(s => (
+47 -9
View File
@@ -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<HotkeyTrigger, string> = {
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<UserPreferences | null>(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 => (
<option key={t} value={t}>{TRIGGER_LABEL[t]}</option>
))}
</select>
@@ -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 (
<Card>
@@ -356,6 +369,7 @@ function ShortcutsSection() {
function PermissionsSection() {
const [accessibility, setAccessibility] = useState<PermissionStatus | 'loading'>('loading');
const [microphone, setMicrophone] = useState<PermissionStatus | 'loading'>('loading');
const [hotkey, setHotkey] = useState<HotkeyStatus | null>(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() {
)}
</div>
</SettingRow>
<SettingRow label="全局快捷键" desc="用于判断 Windows 上快捷键监听是否已经安装。">
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0 }}>
<HotkeyStatusPill status={hotkey} />
{hotkey?.message && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hotkey.message}
</span>
)}
</div>
</SettingRow>
<SettingRow label="网络" desc="云端 ASR / LLM 调用所必需。本地模式可关闭。">
<Pill tone="ok"><Icon name="check" size={11} />可用</Pill>
</SettingRow>
@@ -463,12 +488,25 @@ function AboutSection() {
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>自然说话,完美书写 · {APP_VERSION_LABEL}</div>
</div>
</div>
<SettingRow label="检查更新"><Btn variant="ghost" size="sm">检查</Btn></SettingRow>
<SettingRow label="文档"><Btn variant="ghost" size="sm" icon="link">openless.app/docs</Btn></SettingRow>
<SettingRow label="反馈渠道"><Btn variant="ghost" size="sm" icon="link">GitHub Issues</Btn></SettingRow>
<SettingRow label="检查更新"><Btn variant="ghost" size="sm" onClick={() => openExternal('https://github.com/appergb/openless/releases')}>打开 Releases</Btn></SettingRow>
<SettingRow label="文档"><Btn variant="ghost" size="sm" icon="link" onClick={() => openExternal('https://github.com/appergb/openless#readme')}>README</Btn></SettingRow>
<SettingRow label="反馈渠道"><Btn variant="ghost" size="sm" icon="link" onClick={() => openExternal('https://github.com/appergb/openless/issues')}>GitHub Issues</Btn></SettingRow>
<SettingRow label="隐私" desc="所有识别结果仅保存在本机。云端 API 仅用于实时转写与润色,不会保留你的录音。">
<Pill tone="default">本地优先</Pill>
</SettingRow>
</Card>
);
}
function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) {
if (!status) {
return <Pill tone="default">检查中…</Pill>;
}
if (status.state === 'installed') {
return <Pill tone="ok"><Icon name="check" size={11} />已安装</Pill>;
}
if (status.state === 'starting') {
return <Pill tone="default">安装中…</Pill>;
}
return <Pill tone="outline">监听失败</Pill>;
}