{
- void updatePrefs({ ...prefs, multimodalPipelineEnabled }).catch(error => {
+ void updatePrefs(current => ({ ...current, multimodalPipelineEnabled })).catch(error => {
console.error('[settings] failed to update multimodal pipeline flag', error);
});
};
return (
-
+
{t('settings.advanced.multimodalPipelineTitle')}
-
+
+
+ {children}
+
+ {badge}
+
+
+
+ )
+}
+
// 页面瘦身:设置页描述文案全部隐藏(保留组件签名 + 调用点,便于需要时恢复)。
export function SectionDesc(_props: {
children: ReactNode
diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx
index 6527fd3cc..299af2241 100644
--- a/openless-all/app/src/state/HotkeySettingsContext.tsx
+++ b/openless-all/app/src/state/HotkeySettingsContext.tsx
@@ -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
@@ -42,6 +43,24 @@ const HotkeySettingsContext = createContext(
const errorMessage = (error: unknown) =>
String(error instanceof Error ? error.message : error)
+type PreferenceKey = keyof UserPreferences
+
+const changedPreferenceEntries = (
+ previous: UserPreferences,
+ next: UserPreferences,
+) => {
+ const changes = new Map()
+ for (const key of Object.keys(next) as PreferenceKey[]) {
+ if (!Object.is(previous[key], next[key])) {
+ changes.set(key, next[key])
+ }
+ }
+ return changes
+}
+
+const samePreferences = (left: UserPreferences, right: UserPreferences) =>
+ JSON.stringify(left) === JSON.stringify(right)
+
export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
const [prefs, setPrefs] = useState(null)
const [capability, setCapability] = useState(null)
@@ -50,21 +69,60 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
const persistQueueRef = useRef>(Promise.resolve())
const latestPrefsRef = useRef(null)
const persistedPrefsRef = useRef(null)
+ const writeGateRef = useRef(
+ new PreferencesWriteGate(samePreferences),
+ )
+ const prefsChangeVersionRef = useRef(0)
+ const pendingLocalChangesRef = useRef(
+ new Map(),
+ )
+
+ const applyIncomingPrefs = useCallback(
+ (nextPrefs: UserPreferences, persistedPrefs = nextPrefs) => {
+ prefsChangeVersionRef.current += 1
+ latestPrefsRef.current = nextPrefs
+ persistedPrefsRef.current = persistedPrefs
+ setPrefs(nextPrefs)
+ applyThemeFromPreference(nextPrefs.themeMode ?? "system")
+ },
+ [],
+ )
+
+ const mergePendingLocalChanges = useCallback(
+ (incoming: UserPreferences) => {
+ const merged = { ...incoming }
+ for (const [key, value] of pendingLocalChangesRef.current) {
+ Object.assign(merged, { [key]: value })
+ }
+ return merged
+ },
+ [],
+ )
+
+ const waitForPersistence = useCallback(async () => {
+ while (true) {
+ const observedQueue = persistQueueRef.current
+ await observedQueue.catch(() => undefined)
+ if (observedQueue === persistQueueRef.current) return
+ }
+ }, [])
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
+ await waitForPersistence()
+ const readVersion = prefsChangeVersionRef.current
const [prefsResult, capabilityResult] = await Promise.allSettled([
getSettings(),
getHotkeyCapability(),
])
let nextError: string | null = null
if (prefsResult.status === "fulfilled") {
- latestPrefsRef.current = prefsResult.value
- persistedPrefsRef.current = prefsResult.value
- setPrefs(prefsResult.value)
- applyThemeFromPreference(prefsResult.value.themeMode ?? "system")
+ if (prefsChangeVersionRef.current === readVersion) {
+ const mergedPrefs = mergePendingLocalChanges(prefsResult.value)
+ applyIncomingPrefs(mergedPrefs, prefsResult.value)
+ }
} else {
console.error(
"[hotkey-settings] failed to load preferences",
@@ -91,19 +149,39 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
} finally {
setLoading(false)
}
- }, [])
+ }, [applyIncomingPrefs, mergePendingLocalChanges, waitForPersistence])
const queueSetSettings = useCallback(
- (resolved: UserPreferences) => {
+ (
+ resolved: UserPreferences,
+ previous: UserPreferences | null = latestPrefsRef.current,
+ trackLocalChanges = true,
+ ) => {
+ if (trackLocalChanges && previous) {
+ for (const [key, value] of changedPreferenceEntries(previous, resolved)) {
+ pendingLocalChangesRef.current.set(key, value)
+ }
+ }
+ const finishWrite = writeGateRef.current.beginWrite(resolved)
+ 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 ?? undefined)) {
+ pendingLocalChangesRef.current.clear()
+ if (savedPrefs) applyIncomingPrefs(savedPrefs)
+ }
})
persistQueueRef.current = task
return task
},
- [],
+ [applyIncomingPrefs],
)
useEffect(() => {
@@ -122,10 +200,21 @@ 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")
+ const incoming = writeGateRef.current.receiveIncoming(nextPrefs)
+ if (incoming.isOwnWrite) return
+
+ const mergedPrefs = mergePendingLocalChanges(nextPrefs)
+ applyIncomingPrefs(mergedPrefs, nextPrefs)
+ if (incoming.wasPending) {
+ // The queued full snapshot may still overwrite fields changed by
+ // another window. Re-save the reconciled snapshot after it drains.
+ void queueSetSettings(mergedPrefs, null, false).catch((error) => {
+ console.warn(
+ "[settings] reconcile external preference change failed",
+ error,
+ )
+ })
+ }
},
)
if (cancelled) {
@@ -144,7 +233,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
cancelled = true
unlisten?.()
}
- }, [])
+ }, [applyIncomingPrefs, mergePendingLocalChanges, queueSetSettings])
useEffect(() => {
latestPrefsRef.current = prefs
@@ -178,9 +267,10 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
return
}
const merged = { ...currentPrefs, ...nextLocalePrefs }
+ prefsChangeVersionRef.current += 1
latestPrefsRef.current = merged
setPrefs(merged)
- void queueSetSettings(merged).catch((error) => {
+ void queueSetSettings(merged, currentPrefs).catch((error) => {
console.warn(
"[settings] sync locale output preferences failed",
error,
@@ -199,10 +289,10 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
const resolved = typeof next === "function" ? next(current) : next
if (resolved === current) return
setPrefs(resolved)
+ prefsChangeVersionRef.current += 1
latestPrefsRef.current = resolved
try {
- await queueSetSettings(resolved)
- persistedPrefsRef.current = resolved
+ await queueSetSettings(resolved, current)
} catch (error) {
// 兜底(#904):保存失败必须回滚乐观状态并可见,
// 不能出现界面显示已切换、重启后回退的“假保存”。
diff --git a/openless-all/app/src/state/preferencesWriteGate.test.ts b/openless-all/app/src/state/preferencesWriteGate.test.ts
new file mode 100644
index 000000000..c157d0d45
--- /dev/null
+++ b/openless-all/app/src/state/preferencesWriteGate.test.ts
@@ -0,0 +1,44 @@
+import { PreferencesWriteGate } from './preferencesWriteGate';
+
+function assert(condition: boolean, message: string) {
+ if (!condition) throw new Error(message);
+}
+
+const gate = new PreferencesWriteGate();
+assert(gate.shouldApplyIncoming(), 'remote preference events should apply while idle');
+const idleIncoming = gate.receiveIncoming('idle');
+assert(idleIncoming.value === 'idle', 'idle events should be returned immediately');
+assert(!idleIncoming.isOwnWrite, 'an unrelated idle event is not a local echo');
+
+const finishFirst = gate.beginWrite('first');
+assert(!gate.shouldApplyIncoming(), 'an older event must not replace an optimistic local update');
+const externalIncoming = gate.receiveIncoming('latest');
+assert(externalIncoming.wasPending, 'events during a write must be marked as pending');
+assert(!externalIncoming.isOwnWrite, 'external events must not be discarded as local echoes');
+const ownIncoming = gate.receiveIncoming('first');
+assert(ownIncoming.isOwnWrite, 'the write payload must be recognized as its own echo');
+
+const finishSecond = gate.beginWrite('second');
+assert(!gate.receiveIncoming('old').isOwnWrite, 'unrelated queued events must remain visible to the caller');
+assert(finishFirst('canonical-first') === 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('canonical-second'), 'the final queued write must apply its canonical preference payload');
+assert(gate.shouldApplyIncoming(), 'remote preference events should resume after local writes finish');
+assert(gate.receiveIncoming('canonical-second').isOwnWrite, 'delayed canonical echoes must be recognized');
+
+const objectGate = new PreferencesWriteGate<{ value: string }>(
+ (left, right) => left.value === right.value,
+);
+const finishObjectWrite = objectGate.beginWrite({ value: 'saved' });
+assert(
+ objectGate.receiveIncoming({ value: 'saved' }).isOwnWrite,
+ 'structurally equal event payloads must be recognized as local echoes',
+);
+finishObjectWrite();
+
+// 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');
diff --git a/openless-all/app/src/state/preferencesWriteGate.ts b/openless-all/app/src/state/preferencesWriteGate.ts
new file mode 100644
index 000000000..c528c5857
--- /dev/null
+++ b/openless-all/app/src/state/preferencesWriteGate.ts
@@ -0,0 +1,50 @@
+export interface IncomingPreference {
+ value: T;
+ wasPending: boolean;
+ isOwnWrite: boolean;
+}
+
+/** Correlates preference broadcasts with writes initiated by this webview. */
+export class PreferencesWriteGate {
+ private readonly pendingWrites = new Map();
+ private readonly recentWrites: T[] = [];
+ private nextWriteId = 0;
+
+ constructor(
+ private readonly equals: (left: T, right: T) => boolean = Object.is,
+ ) {}
+
+ beginWrite(value: T): (canonical?: T) => boolean {
+ const writeId = this.nextWriteId++;
+ this.pendingWrites.set(writeId, value);
+ let finished = false;
+ return (canonical) => {
+ if (finished) return false;
+ finished = true;
+ this.pendingWrites.delete(writeId);
+ this.remember(value);
+ if (canonical !== undefined) this.remember(canonical);
+ return this.pendingWrites.size === 0;
+ };
+ }
+
+ shouldApplyIncoming(): boolean {
+ return this.pendingWrites.size === 0;
+ }
+
+ receiveIncoming(value: T): IncomingPreference {
+ const wasPending = this.pendingWrites.size > 0;
+ const isOwnWrite = [
+ ...this.pendingWrites.values(),
+ ...this.recentWrites,
+ ].some(expected => this.equals(expected, value));
+ return { value, wasPending, isOwnWrite };
+ }
+
+ private remember(value: T) {
+ const previousIndex = this.recentWrites.findIndex(item => this.equals(item, value));
+ if (previousIndex >= 0) this.recentWrites.splice(previousIndex, 1);
+ this.recentWrites.push(value);
+ if (this.recentWrites.length > 16) this.recentWrites.shift();
+ }
+}
diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css
index 3b443344c..b524d8501 100644
--- a/openless-all/app/src/styles/global.css
+++ b/openless-all/app/src/styles/global.css
@@ -7,9 +7,11 @@ html, body, #root {
overflow: hidden;
}
-html[data-ol-platform="linux"],
-html[data-ol-platform="linux"] body,
-html[data-ol-platform="linux"] #root {
+/* Linux 主窗口无系统级毛玻璃,保留不透明底;胶囊窗排除在外 —— 它依赖
+ 透明窗口渲染 Siri 光效,X11 会话下透明可用(Wayland 不显示胶囊窗)。 */
+html[data-ol-platform="linux"]:not([data-ol-window="capsule"]),
+html[data-ol-platform="linux"]:not([data-ol-window="capsule"]) body,
+html[data-ol-platform="linux"]:not([data-ol-window="capsule"]) #root {
background: var(--ol-surface);
}
@@ -188,11 +190,11 @@ a { color: inherit; text-decoration: none; }
pointer-events: none;
}
-html[data-ol-platform="linux"] .ol-frost {
+html[data-ol-platform="linux"]:not([data-ol-window="capsule"]) .ol-frost {
background: var(--ol-surface);
}
-html[data-ol-platform="linux"] .ol-frost::before {
+html[data-ol-platform="linux"]:not([data-ol-window="capsule"]) .ol-frost::before {
content: none;
}
diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp
index 27a436f37..6ef8ad15e 100644
--- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp
+++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp
@@ -262,25 +262,35 @@ class OpenLess final : public AddonInstance,
// 返回 void 而非 std::tuple<>,以匹配 FCITX_OBJECT_VTABLE_METHOD 的 RET("")
void commitText(const std::string &text) {
- // 优先使用快捷键按下时保存的输入上下文(savedIc_),
- // 此时用户在目标 app 中,此后胶囊窗口抢焦点不影响提交。
- // 若 savedIc_ 为空则兜底用 foreachFocused。
- auto *ic = savedIc_;
- if (!ic) {
- FCITX_LOGC(openless, Warn)
- << "CommitText: savedIc_ is null, trying foreachFocused";
+ // 优先用**当前焦点 IC**(实时),savedIc_ 仅作焦点缺失时的兜底。
+ //
+ // 原实现优先 savedIc_(快捷键按下时快照),前提是快捷键事件经由 fcitx5
+ // 流过、能顺带刷新快照。但 GNOME Wayland 下全局热键(Mutter 抓取)不会
+ // 到达 fcitx5,savedIc_ 停留在「上一次某个 app 走过 fcitx 按键」的时刻
+ // —— CommitText 会把文字提交到那个陈旧窗口,造成「说话在 A 窗口、
+ // 文字落在 B 窗口」。Linux 无胶囊窗口抢焦点的问题,实时焦点即用户
+ // 意图目标;焦点暂时拿不到时退回 savedIc_。
+ auto *ic = static_cast(nullptr);
+ {
auto &mgr = instance_->inputContextManager();
mgr.foreachFocused([&](InputContext *focusedIc) {
ic = focusedIc;
return false;
});
}
+ if (!ic) {
+ FCITX_LOGC(openless, Warn)
+ << "CommitText: no focused IC, falling back to savedIc_";
+ ic = savedIc_;
+ }
if (!ic) {
FCITX_LOGC(openless, Warn)
<< "CommitText: no input context available";
throw std::runtime_error("no focused input context");
}
- FCITX_LOGC(openless, Debug) << "CommitText: " << text;
+ FCITX_LOGC(openless, Info)
+ << "CommitText: " << text.size() << " bytes, program=["
+ << ic->program() << "] display=[" << ic->display() << "]";
ic->commitString(text);
}