Files
Netcatty/domain/customKeyBindings.ts
陈大猫 c30d872852 fix(settings): guard customKeyBindings sync against echo loop (closes #818) (#821)
* fix(settings): guard customKeyBindings cross-window sync against echo loop (closes #818)

customKeyBindings was the only synced setting whose two cross-window
handlers (DOM storage event + IPC onSettingsChanged) called
setCustomKeyBindings unconditionally. Every broadcast landed with a
fresh parsed object reference, so React re-rendered and the persist
effect re-broadcast, echoing across windows indefinitely.

While the echoes carry the same content, a rapid second click from
the user can arrive between the outbound broadcast and an older
in-flight echo — the echo's setState then clobbers the latest click
and the UI "bounces" from Disabled back to the original binding.
This matches the report in #818 (disable and reset operations
flicker between values when clicked in quick succession).

Fix: mirror the equality guards used by every other synced field.
Compare the incoming payload (stringified for objects) against the
current value from settingsSnapshotRef, and skip setCustomKeyBindings
when they match. Add customKeyBindings to settingsSnapshotRef so the
IPC handler has access without pulling it into the effect's closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): stop shortcut sync bounce flicker

* fix(settings): harden shortcut sync ordering

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:34:38 +08:00

134 lines
3.3 KiB
TypeScript

import { CustomKeyBindings } from './models';
const SYNC_VERSION_FIELD = '__netcattySyncVersion';
const SYNC_ORIGIN_FIELD = '__netcattySyncOrigin';
export interface CustomKeyBindingsStorageRecord {
bindings: CustomKeyBindings;
version: number;
origin: string;
}
export const serializeCustomKeyBindings = (bindings: CustomKeyBindings): string =>
JSON.stringify(bindings);
export const areCustomKeyBindingsEqual = (a: CustomKeyBindings, b: CustomKeyBindings): boolean =>
serializeCustomKeyBindings(a) === serializeCustomKeyBindings(b);
export const parseCustomKeyBindingsStorageRecord = (
value: unknown,
): CustomKeyBindingsStorageRecord | null => {
let candidate = value;
if (typeof candidate === 'string') {
try {
candidate = JSON.parse(candidate);
} catch {
return null;
}
}
if (!candidate || typeof candidate !== 'object') {
return null;
}
const record = candidate as Record<string, unknown>;
if (
typeof record.version === 'number' &&
typeof record.origin === 'string' &&
record.bindings &&
typeof record.bindings === 'object'
) {
return {
version: record.version,
origin: record.origin,
bindings: record.bindings as CustomKeyBindings,
};
}
if (
typeof record[SYNC_VERSION_FIELD] === 'number' &&
typeof record[SYNC_ORIGIN_FIELD] === 'string' &&
record.bindings &&
typeof record.bindings === 'object'
) {
return {
version: record[SYNC_VERSION_FIELD] as number,
origin: record[SYNC_ORIGIN_FIELD] as string,
bindings: record.bindings as CustomKeyBindings,
};
}
return {
version: 0,
origin: 'legacy',
bindings: candidate as CustomKeyBindings,
};
};
export const serializeCustomKeyBindingsStorageRecord = (
record: CustomKeyBindingsStorageRecord,
): string =>
JSON.stringify({
[SYNC_VERSION_FIELD]: record.version,
[SYNC_ORIGIN_FIELD]: record.origin,
bindings: record.bindings,
});
export const nextCustomKeyBindingsSyncVersion = (
currentVersion: number,
now: number = Date.now(),
): number => Math.max(now, currentVersion + 1);
export const shouldApplyIncomingCustomKeyBindingsRecord = (
current: Pick<CustomKeyBindingsStorageRecord, 'version' | 'origin'>,
incoming: Pick<CustomKeyBindingsStorageRecord, 'version' | 'origin'>,
): boolean => {
if (incoming.version !== current.version) {
return incoming.version > current.version;
}
return incoming.origin > current.origin;
};
export const updateCustomKeyBinding = (
bindings: CustomKeyBindings,
bindingId: string,
scheme: 'mac' | 'pc',
newKey: string,
): CustomKeyBindings => ({
...bindings,
[bindingId]: {
...bindings[bindingId],
[scheme]: newKey,
},
});
export const resetCustomKeyBinding = (
bindings: CustomKeyBindings,
bindingId: string,
scheme?: 'mac' | 'pc',
): CustomKeyBindings => {
if (!scheme) {
const { [bindingId]: _removed, ...rest } = bindings;
return rest;
}
const existing = bindings[bindingId];
if (!existing) {
return bindings;
}
const nextBinding = { ...existing };
delete nextBinding[scheme];
if (Object.keys(nextBinding).length === 0) {
const { [bindingId]: _removed, ...rest } = bindings;
return rest;
}
return {
...bindings,
[bindingId]: nextBinding,
};
};