fix(settings): keep experimental state consistent
This commit is contained in:
1 parent
2ca2a48c52
commit
1b4c4b8140
16 files changed
+210
-37
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Windows sherpa-onnx 本地 ASR 实施规划
|
||||
|
||||
> 状态:草案 / 待评审
|
||||
> 状态:历史规划 / 已实现(当前行为以代码与测试为准)
|
||||
> 日期:2026-05-12
|
||||
> 范围:仅 Windows;不替换 macOS `local-qwen3`;不替换 Windows `foundry-local-whisper`
|
||||
|
||||
@@ -58,7 +58,8 @@ asr/local/
|
||||
`#[cfg(target_os = "windows")]` 分支
|
||||
- `commands.rs`:增加准备 / 释放 / 状态 / 模型管理命令
|
||||
- `types.rs`:增加 `UserPreferences` 字段
|
||||
- 前端 Settings 高级页:在 Windows 下新增第三个本地 ASR toggle
|
||||
- 前端 Settings 服务页:本地模型板块负责模型管理;是否启用由「AI 提供商」中的
|
||||
`sherpa-onnx-local` 渠道开关统一控制,不再保留第二套本地 ASR 总开关
|
||||
|
||||
---
|
||||
|
||||
@@ -303,7 +304,7 @@ sherpa-onnx = "..." # 选最新稳定版,feature 关闭非必要后端
|
||||
- `sherpa_provider.rs` / `sherpa_runtime.rs` / `sherpa_models.rs` 文件结构
|
||||
- `ActiveAsr::SherpaOnnxLocal`
|
||||
- `commands.rs` 桩函数
|
||||
- 前端 toggle + i18n
|
||||
- 前端渠道开关 + i18n
|
||||
- **不实际推理**,先打通主链路(mock transcribe 返回空串或固定字符串)
|
||||
|
||||
### M2 Batch 推理可用 (1.5 周)
|
||||
|
||||
@@ -303,7 +303,7 @@ pub(crate) fn reconcile_hotkey_collisions(
|
||||
pub(crate) fn persist_settings<T: SettingsWriter>(
|
||||
coord: &T,
|
||||
prefs: UserPreferences,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<UserPreferences, String> {
|
||||
persist_settings_with_keyboard_apply(
|
||||
coord,
|
||||
prefs,
|
||||
@@ -436,7 +436,7 @@ pub(crate) fn persist_settings_with_keyboard_apply<T: SettingsWriter>(
|
||||
if coding_agent_changed {
|
||||
coord.refresh_coding_agent_hotkey();
|
||||
}
|
||||
Ok(())
|
||||
Ok(prefs)
|
||||
}
|
||||
|
||||
#[cfg(not(mobile))]
|
||||
@@ -446,7 +446,7 @@ pub fn set_settings(
|
||||
app: AppHandle,
|
||||
tray_microphones: State<'_, TrayMicrophoneMenuState>,
|
||||
mut prefs: UserPreferences,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<UserPreferences, String> {
|
||||
// 捕获旧值用于远程输入服务的 diff(persist 后端口/开关变化时启停/重启)。
|
||||
let remote_prev = coord.prefs().get();
|
||||
let packs = coord.style_packs().list().map_err(|e| e.to_string())?;
|
||||
@@ -502,7 +502,7 @@ pub fn set_settings(
|
||||
{
|
||||
coord.refresh_remote_server();
|
||||
}
|
||||
Ok(())
|
||||
Ok(prefs)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
|
||||
@@ -23,6 +23,7 @@ export const en: typeof zhCN = {
|
||||
hide: 'Hide',
|
||||
saved: 'Saved',
|
||||
saving: 'Saving…',
|
||||
experimental: 'Experimental',
|
||||
copied: 'Copied',
|
||||
operationFailed: 'Operation failed',
|
||||
add: 'Add',
|
||||
@@ -971,7 +972,7 @@ export const en: typeof zhCN = {
|
||||
asrXiaomiMimo: 'Xiaomi MiMo ASR',
|
||||
asrIflytek: 'iFlytek Realtime ASR',
|
||||
asrElevenLabs: 'ElevenLabs Scribe',
|
||||
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
|
||||
asrSherpaOnnxLocal: 'Local sherpa-onnx (Experimental)',
|
||||
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
|
||||
asrLocalQwen3: 'Local Qwen3-ASR',
|
||||
asrAppleSpeech: 'Apple Speech (macOS)',
|
||||
@@ -1261,12 +1262,12 @@ export const en: typeof zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: 'Multimodal recognition pipeline (experimental)',
|
||||
multimodalPipelineTitle: 'Multimodal recognition pipeline',
|
||||
multimodalPipelineTitleHint: 'One-pass audio recognition with a single multimodal model; traditional ASR + LLM configuration is fully isolated from it.',
|
||||
multimodalPipelineLabel: 'Enable multimodal pipeline',
|
||||
multimodalPipelineHint: 'Adds a Traditional / Multimodal switch on the AI providers page. Traditional = ASR + LLM; Multimodal = one audio-capable model. The two configurations are stored separately and never share credentials.',
|
||||
streamingInsertTitle: 'Streaming insertion',
|
||||
streamingInsertTitleLinux: 'Streaming insertion (experimental)',
|
||||
streamingInsertTitleLinux: 'Streaming insertion (Experimental)',
|
||||
streamingInsertDesc:
|
||||
'Streams text to cursor character by character, reducing perceived latency. Falls back to one-shot paste when conditions are not met.',
|
||||
streamingInsertLabel: 'Streaming insertion',
|
||||
@@ -1278,7 +1279,7 @@ export const en: typeof zhCN = {
|
||||
'Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.',
|
||||
streamingInsertSaveClipboardLabel: 'Copy to clipboard',
|
||||
streamingInsertSaveClipboardHint: 'After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.',
|
||||
localAsrTitle: 'Local ASR models (experimental)',
|
||||
localAsrTitle: 'Local ASR models',
|
||||
localAsrDesc: 'Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.',
|
||||
localAsrWarningShort: 'Local inference is slower; under-spec hardware may drop words.',
|
||||
qwen3Desc: 'Once enabled, the ASR provider will be taken over.',
|
||||
@@ -1559,7 +1560,7 @@ export const en: typeof zhCN = {
|
||||
foundryModelBaseDesc: 'Faster with lower resource use for lightweight daily dictation.',
|
||||
foundryModelTiny: 'Whisper Tiny (fastest / smoke test)',
|
||||
foundryModelTinyDesc: 'Fastest check option for confirming the Foundry path works.',
|
||||
sherpaTitle: 'Windows sherpa-onnx Local (experimental)',
|
||||
sherpaTitle: 'Windows sherpa-onnx Local (Experimental)',
|
||||
sherpaDesc: 'Windows uses sherpa-onnx for offline batch recognition on this device with no ASR API key.',
|
||||
sherpaRuntimeReady: 'Model loaded',
|
||||
sherpaRuntimeMissing: 'Model not loaded',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { zhCN } from './zh-CN';
|
||||
import { zhTW } from './zh-TW';
|
||||
import { en } from './en';
|
||||
import { ja } from './ja';
|
||||
import { ko } from './ko';
|
||||
|
||||
const locales = { zhCN, zhTW, en, ja, ko };
|
||||
|
||||
for (const [locale, messages] of Object.entries(locales)) {
|
||||
const marker = messages.common.experimental;
|
||||
if (!marker) throw new Error(`${locale} must define the shared experimental badge`);
|
||||
|
||||
for (const [key, title] of Object.entries({
|
||||
multimodalPipelineTitle: messages.settings.advanced.multimodalPipelineTitle,
|
||||
localAsrTitle: messages.settings.advanced.localAsrTitle,
|
||||
})) {
|
||||
if (title.includes(marker)) {
|
||||
throw new Error(`${locale}.${key} must leave the experimental marker to the shared title component`);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.localAsr.qwenExperimentalBadge !== marker) {
|
||||
throw new Error(`${locale} Qwen badge must use the same experimental wording`);
|
||||
}
|
||||
|
||||
for (const [key, title] of Object.entries({
|
||||
streamingInsertTitleLinux: messages.settings.advanced.streamingInsertTitleLinux,
|
||||
asrSherpaOnnxLocal: messages.settings.providers.presets.asrSherpaOnnxLocal,
|
||||
sherpaTitle: messages.localAsr.sherpaTitle,
|
||||
})) {
|
||||
if (!title.includes(marker)) {
|
||||
throw new Error(`${locale}.${key} must use the shared experimental wording`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('experimental title translations are consistent');
|
||||
@@ -25,6 +25,7 @@ export const ja: typeof zhCN = {
|
||||
hide: '非表示',
|
||||
saved: '保存しました',
|
||||
saving: '保存中',
|
||||
experimental: '実験的',
|
||||
copied: 'コピーしました',
|
||||
operationFailed: '操作に失敗しました',
|
||||
add: '追加',
|
||||
@@ -1229,7 +1230,7 @@ export const ja: typeof zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: 'マルチモーダル認識パイプライン(実験的)',
|
||||
multimodalPipelineTitle: 'マルチモーダル認識パイプライン',
|
||||
multimodalPipelineTitleHint: '1つのマルチモーダルモデルで音声認識を一括実行。従来の ASR + LLM 設定から完全に分離されます。',
|
||||
multimodalPipelineLabel: 'マルチモーダルパイプラインを有効化',
|
||||
multimodalPipelineHint: '有効にすると「サービス → AI プロバイダー」ページに従来 / マルチモーダルの切り替えが表示されます。従来 = ASR + LLM、マルチモーダル = 音声対応モデル1つ。設定は別々に保存され、認証情報を共有しません。',
|
||||
@@ -1246,7 +1247,7 @@ export const ja: typeof zhCN = {
|
||||
'fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。',
|
||||
streamingInsertSaveClipboardLabel: 'クリップボードに保存',
|
||||
streamingInsertSaveClipboardHint: '挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。',
|
||||
localAsrTitle: 'ローカル ASR モデル(実験的)',
|
||||
localAsrTitle: 'ローカル ASR モデル',
|
||||
localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。',
|
||||
localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。',
|
||||
qwen3Desc: '有効化すると ASR プロバイダーが引き継がれます。',
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ko: typeof zhCN = {
|
||||
hide: '숨기기',
|
||||
saved: '저장됨',
|
||||
saving: '저장 중',
|
||||
experimental: '실험적',
|
||||
copied: '복사됨',
|
||||
operationFailed: '작업 실패',
|
||||
add: '추가',
|
||||
@@ -1229,7 +1230,7 @@ export const ko: typeof zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: '멀티모달 인식 파이프라인 (실험적)',
|
||||
multimodalPipelineTitle: '멀티모달 인식 파이프라인',
|
||||
multimodalPipelineTitleHint: '단일 멀티모달 모델로 음성 인식을 한 번에 처리합니다. 기존 ASR + LLM 설정과 완전히 분리됩니다.',
|
||||
multimodalPipelineLabel: '멀티모달 파이프라인 활성화',
|
||||
multimodalPipelineHint: '활성화하면 「서비스 → AI 공급자」 페이지에 전통 / 멀티모달 전환이 나타납니다. 전통 = ASR + LLM, 멀티모달 = 오디오 지원 모델 1개. 두 설정은 별도로 저장되며 자격 증명을 공유하지 않습니다.',
|
||||
@@ -1246,7 +1247,7 @@ export const ko: typeof zhCN = {
|
||||
'fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.',
|
||||
streamingInsertSaveClipboardLabel: '클립보드에 저장',
|
||||
streamingInsertSaveClipboardHint: '삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.',
|
||||
localAsrTitle: '로컬 ASR 모델 (실험적)',
|
||||
localAsrTitle: '로컬 ASR 모델',
|
||||
localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.',
|
||||
localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.',
|
||||
qwen3Desc: '활성화하면 ASR 프로바이더가 인수됩니다.',
|
||||
|
||||
@@ -21,6 +21,7 @@ export const zhCN = {
|
||||
hide: '隐藏',
|
||||
saved: '已保存',
|
||||
saving: '保存中',
|
||||
experimental: '实验性',
|
||||
copied: '已复制',
|
||||
operationFailed: '操作失败',
|
||||
add: '添加',
|
||||
@@ -1259,12 +1260,12 @@ export const zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: '多模态识别管线(实验性)',
|
||||
multimodalPipelineTitle: '多模态识别管线',
|
||||
multimodalPipelineTitleHint: '用单个多模态模型一步完成语音识别;与传统 ASR + LLM 配置完全隔离。',
|
||||
multimodalPipelineLabel: '启用多模态识别管线',
|
||||
multimodalPipelineHint: '开启后,「服务 → AI 提供商」页出现「传统模式 / 多模态模式」切换。传统 = ASR + LLM;多模态 = 单个支持音频的模型。两套配置分开存储、绝不共享凭据。',
|
||||
streamingInsertTitle: '流式输入',
|
||||
streamingInsertTitleLinux: '流式输入(实验)',
|
||||
streamingInsertTitleLinux: '流式输入(实验性)',
|
||||
streamingInsertDesc:
|
||||
'逐字实时插入,降低感知延迟。不满足条件时回落到一次性粘贴。',
|
||||
streamingInsertLabel: '流式输入',
|
||||
@@ -1276,7 +1277,7 @@ export const zhCN = {
|
||||
'通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。',
|
||||
streamingInsertSaveClipboardLabel: '同步到剪贴板',
|
||||
streamingInsertSaveClipboardHint: '插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。',
|
||||
localAsrTitle: '本地 ASR 模型(实验性)',
|
||||
localAsrTitle: '本地 ASR 模型',
|
||||
localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。',
|
||||
localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。',
|
||||
qwen3Desc: '启动之后,ASR 提供商将被接管。',
|
||||
|
||||
@@ -23,6 +23,7 @@ export const zhTW: typeof zhCN = {
|
||||
hide: '隱藏',
|
||||
saved: '已保存',
|
||||
saving: '保存中',
|
||||
experimental: '實驗性',
|
||||
copied: '已複製',
|
||||
operationFailed: '操作失敗',
|
||||
add: '添加',
|
||||
@@ -1227,12 +1228,12 @@ export const zhTW: typeof zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: '多模態辨識管線(實驗性)',
|
||||
multimodalPipelineTitle: '多模態辨識管線',
|
||||
multimodalPipelineTitleHint: '用單一多模態模型一步完成語音辨識;與傳統 ASR + LLM 設定完全隔離。',
|
||||
multimodalPipelineLabel: '啟用多模態辨識管線',
|
||||
multimodalPipelineHint: '開啟後,「服務 → AI 提供者」頁出現「傳統模式 / 多模態模式」切換。傳統 = ASR + LLM;多模態 = 單一支援音訊的模型。兩套設定分開儲存、絕不共用憑證。',
|
||||
streamingInsertTitle: '流式輸入',
|
||||
streamingInsertTitleLinux: '流式輸入(實驗)',
|
||||
streamingInsertTitleLinux: '流式輸入(實驗性)',
|
||||
streamingInsertDesc:
|
||||
'逐字即時插入,降低感知延遲。不滿足條件時回落到一次性貼上。',
|
||||
streamingInsertLabel: '流式輸入',
|
||||
@@ -1244,7 +1245,7 @@ export const zhTW: typeof zhCN = {
|
||||
'通過 fcitx5 插件提交文字;串流輸入使用 enigo + XTest 合成按鍵。',
|
||||
streamingInsertSaveClipboardLabel: '同步到剪貼簿',
|
||||
streamingInsertSaveClipboardHint: '插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。',
|
||||
localAsrTitle: '本地 ASR 模型(實驗性)',
|
||||
localAsrTitle: '本地 ASR 模型',
|
||||
localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。',
|
||||
localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。',
|
||||
qwen3Desc: '啓動之後,ASR 提供商將被接管。',
|
||||
|
||||
@@ -13,9 +13,9 @@ export function getDefaultStyleSystemPrompts(): Promise<StyleSystemPrompts> {
|
||||
}))
|
||||
}
|
||||
|
||||
export function setSettings(prefs: UserPreferences): Promise<void> {
|
||||
export function setSettings(prefs: UserPreferences): Promise<UserPreferences> {
|
||||
return invokeOrMock("set_settings", { prefs }, () => {
|
||||
mockSetSettings(prefs)
|
||||
return undefined
|
||||
return prefs
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ExperimentalSectionTitle } from './shared';
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
ExperimentalSectionTitle({ children: 'Local ASR models', badge: 'Experimental' }),
|
||||
);
|
||||
|
||||
if (!markup.includes('Local ASR models')) {
|
||||
throw new Error('experimental section title must render its title');
|
||||
}
|
||||
if (!markup.includes('Experimental')) {
|
||||
throw new Error('experimental section title must render the shared badge');
|
||||
}
|
||||
if ((markup.match(/Experimental/g) ?? []).length !== 1) {
|
||||
throw new Error('experimental badge must be rendered exactly once');
|
||||
}
|
||||
|
||||
console.log('experimental section title tests passed');
|
||||
@@ -12,6 +12,7 @@ import { LocalAsr } from '../LocalAsr';
|
||||
import { detectOS } from '../../components/WindowChrome';
|
||||
import { getPlatformCapabilities } from '../../lib/platform';
|
||||
import { Card } from '../_atoms';
|
||||
import { ExperimentalSectionTitle } from './shared';
|
||||
|
||||
export function LocalModelSection() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,7 +32,9 @@ export function LocalModelSection() {
|
||||
Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>{t('settings.advanced.localAsrTitle')}</div>
|
||||
<ExperimentalSectionTitle badge={t('common.experimental')} style={{ marginBottom: 0 }}>
|
||||
{t('settings.advanced.localAsrTitle')}
|
||||
</ExperimentalSectionTitle>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import { Card } from '../_atoms';
|
||||
import { SettingRow, SectionTitle, Toggle } from './shared';
|
||||
import { ExperimentalSectionTitle, SettingRow, Toggle } from './shared';
|
||||
|
||||
export function MultimodalPipelineSection() {
|
||||
const { t } = useTranslation();
|
||||
@@ -21,16 +21,19 @@ export function MultimodalPipelineSection() {
|
||||
}
|
||||
|
||||
const onToggle = (multimodalPipelineEnabled: boolean) => {
|
||||
void updatePrefs({ ...prefs, multimodalPipelineEnabled }).catch(error => {
|
||||
void updatePrefs(current => ({ ...current, multimodalPipelineEnabled })).catch(error => {
|
||||
console.error('[settings] failed to update multimodal pipeline flag', error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<SectionTitle hint={t('settings.advanced.multimodalPipelineTitleHint')}>
|
||||
<ExperimentalSectionTitle
|
||||
badge={t('common.experimental')}
|
||||
hint={t('settings.advanced.multimodalPipelineTitleHint')}
|
||||
>
|
||||
{t('settings.advanced.multimodalPipelineTitle')}
|
||||
</SectionTitle>
|
||||
</ExperimentalSectionTitle>
|
||||
<SettingRow
|
||||
label={t('settings.advanced.multimodalPipelineLabel')}
|
||||
desc={t('settings.advanced.multimodalPipelineHint')}
|
||||
|
||||
@@ -44,6 +44,40 @@ export function SectionTitle({
|
||||
)
|
||||
}
|
||||
|
||||
export function ExperimentalSectionTitle({
|
||||
children,
|
||||
badge,
|
||||
hint,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode
|
||||
badge: string
|
||||
hint?: string
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<SectionTitle hint={hint} style={style}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
|
||||
<span>{children}</span>
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
borderRadius: 999,
|
||||
background: "var(--ol-blue-soft)",
|
||||
color: "var(--ol-blue)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</span>
|
||||
</SectionTitle>
|
||||
)
|
||||
}
|
||||
|
||||
// 页面瘦身:设置页描述文案全部隐藏(保留组件签名 + 调用点,便于需要时恢复)。
|
||||
export function SectionDesc(_props: {
|
||||
children: ReactNode
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n"
|
||||
import { applyThemeFromPreference } from "../lib/themeMode"
|
||||
import { emitSaved } from "../lib/savedEvent"
|
||||
import { PreferencesWriteGate } from "./preferencesWriteGate"
|
||||
|
||||
interface HotkeySettingsContextValue {
|
||||
prefs: UserPreferences | null
|
||||
@@ -50,6 +51,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
const persistQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const latestPrefsRef = useRef<UserPreferences | null>(null)
|
||||
const persistedPrefsRef = useRef<UserPreferences | null>(null)
|
||||
const writeGateRef = useRef(new PreferencesWriteGate<UserPreferences>())
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -93,17 +95,32 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyIncomingPrefs = useCallback((nextPrefs: UserPreferences) => {
|
||||
latestPrefsRef.current = nextPrefs
|
||||
persistedPrefsRef.current = nextPrefs
|
||||
setPrefs(nextPrefs)
|
||||
applyThemeFromPreference(nextPrefs.themeMode ?? "system")
|
||||
}, [])
|
||||
|
||||
const queueSetSettings = useCallback(
|
||||
(resolved: UserPreferences) => {
|
||||
const finishWrite = writeGateRef.current.beginWrite()
|
||||
let savedPrefs: UserPreferences | null = null
|
||||
const task = persistQueueRef.current
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await setSettings(resolved)
|
||||
savedPrefs = await setSettings(resolved)
|
||||
})
|
||||
.then(() => {
|
||||
persistedPrefsRef.current = savedPrefs
|
||||
})
|
||||
.finally(() => {
|
||||
if (finishWrite() && savedPrefs) applyIncomingPrefs(savedPrefs)
|
||||
})
|
||||
persistQueueRef.current = task
|
||||
return task
|
||||
},
|
||||
[],
|
||||
[applyIncomingPrefs],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,10 +139,10 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
(event) => {
|
||||
const nextPrefs = event.payload
|
||||
if (!nextPrefs) return
|
||||
latestPrefsRef.current = nextPrefs
|
||||
persistedPrefsRef.current = nextPrefs
|
||||
setPrefs(nextPrefs)
|
||||
applyThemeFromPreference(nextPrefs.themeMode ?? "system")
|
||||
// A save emits before its IPC promise resolves. An older queued save can
|
||||
// otherwise overwrite a newer optimistic click and make its toggle snap back.
|
||||
const applicable = writeGateRef.current.receiveIncoming(nextPrefs)
|
||||
if (applicable) applyIncomingPrefs(applicable)
|
||||
},
|
||||
)
|
||||
if (cancelled) {
|
||||
@@ -144,7 +161,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
cancelled = true
|
||||
unlisten?.()
|
||||
}
|
||||
}, [])
|
||||
}, [applyIncomingPrefs])
|
||||
|
||||
useEffect(() => {
|
||||
latestPrefsRef.current = prefs
|
||||
@@ -202,7 +219,6 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
latestPrefsRef.current = resolved
|
||||
try {
|
||||
await queueSetSettings(resolved)
|
||||
persistedPrefsRef.current = resolved
|
||||
} catch (error) {
|
||||
// 兜底(#904):保存失败必须回滚乐观状态并可见,
|
||||
// 不能出现界面显示已切换、重启后回退的“假保存”。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PreferencesWriteGate } from './preferencesWriteGate';
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const gate = new PreferencesWriteGate<string>();
|
||||
assert(gate.shouldApplyIncoming(), 'remote preference events should apply while idle');
|
||||
assert(gate.receiveIncoming('idle') === 'idle', 'idle events should be returned immediately');
|
||||
|
||||
const finishFirst = gate.beginWrite();
|
||||
assert(!gate.shouldApplyIncoming(), 'an older event must not replace an optimistic local update');
|
||||
assert(gate.receiveIncoming('latest') === null, 'events during a write must not replace an optimistic local update');
|
||||
|
||||
const finishSecond = gate.beginWrite();
|
||||
assert(gate.receiveIncoming('old') === null, 'a delayed older event must not be replayed after local writes finish');
|
||||
assert(finishFirst() === false, 'only the final queued write may apply its canonical preference payload');
|
||||
assert(!gate.shouldApplyIncoming(), 'all queued local writes must finish before events resume');
|
||||
|
||||
assert(finishSecond(), 'the final queued write must apply its canonical preference payload');
|
||||
assert(gate.shouldApplyIncoming(), 'remote preference events should resume after local writes finish');
|
||||
|
||||
// Completion callbacks can be reached from defensive cleanup paths; they must be idempotent.
|
||||
assert(finishSecond() === false, 'a completion callback must only release its event once');
|
||||
assert(gate.shouldApplyIncoming(), 'finishing one write twice must not corrupt the gate');
|
||||
|
||||
console.log('preference write gate tests passed');
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Prevents an older `prefs:changed` broadcast from overwriting an optimistic
|
||||
* local edit while one or more local writes are queued. Events cannot be
|
||||
* correlated to a particular write, so they are discarded instead of replayed
|
||||
* after the queue drains; the latest local value is already optimistic.
|
||||
*/
|
||||
export class PreferencesWriteGate<T = unknown> {
|
||||
private pendingWrites = 0;
|
||||
|
||||
beginWrite(): () => boolean {
|
||||
this.pendingWrites += 1;
|
||||
let finished = false;
|
||||
return () => {
|
||||
if (finished) return false;
|
||||
finished = true;
|
||||
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
|
||||
return this.pendingWrites === 0;
|
||||
};
|
||||
}
|
||||
|
||||
shouldApplyIncoming(): boolean {
|
||||
return this.pendingWrites === 0;
|
||||
}
|
||||
|
||||
receiveIncoming(value: T): T | null {
|
||||
if (this.shouldApplyIncoming()) return value;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user