Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 5dd08d92e3 fix(sync): narrow complete-wipe shrink guard to groupConfigs
Only treat empty groupConfigs with other vault data present as suspicious.
Wiping the last snippet/note/etc. while hosts remain stays a normal delete.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:06:26 +00:00
Cursor Agentand陈大猫 42eb659dda fix(sync): rehydrate follow-app theme and guard partial groupConfig wipes
Second devices were keeping the local follow-app terminal theme flag after
cloud sync wrote peer appearance settings, which flickered black vs yellow.
Also block uploading an empty groupConfigs list while other vault data
remains so hydration races cannot clear startup-command group defaults.

Fixes #2757

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 02:57:33 +00:00
5 changed files with 149 additions and 0 deletions

No files matched your search

+23
View File
@@ -343,3 +343,26 @@ test("the race guards are wired into the real settings paths", () => {
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
});
test("cloud sync rehydrate picks up follow-app terminal theme from storage", () => {
// applySyncPayload writes followAppTerminalTheme to localStorage, then calls
// rehydrateAllFromStorage. Without reading TERM_FOLLOW_APP_THEME there, the
// open window keeps the pre-sync follow-app flag while terminalThemeId updates
// — black default vs synced yellow theme flicker (#2757).
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const rehydrateIndex = stateSource.indexOf("const rehydrateAllFromStorage = useCallback(() => {");
assert.ok(rehydrateIndex >= 0, "rehydrateAllFromStorage must exist");
const nextCallbackIndex = stateSource.indexOf("}, [applyIncomingCustomKeyBindings", rehydrateIndex);
assert.ok(nextCallbackIndex > rehydrateIndex, "rehydrate callback body must be bounded");
const rehydrateBody = stateSource.slice(rehydrateIndex, nextCallbackIndex);
assert.match(
rehydrateBody,
/STORAGE_KEY_TERM_FOLLOW_APP_THEME/,
"rehydrate must read the follow-app terminal theme key written by applySyncableSettings",
);
assert.match(
rehydrateBody,
/setFollowAppTerminalThemeState/,
"rehydrate must update React follow-app state from storage after cloud sync",
);
});
+7
View File
@@ -833,6 +833,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
// Cloud sync writes follow-app via applySyncableSettings; without this the
// open window keeps the pre-sync flag while terminalThemeId updates, which
// flickers between the local default theme and the synced one (#2757).
const storedFollowAppTermTheme = readStoredString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (storedFollowAppTermTheme === 'true' || storedFollowAppTermTheme === 'false') {
setFollowAppTerminalThemeState(storedFollowAppTermTheme === 'true');
}
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
+50
View File
@@ -139,6 +139,56 @@ test("sync payloads preserve opaque plugin hosts without requiring the plugin to
assert.deepEqual(imported?.hosts, [pluginHost]);
});
test("applySyncPayload preserves host startup command and appearance overrides (#2757)", async () => {
const host = {
id: "host-1",
label: "Prod",
hostname: "prod.example",
username: "root",
port: 22,
protocol: "ssh" as const,
tags: [],
os: "linux" as const,
startupCommand: "tmux attach || tmux",
startupCommandRunMode: "lineDelay" as const,
theme: "solarized-dark",
themeOverride: true,
};
const groupConfigs = [
{
path: "prod",
startupCommand: "cd /srv && exec bash -l",
theme: "solarized-dark",
themeOverride: true,
},
];
const payload = buildSyncPayload({
...vault(),
hosts: [host],
groupConfigs,
});
assert.equal(payload.hosts[0]?.startupCommand, "tmux attach || tmux");
assert.equal(payload.hosts[0]?.theme, "solarized-dark");
assert.equal(payload.hosts[0]?.themeOverride, true);
assert.equal(payload.groupConfigs?.[0]?.startupCommand, "cd /srv && exec bash -l");
let imported: Record<string, unknown> | null = null;
await applySyncPayload(payload, {
importVaultData: (json) => { imported = JSON.parse(json); },
});
const importedHosts = imported?.hosts as typeof payload.hosts;
assert.equal(importedHosts?.[0]?.startupCommand, "tmux attach || tmux");
assert.equal(importedHosts?.[0]?.startupCommandRunMode, "lineDelay");
assert.equal(importedHosts?.[0]?.theme, "solarized-dark");
assert.equal(importedHosts?.[0]?.themeOverride, true);
assert.equal(
(imported?.groupConfigs as typeof groupConfigs)?.[0]?.startupCommand,
"cd /srv && exec bash -l",
);
});
test("buildCloudSyncPayload includes notes and note groups", async () => {
const payload = await buildCloudSyncPayload({
...vault([]),
+47
View File
@@ -176,6 +176,53 @@ test("proxy profile shrink is protected like other synced vault entities", () =>
}
});
test("wiping group configs while other vault data remains is suspicious (#2757)", () => {
// Group defaults often hold startup commands / host appearance for a small
// number of folders. A hydration race that uploads hosts + groupConfigs:[]
// must not clear the cloud copy just because lost < BULK_SHRINK_MIN_ABSOLUTE.
const base = payload({
hosts: hosts(1),
groupConfigs: [
{
path: "prod",
startupCommand: "tmux attach || tmux",
theme: "solarized-dark",
themeOverride: true,
},
],
} as Partial<SyncPayload>);
const out = payload({ hosts: hosts(1), groupConfigs: [] });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) {
assert.equal(result.entityType, "groupConfigs");
assert.equal(result.lost, 1);
assert.equal(result.reason, "bulk-shrink");
}
});
test("wiping the last snippet while hosts remain is still a normal delete", () => {
// The groupConfigs complete-wipe guard must not broaden to every entity —
// deleting your only snippet with hosts present is intentional.
const snippets = (n: number) =>
Array.from({ length: n }, (_, i) => ({ id: `s${i}`, label: `s${i}`, command: "" })) as SyncPayload["snippets"];
const base = payload({ hosts: hosts(1), snippets: snippets(1) });
const out = payload({ hosts: hosts(1), snippets: snippets(0) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("a fully empty outgoing snapshot is not a partial wipe (#2757)", () => {
// Empty-everything against a trusted baseline is a real deletion path
// (convergent migration). Do not flag complete wipes when no other vault
// content remains on the outgoing payload.
const base = payload({
hosts: hosts(1),
groupConfigs: [{ path: "prod", startupCommand: "echo hi" }],
} as Partial<SyncPayload>);
const out = payload();
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("knownHosts shrink is ignored because known hosts are local-only", () => {
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
const base = payload({ knownHosts: kh(12) });
+22
View File
@@ -52,6 +52,28 @@ export function detectSuspiciousShrink(
const lost = baseCount - outgoingCount;
if (lost <= 0) continue;
// groupConfigs often hold startup commands / host appearance for only one
// or two folders. A hydration race that uploads hosts + groupConfigs:[]
// falls below the bulk/ratio gates (#2757). Only flag this entity — wiping
// the last snippet/note/etc. while hosts remain stays a normal delete.
// Fully empty outgoing snapshots remain allowed as real deletions.
if (
entityType === 'groupConfigs'
&& outgoingCount === 0
&& baseCount > 0
&& CHECKED_ENTITIES.some((other) => other !== entityType && countOf(outgoing, other) > 0)
) {
return {
suspicious: true,
reason: lost >= LARGE_SHRINK_ABSOLUTE ? 'large-shrink' : 'bulk-shrink',
entityType,
baseCount,
outgoingCount,
lost,
...(viaRemote ? { viaRemote: true } : {}),
};
}
if (lost >= LARGE_SHRINK_ABSOLUTE) {
return {
suspicious: true,