fix(settings): keep experimental state consistent

This commit is contained in:
sim committed 2026-08-16 17:09:19 +08:00
1 parent 2ca2a48c52
commit 1b4c4b8140
16 files changed
+210 -37

No files matched your search

+4 -3
View File
@@ -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)]
+6 -5
View File
@@ -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');
+3 -2
View File
@@ -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 プロバイダーが引き継がれます。',
+3 -2
View File
@@ -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 프로바이더가 인수됩니다.',
+4 -3
View File
@@ -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 提供商将被接管。',
+4 -3
View File
@@ -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 提供商將被接管。',
+2 -2
View File
@@ -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;
}
}