* 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>
134 lines
3.3 KiB
TypeScript
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,
|
|
};
|
|
};
|