diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 5e8078ed..a185d183 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.2.6", + "version": "1.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.2.6", + "version": "1.2.7", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-shell": "^2.0.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 52998543..ebcfb78f 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "1.2.6", + "version": "1.2.7", "type": "module", "scripts": { "dev": "vite", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 1faae85a..d6c0c368 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3250,7 +3250,7 @@ dependencies = [ [[package]] name = "openless" -version = "1.2.6" +version = "1.2.7" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 057d444e..1c5533ef 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "1.2.6" +version = "1.2.7" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] edition = "2021" diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 79d3dcfe..60010d44 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -497,46 +497,45 @@ impl VolcengineStreamingASR { impl AudioConsumer for VolcengineStreamingASR { fn consume_pcm_chunk(&self, pcm: &[u8]) { - let runtime = { + // 一次性把就绪 chunk 全部 drain 出来(同一把 state 锁内分配 seq,保证 seq 单调)。 + // 然后 spawn 一个串行 send 的 task —— 不要每块一个 spawn,否则 burst flush 时多 + // 个 task 异步竞争 writer 锁,发送顺序和 seq 顺序对不上,服务端会报 + // "autoAssignedSequence (N) mismatch sequence in request (N+1)" 直接断连。 + let (runtime, chunks) = { let mut st = self.state.lock(); if !st.is_connected { return; } st.pending_audio.extend_from_slice(pcm); - st.runtime.clone() + + let mut out: Vec<(i32, Vec)> = Vec::new(); + while st.pending_audio.len() >= TARGET_AUDIO_CHUNK_BYTES { + let chunk: Vec = st.pending_audio.drain(..TARGET_AUDIO_CHUNK_BYTES).collect(); + let seq = st.next_sequence; + st.next_sequence += 1; + st.bytes_sent += chunk.len(); + st.frames_sent += 1; + out.push((seq, chunk)); + } + (st.runtime.clone(), out) }; + if chunks.is_empty() { + return; + } let Some(runtime) = runtime else { return; }; - // Drain as many full chunks as we have, spawning one send per chunk. - loop { - let chunk_and_seq = { - let mut st = self.state.lock(); - if st.pending_audio.len() < TARGET_AUDIO_CHUNK_BYTES { - None - } else { - let chunk: Vec = - st.pending_audio.drain(..TARGET_AUDIO_CHUNK_BYTES).collect(); - let seq = st.next_sequence; - st.next_sequence += 1; - st.bytes_sent += chunk.len(); - st.frames_sent += 1; - Some((chunk, seq)) - } - }; - - let Some((chunk, seq)) = chunk_and_seq else { - break; - }; - - let writer = Arc::clone(&self.writer); - // pending_sends + Notify 让 send_last_frame 知道何时所有 chunk 都已发出。 - self.pending_sends.fetch_add(1, Ordering::SeqCst); - let pending = Arc::clone(&self.pending_sends); - let notify = Arc::clone(&self.send_done); - runtime.spawn(async move { + // pending_sends + Notify 让 send_last_frame 知道何时所有 chunk 都已发出。 + // 单 task 内串行 send,所以一次性 +N、收尾 -N。 + let count = chunks.len(); + self.pending_sends.fetch_add(count, Ordering::SeqCst); + let writer = Arc::clone(&self.writer); + let pending = Arc::clone(&self.pending_sends); + let notify = Arc::clone(&self.send_done); + runtime.spawn(async move { + for (seq, chunk) in chunks { let frame = frame::build( MessageType::AudioOnlyRequest, Flags::PositiveSequence, @@ -548,11 +547,11 @@ impl AudioConsumer for VolcengineStreamingASR { // 把丢帧错误顶到日志里,定位"为什么服务端只收到 100ms" log::error!("[asr] audio frame seq={} send 失败: {}", seq, e); } - if pending.fetch_sub(1, Ordering::SeqCst) == 1 { - notify.notify_waiters(); - } - }); - } + } + if pending.fetch_sub(count, Ordering::SeqCst) == count { + notify.notify_waiters(); + } + }); } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 6891ca6e..823c8d49 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -76,7 +76,7 @@ fn default_true() -> bool { } #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(default, rename_all = "camelCase")] pub struct UserPreferences { pub hotkey: HotkeyBinding, pub default_mode: PolishMode, @@ -158,7 +158,7 @@ impl HotkeyAdapterKind { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] +#[serde(default, rename_all = "camelCase")] pub struct HotkeyBinding { pub trigger: HotkeyTrigger, pub mode: HotkeyMode, @@ -213,7 +213,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "默认建议使用“右 Control + 按住说话”;若无响应,可在权限页查看 hook 安装状态。" + "默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。" .into(), ), }; @@ -287,7 +287,7 @@ impl Default for HotkeyBinding { { Self { trigger: HotkeyTrigger::RightControl, - mode: HotkeyMode::Hold, + mode: HotkeyMode::Toggle, } } diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 5d8118d4..a45652ea 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "1.2.6", + "version": "1.2.7", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", @@ -26,7 +26,8 @@ "shadow": true, "hiddenTitle": true, "titleBarStyle": "Overlay", - "visible": true + "visible": true, + "acceptFirstMouse": true }, { "label": "capsule", diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx new file mode 100644 index 00000000..92e11a3d --- /dev/null +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -0,0 +1,202 @@ +// 自动更新共用模块 — Settings 的"关于"section 和 footer 按钮共用同一套 +// 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。 + +import { useEffect, useRef, useState } from 'react'; +import type { Update, DownloadEvent } from '@tauri-apps/plugin-updater'; +import { useTranslation } from 'react-i18next'; +import { isTauri, restartApp } from '../lib/ipc'; +import { Btn } from '../pages/_atoms'; + +export type UpdateStatus = + | 'idle' + | 'checking' + | 'available' + | 'none' + | 'downloading' + | 'installing' + | 'downloaded' + | 'error'; + +export interface UseAutoUpdate { + status: UpdateStatus; + version: string; + progress: number | null; + downloaded: number; + contentLength: number | null; + checking: boolean; + busy: boolean; + /** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */ + checkForUpdates: () => Promise; + /** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */ + installUpdate: () => Promise; + /** 关闭对话框(仅在非 busy 状态可用)。 */ + dismissDialog: () => Promise; +} + +export function useAutoUpdate(): UseAutoUpdate { + const updateRef = useRef(null); + const [status, setStatus] = useState('idle'); + const [version, setVersion] = useState(''); + const [downloaded, setDownloaded] = useState(0); + const [contentLength, setContentLength] = useState(null); + + const checking = status === 'checking'; + const busy = status === 'downloading' || status === 'installing'; + const progress = contentLength && contentLength > 0 + ? Math.min(100, Math.round((downloaded / contentLength) * 100)) + : null; + + const closeUpdate = async () => { + const current = updateRef.current; + updateRef.current = null; + if (current) { + try { + await current.close(); + } catch (error) { + console.warn('[updater] failed to close update resource', error); + } + } + }; + + useEffect(() => { + return () => { void closeUpdate(); }; + }, []); + + const resetProgress = () => { + setDownloaded(0); + setContentLength(null); + }; + + const checkForUpdates = async () => { + setStatus('checking'); + setVersion(''); + resetProgress(); + await closeUpdate(); + try { + if (!isTauri) { + setStatus('none'); + return; + } + const { check } = await import('@tauri-apps/plugin-updater'); + const next = await check(); + updateRef.current = next; + setVersion(next?.version ?? ''); + setStatus(next ? 'available' : 'none'); + } catch (error) { + console.error('[updater] failed to check update', error); + setStatus('error'); + } + }; + + const installUpdate = async () => { + const update = updateRef.current; + if (!update) return; + resetProgress(); + setStatus('downloading'); + try { + await update.download((event: DownloadEvent) => { + if (event.event === 'Started') { + resetProgress(); + setContentLength(event.data.contentLength ?? null); + } else if (event.event === 'Progress') { + setDownloaded(value => value + event.data.chunkLength); + } else if (event.event === 'Finished') { + setStatus('installing'); + } + }); + setStatus('installing'); + await update.install(); + await closeUpdate(); + setStatus('downloaded'); + } catch (error) { + console.error('[updater] failed to install update', error); + await closeUpdate(); + setStatus('error'); + } + }; + + const dismissDialog = async () => { + if (busy) return; + await closeUpdate(); + setStatus('idle'); + setVersion(''); + resetProgress(); + }; + + return { + status, + version, + progress, + downloaded, + contentLength, + checking, + busy, + checkForUpdates, + installUpdate, + dismissDialog, + }; +} + +export function isDialogStatus(status: UpdateStatus): status is 'available' | 'downloading' | 'installing' | 'downloaded' { + return status === 'available' || status === 'downloading' || status === 'installing' || status === 'downloaded'; +} + +export function UpdateDialog({ + status, + version, + progress, + downloaded, + contentLength, + onInstall, + onClose, +}: { + status: 'available' | 'downloading' | 'installing' | 'downloaded'; + version: string; + progress: number | null; + downloaded: number; + contentLength: number | null; + onInstall: () => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const downloading = status === 'downloading'; + const installing = status === 'installing'; + return ( +
+
+
{t(`settings.about.updateDialog.${status}.title`)}
+
+ {t(`settings.about.updateDialog.${status}.desc`, { version })} +
+ {(downloading || installing || status === 'downloaded') && ( +
+
+
+
+
+ {installing + ? t('settings.about.updateDialog.installingLabel') + : progress === null + ? t('settings.about.updateDialog.progressUnknown', { downloaded: formatBytes(downloaded) }) + : t('settings.about.updateDialog.progress', { progress, downloaded: formatBytes(downloaded), total: formatBytes(contentLength ?? 0) })} +
+
+ )} +
+ {status === 'available' && {t('common.cancel')}} + {status === 'available' && {t('settings.about.updateDialog.install')}} + {(downloading || installing) && {installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}} + {status === 'downloaded' && {t('settings.about.updateDialog.later')}} + {status === 'downloaded' && {t('settings.about.updateDialog.restartNow')}} +
+
+
+ ); +} + +function formatBytes(value: number) { + if (!Number.isFinite(value) || value <= 0) return '0 B'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index e237d8ca..8ad641a3 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -4,8 +4,9 @@ // // Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell. -import { useEffect, useMemo, useState, type ComponentType } from 'react'; +import { useEffect, useMemo, useState, type CSSProperties, type ComponentType, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate'; import { Icon } from './Icon'; import { WindowChrome, detectOS, type OS } from './WindowChrome'; import { SettingsModal } from './SettingsModal'; @@ -14,7 +15,13 @@ import { History } from '../pages/History'; import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { APP_VERSION_LABEL } from '../lib/appVersion'; +import { + HOTKEY_MODE_MIGRATION_ACK_KEY, + HOTKEY_MODE_MIGRATION_DEFERRED_KEY, + shouldShowHotkeyModeMigrationPrompt, +} from '../lib/hotkeyMigration'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { applyFontScale, readFontScale } from '../lib/fontScale'; import { getCredentials, openExternal } from '../lib/ipc'; import { OL_DATA } from '../lib/mockData'; import { @@ -39,6 +46,9 @@ const NAV_BASE: Array> = [ { id: 'style', icon: 'style', cmp: Style }, ]; +const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases'; +const HELP_DOCS_URL = 'https://github.com/appergb/openless#readme'; + interface FloatingShellProps { os?: OS; initialTab?: AppTab; @@ -59,7 +69,29 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings); const [settingsInitialSection, setSettingsInitialSection] = useState(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); + const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); + const [helpPopoverOpen, setHelpPopoverOpen] = useState(false); const { hotkey } = useHotkeySettings(); + + // 字体档位 — 启动时按 localStorage 应用一次;之后改动来自 Settings 的"个性化"section。 + useEffect(() => { + applyFontScale(readFontScale()); + }, []); + + // help popover 打开时,点击其他位置自动关闭 + useEffect(() => { + if (!helpPopoverOpen) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Element | null; + if (target && target.closest('[data-ol-footer-popover]')) return; + setHelpPopoverOpen(false); + }; + const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0); + return () => { + window.clearTimeout(id); + document.removeEventListener('mousedown', onDown); + }; + }, [helpPopoverOpen]); const NAV = useMemo( () => NAV_BASE.map(b => ({ ...b, name: t(`nav.${b.id}`) })), [t], @@ -80,11 +112,24 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia }; }, []); + useEffect(() => { + const acknowledgedValue = window.localStorage.getItem(HOTKEY_MODE_MIGRATION_ACK_KEY); + const deferredValue = window.sessionStorage.getItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY); + if (shouldShowHotkeyModeMigrationPrompt(acknowledgedValue, deferredValue)) { + setHotkeyModePromptOpen(true); + } + }, []); + const rememberProviderPrompt = () => { window.sessionStorage.setItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY, '1'); setProviderPromptOpen(false); }; + const deferHotkeyModePrompt = () => { + window.sessionStorage.setItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY, '1'); + setHotkeyModePromptOpen(false); + }; + const openSettings = (section?: SettingsSectionId) => { setSettingsInitialSection(section); setSettingsOpen(true); @@ -95,6 +140,12 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia openSettings('providers'); }; + const openHotkeyRecordingSettings = () => { + window.localStorage.setItem(HOTKEY_MODE_MIGRATION_ACK_KEY, '1'); + setHotkeyModePromptOpen(false); + openSettings('recording'); + }; + return (
@@ -109,13 +160,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia zIndex: 1, }}> - {/* Sidebar — inside the raised console */} + {/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */} - {/* Main content — inset white card sitting on the frosted backplate */} + {/* Main content — inset white card sitting on the frosted backplate. + 内卡圆角与外层窗口(WindowChrome 20/14)对齐,避免视觉上"两个不一致的圆角"。 */}
-
- {/* key={currentTab} 让每次切换重挂这棵子树 → ol-page-fade keyframe 重新触发 */} -
- {currentTab === 'overview' ? ( - setCurrentTab('history')} /> - ) : ( - - )} -
+ {/* key={currentTab} 让每次切换重挂这棵子树 → ol-page-slide keyframe 重新触发。 + padding + overflow:auto 直接挂在这棵 wrapper 上: + - 自然高度的页(Overview / Vocab / Style)—— 整页内容超出时 wrapper 出现滚动条 + - 用 height:100% 撑满的页(History 左右双列)—— 100% 能解析到 wrapper 的固定高度, + 两列内部各自的 overflow:auto 才能独立滚动 */} +
+ {currentTab === 'overview' ? ( + setCurrentTab('history')} /> + ) : ( + + )}
- {/* Footer — sits on frosted outer, like Typeless */} + {/* Footer — 透明地坐在外层磨砂底板上,跟 sidebar 同一片磨砂玻璃 */}
openSettings('providers')} /> openExternal('https://github.com/appergb/openless/issues')} /> openSettings()} /> - openExternal('https://github.com/appergb/openless#readme')} /> + + {/* 问号 — 点击展开版本说明 popover */} + setHelpPopoverOpen(o => !o)} + > + +
{t('shell.footer.version', { version: APP_VERSION_LABEL })} - +
{/* Settings modal — rendered inside this window */} @@ -291,24 +330,33 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia /> } - {providerPromptOpen && ( + {providerPromptOpen ? ( - )} + ) : hotkeyModePromptOpen ? ( + + ) : null} - {/* tab 切换 + provider prompt 公用的入场关键帧 */} + {/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */}
); @@ -407,6 +455,99 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; ); } +function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => void; onOpenSettings: () => void }) { + const { t } = useTranslation(); + return ( +
+
+
+
+ +
+
{t('shell.hotkeyModePrompt.title')}
+
+
+ {t('shell.hotkeyModePrompt.body')} +
+
+ + +
+
+
+ ); +} + interface FooterIconProps { name: string; tip: string; @@ -415,14 +556,23 @@ interface FooterIconProps { } function FooterIcon({ name, tip, active, onClick }: FooterIconProps) { + const [hover, setHover] = useState(false); + // 选中(active)= popover 打开,深灰;hover = 浅灰;其它 = 透明 + const background = active + ? 'rgba(0,0,0,0.10)' + : hover + ? 'rgba(0,0,0,0.05)' + : 'transparent'; return ( ); } + +// 把 footer icon 和它的 popover 绑在同一个相对定位容器里,popover 锚定在按钮正上方。 +function FooterIconWithPopover({ + name, tip, open, onToggle, children, +}: { + name: string; + tip: string; + open: boolean; + onToggle: () => void; + children: ReactNode; +}) { + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +function HelpPopoverBody() { + const { t } = useTranslation(); + return ( +
+
+ +
+
OpenLess
+
{APP_VERSION_LABEL}
+
+
+
+ {t('shell.footer.helpPopover.tagline')} +
+ + +
+ ); +} + +const popoverLinkStyle: CSSProperties = { + display: 'block', + width: '100%', + border: 0, + background: 'transparent', + color: 'var(--ol-blue)', + fontFamily: 'inherit', + fontSize: 12, + fontWeight: 500, + cursor: 'default', + textAlign: 'left', + padding: '6px 4px', +}; + +// Footer 的"检查更新"按钮 — 复用 Settings 页面的 useAutoUpdate hook + UpdateDialog 窗口, +// 跟"关于"section 走完全相同的状态机和确认对话框。按钮本身只显示触发文案 + 简短状态。 +function FooterAutoUpdateButton() { + const { t } = useTranslation(); + const u = useAutoUpdate(); + + const inlineHint = u.status === 'none' + ? t('settings.about.upToDate') + : u.status === 'error' + ? t('settings.about.updateError') + : null; + const inlineHintColor = u.status === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)'; + + return ( + <> + + {inlineHint && ( + {inlineHint} + )} + {isDialogStatus(u.status) && ( + + )} + + ); +} diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index fe7a0f68..79933f20 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; import { AboutUpdateControl, Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings'; import { Row } from './ui/Row'; +import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale'; import { openExternal } from '../lib/ipc'; import { FOLLOW_SYSTEM, @@ -186,11 +187,52 @@ function PersonalizeSection() { window.localStorage.setItem('ol.glassBlur', String(blur)); }, [blur]); + const [fontScale, setFontScaleState] = useState(() => readFontScale()); + const applyFontScaleChoice = (next: FontScaleId) => { + setFontScaleState(next); + setFontScale(next); + }; + const fontOptions: Array<[FontScaleId, string]> = [ + ['small', t('modal.personalize.fontSmall')], + ['medium', t('modal.personalize.fontMedium')], + ['large', t('modal.personalize.fontLarge')], + ]; + return (
+ +
+ {fontOptions.map(([id, label]) => { + const selected = fontScale === id; + return ( + + ); + })} +
+
= { + small: 0.9, + medium: 1.0, + large: 1.1, +}; + +const FONT_SCALE_KEY = 'ol-font-scale'; + +export function readFontScale(): FontScaleId { + try { + const v = window.localStorage.getItem(FONT_SCALE_KEY); + if (v === 'small' || v === 'medium' || v === 'large') return v; + } catch { /* localStorage 不可用:忽略,落回默认 */ } + return 'medium'; +} + +export function applyFontScale(id: FontScaleId): void { + const scale = FONT_SCALE_VALUES[id]; + // CSS zoom 不在 W3C 标准里但 WebKit/Blink 都支持;Tauri 桌面端走 Wry/WebKit,没问题。 + (document.documentElement.style as CSSStyleDeclaration & { zoom?: string }).zoom = String(scale); +} + +export function setFontScale(id: FontScaleId): void { + applyFontScale(id); + try { window.localStorage.setItem(FONT_SCALE_KEY, id); } catch { /* 忽略 */ } +} diff --git a/openless-all/app/src/lib/hotkeyMigration.ts b/openless-all/app/src/lib/hotkeyMigration.ts new file mode 100644 index 00000000..80d676f9 --- /dev/null +++ b/openless-all/app/src/lib/hotkeyMigration.ts @@ -0,0 +1,16 @@ +import { APP_VERSION } from './appVersion'; + +export const HOTKEY_MODE_MIGRATION_NOTICE_VERSION = '1.2.7'; +export const HOTKEY_MODE_MIGRATION_ACK_KEY = `ol.hotkeyModeMigrationAck:${HOTKEY_MODE_MIGRATION_NOTICE_VERSION}`; +export const HOTKEY_MODE_MIGRATION_DEFERRED_KEY = `ol.hotkeyModeMigrationDeferred:${HOTKEY_MODE_MIGRATION_NOTICE_VERSION}`; + +export function isHotkeyModeMigrationNoticeActive(): boolean { + return APP_VERSION === HOTKEY_MODE_MIGRATION_NOTICE_VERSION; +} + +export function shouldShowHotkeyModeMigrationPrompt( + acknowledgedValue: string | null, + deferredValue: string | null, +): boolean { + return isHotkeyModeMigrationNoticeActive() && acknowledgedValue !== '1' && deferredValue !== '1'; +} diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index df454eb7..2e506ac4 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -36,7 +36,7 @@ export async function invokeOrMock( // ── Mock fixtures ────────────────────────────────────────────────────── const mockSettings: UserPreferences = { - hotkey: { trigger: 'rightControl', mode: 'hold' }, + hotkey: { trigger: 'rightControl', mode: 'toggle' }, defaultMode: 'structured', enabledModes: ['raw', 'light', 'structured', 'formal'], launchAtLogin: false, @@ -52,7 +52,7 @@ const mockHotkeyCapability: HotkeyCapability = { supportsModifierOnlyTrigger: true, supportsSideSpecificModifiers: true, explicitFallbackAvailable: false, - statusHint: '默认建议使用“右 Control + 按住说话”;若无响应,可在权限页查看 hook 安装状态。', + statusHint: '默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。', }; const mockCredentialsStatus: CredentialsStatus = { diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 9ab41500..20111704 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -3,22 +3,21 @@ // keep their inline-style literals 1:1 with the source JSX. import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; -import type { Update, DownloadEvent } from '@tauri-apps/plugin-updater'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; +import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { APP_VERSION_LABEL } from '../lib/appVersion'; +import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; import { checkAccessibilityPermission, checkMicrophonePermission, getHotkeyStatus, - isTauri, openExternal, openSystemSettings, readCredential, requestAccessibilityPermission, requestMicrophonePermission, - restartApp, setActiveAsrProvider, setActiveLlmProvider, setCredential, @@ -146,6 +145,25 @@ function RecordingSection() {
{t('settings.recording.title')}
{t('settings.recording.desc')}
+ {isHotkeyModeMigrationNoticeActive() && ( +
+
+ {t('settings.recording.migrationNoticeTitle')} +
+
+ {t('settings.recording.migrationNoticeDesc')} +
+
+ )}