Files
NetMesh/application/state/useVaultState.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

2176 lines
85 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
hostsEqualForIdentityReuse,
migrateHostsFromLegacyLineTimestamps,
normalizeDistroId,
sanitizeHost,
} from "../../domain/host";
import { isEncryptedCredentialPlaceholder } from "../../domain/credentials";
import { retainLocalHostLastConnectedAt } from "../../domain/sync";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import { normalizeKnownHosts } from "../../domain/knownHosts";
import { normalizeNoteGroups, normalizeVaultNotes } from "../../domain/notes";
import {
applyPluginImporterDestination,
mergePluginImporterDrafts,
} from "../../domain/pluginImporter";
import {
ConnectionLog,
GroupConfig,
Host,
Identity,
KeyCategory,
KnownHost,
ManagedSource,
ProxyProfile,
ShellHistoryEntry,
Snippet,
SSHKey,
VaultNote,
} from "../../domain/models";
import {
INITIAL_HOSTS,
INITIAL_SNIPPETS,
} from "../../infrastructure/config/defaultData";
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_IDENTITIES,
STORAGE_KEY_KEYS,
STORAGE_KEY_KNOWN_HOSTS,
STORAGE_KEY_LEGACY_KEYS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_NOTE_GROUPS,
STORAGE_KEY_NOTES,
STORAGE_KEY_PROXY_PROFILES,
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../infrastructure/persistence/localStorageAdapter";
import {
mergeGlobalHistoryOnAppend,
removeGlobalHistoryEntry,
sanitizeGlobalHistoryEntries,
} from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
deleteSelectedSnippetsFromVault,
pruneHostsStaleSnippetBindings,
rebaseSnippetVaultWrite,
} from "../../domain/snippetSelection";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishConnectionLogsSnapshot,
registerConnectionLogsActions,
} from "./connectionLogsStore";
import {
publishNotesSnapshot,
registerNotesActions,
} from "./notesStore";
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
import {
decryptGroupConfigs,
decryptHosts,
decryptIdentities,
decryptKeys,
decryptProxyProfiles,
encryptGroupConfigs,
encryptHosts,
encryptIdentities,
encryptKeys,
encryptProxyProfiles,
notifyKeysEncryptedWritePending,
} from "../../infrastructure/persistence/secureFieldAdapter";
import { pluginExtensionBridge } from "./pluginExtensionBridge";
import type { PluginImporterCommitRequest } from "./usePluginImporterCommit";
import {
isVaultImportLockHeld,
type VaultLockHandle,
withVaultImportLock,
withVaultImportLockIfNeeded,
} from "./vaultManagedImportLock";
import {
persistVaultImportMetadata,
readStoredArray,
} from "./vaultImportPersistence";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../../domain/vaultGroupMutation";
import {
commitPluginImporterTransaction,
recoverPluginImporterTransaction,
} from "./pluginImporterTransaction";
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
type ExportableVaultData = {
hosts: Host[];
keys: SSHKey[];
identities?: Identity[];
proxyProfiles?: ProxyProfile[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
notes?: VaultNote[];
noteGroups?: string[];
knownHosts?: KnownHost[];
groupConfigs?: GroupConfig[];
};
type LegacyKeyRecord = Record<string, unknown> & { id?: string; source?: string };
const PLUGIN_IMPORT_TRANSACTION_KEYS = new Set([
STORAGE_KEY_HOSTS,
STORAGE_KEY_KEYS,
STORAGE_KEY_IDENTITIES,
STORAGE_KEY_SNIPPETS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_MANAGED_SOURCES,
]);
const buildGroupConfigsForGroups = (
groups: string[],
currentConfigs: GroupConfig[],
): GroupConfig[] => {
const groupOrderByPath = new Map<string, number>(
groups.map((path, index) => [path, (index + 1) * 1000]),
);
const existingConfigByPath = new Map<string, GroupConfig>(
currentConfigs.map((config) => [config.path, config]),
);
const orderedConfigs = groups.map((path) => {
const existing = existingConfigByPath.get(path);
return sanitizeGroupConfig({
...(existing ? { ...existing } : { path }),
path,
order: groupOrderByPath.get(path),
});
});
return normalizeVaultOrder([
...orderedConfigs,
...currentConfigs
.filter((config) => !groupOrderByPath.has(config.path))
.map(sanitizeGroupConfig),
]);
};
// Migration helper for old SSHKey format to new format
const migrateKey = (key: Partial<SSHKey>): SSHKey => {
const id = key.id ?? crypto.randomUUID();
const label = key.label ?? `Key ${id.slice(0, 8)}`;
const source =
key.source === "generated" || key.source === "imported" || key.source === "reference"
? key.source
: key.privateKey
? "imported"
: "generated";
return {
id,
label,
type: key.type || "ED25519",
privateKey: key.privateKey || "",
publicKey: key.publicKey,
certificate: key.certificate,
passphrase: key.passphrase,
savePassphrase: key.savePassphrase,
source,
category:
key.category ||
((key.certificate ? "certificate" : "key") as KeyCategory),
created: key.created || Date.now(),
filePath: key.filePath,
order: key.order,
};
};
const isLegacyUnsupportedKey = (key: LegacyKeyRecord): boolean => {
const source = key.source;
if (source === "biometric" || source === "fido2" || source === "passkey") return true;
// Legacy experimental WebAuthn fields
if ("credentialId" in key || "rpId" in key || "userVerification" in key) return true;
return false;
};
const safeParse = <T,>(value: string | null): T | null => {
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
};
/**
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
* connection logs before persisting. `terminalData` is the full terminal
* scrollback for a session; with up to 500 logs it grew the
* `netcatty_connection_logs_v1` localStorage blob to ~11 MB, and every
* add/update re-serialized + wrote the whole thing synchronously
* (5073 ms on the main thread), causing freezes on connect/disconnect.
*
* The full `terminalData` stays in the in-memory React state (so in-session
* replay still works); only explicitly *saved* logs keep it on disk. This
* keeps the persisted blob small and writes fast.
*/
const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] => {
let changed = false;
const next = logs.map((log) => {
if (log.saved || log.terminalData === undefined) return log;
changed = true;
const { terminalData: _omitted, ...rest } = log;
return rest;
});
return changed ? next : logs;
};
const readLegacyLineTimestampsEnabled = (): boolean => {
const stored = localStorageAdapter.read<Record<string, unknown>>(STORAGE_KEY_TERM_SETTINGS);
return stored?.showLineTimestamps === true;
};
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
const readPersistedConnectionLogIds = (): Set<string> => {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
return new Set(stored.map((log) => log.id));
};
const buildPersistedLogIds = (
logs: ConnectionLog[],
usePersistedLogIdsFromDisk: boolean,
): Set<string> => {
const ids = new Set(logs.map((log) => log.id));
if (usePersistedLogIdsFromDisk) {
for (const id of readPersistedConnectionLogIds()) {
ids.add(id);
}
}
return ids;
};
const writeConnectionLogTerminalDataMap = (
logs: ConnectionLog[],
localMaps: ConnectionLogTerminalDataMap[],
usePersistedLogIdsFromDisk: boolean,
): boolean => {
const existing = readConnectionLogTerminalDataMap();
const pruned = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [identities, setIdentities] = useState<Identity[]>([]);
const [proxyProfiles, setProxyProfiles] = useState<ProxyProfile[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [customGroups, setCustomGroups] = useState<string[]>([]);
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
const [notes, setNotes] = useState<VaultNote[]>([]);
const [noteGroups, setNoteGroups] = useState<string[]>([]);
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
const customGroupsRef = useRef<string[]>([]);
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
snippetsRef.current = snippets;
groupConfigsRef.current = groupConfigs;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
// persists if its version still matches the latest.
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const snippetsWriteVersion = useRef(0);
const managedSourcesWriteVersion = useRef(0);
// Tracks the latest local updateSnippets schedule. Storage events also bump
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
// key supersede checks off this owner instead of that shared counter.
const snippetsWriteOwnerRef = useRef(0);
// Last persisted ancestor for in-flight updateSnippets rebases. Superseded
// saves must not advance this to an optimistic in-memory array that never
// landed on disk — that would make a local add look like a concurrent delete.
const snippetsWriteBaseRef = useRef<Snippet[] | null>(null);
// Outstanding clear/restore/import replace must survive a superseding local
// save. Otherwise the later owner rebases additively against the old disk
// catalog and resurrects every pre-replacement snippet.
const snippetsWriteReplaceRef = useRef(false);
const customGroupsWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Encrypt-phase promises can always be awaited, even under the vault lock.
// Full write promises include lock acquisition and must only be awaited when
// this window does not already hold that lock (otherwise writers deadlock).
const hostsEncryptPendingRef = useRef<Promise<unknown>>(Promise.resolve());
const keysEncryptPendingRef = useRef<Promise<unknown>>(Promise.resolve());
const identitiesEncryptPendingRef = useRef<Promise<unknown>>(Promise.resolve());
const groupConfigsEncryptPendingRef = useRef<Promise<unknown>>(Promise.resolve());
const hostsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const keysWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const managedSourcesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const waitForPendingVaultWrites = useCallback(async () => {
while (true) {
const encryptPending = [
hostsEncryptPendingRef.current,
keysEncryptPendingRef.current,
identitiesEncryptPendingRef.current,
groupConfigsEncryptPendingRef.current,
];
await Promise.all(encryptPending);
if (isVaultImportLockHeld("vault")) {
if (
encryptPending[0] === hostsEncryptPendingRef.current
&& encryptPending[1] === keysEncryptPendingRef.current
&& encryptPending[2] === identitiesEncryptPendingRef.current
&& encryptPending[3] === groupConfigsEncryptPendingRef.current
) return;
continue;
}
const writePending = [
hostsWritePendingRef.current,
keysWritePendingRef.current,
identitiesWritePendingRef.current,
groupConfigsWritePendingRef.current,
snippetsWritePendingRef.current,
managedSourcesWritePendingRef.current,
];
await Promise.all(writePending);
if (
writePending[0] === hostsWritePendingRef.current
&& writePending[1] === keysWritePendingRef.current
&& writePending[2] === identitiesWritePendingRef.current
&& writePending[3] === groupConfigsWritePendingRef.current
&& writePending[4] === snippetsWritePendingRef.current
&& writePending[5] === managedSourcesWritePendingRef.current
&& encryptPending[0] === hostsEncryptPendingRef.current
&& encryptPending[1] === keysEncryptPendingRef.current
&& encryptPending[2] === identitiesEncryptPendingRef.current
&& encryptPending[3] === groupConfigsEncryptPendingRef.current
) return;
}
}, []);
// Read-sequence counters for cross-window storage events. Each incoming
// event bumps the counter; the async decrypt callback only applies state if
// its sequence still matches, preventing stale decrypts from overwriting
// newer data when multiple events arrive in quick succession.
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const pluginCredentialCatalogVersion = useRef(0);
const syncConnectionLogTerminalDataMap = useCallback((
logs: ConnectionLog[],
options?: { usePersistedLogIdsFromDisk?: boolean },
) => {
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
const localMaps = [
connectionLogTerminalDataRef.current,
buildTerminalDataMapFromLogs(logs),
];
const existing = readConnectionLogTerminalDataMap();
const merged = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
const persisted = writeConnectionLogTerminalDataMap(
logs,
localMaps,
usePersistedLogIdsFromDisk,
);
if (persisted) {
connectionLogTerminalDataRef.current = merged;
} else {
connectionLogTerminalDataRef.current = {
...connectionLogTerminalDataRef.current,
...buildTerminalDataMapFromLogs(logs),
};
}
return { map: connectionLogTerminalDataRef.current, persisted };
}, []);
const persistConnectionLogState = useCallback((
logs: ConnectionLog[],
options?: { pruneMainBlob?: boolean },
) => {
const unsavedTerminalDataPending = logs.some(
(log) => !log.saved && log.terminalData !== undefined,
);
let mainPersisted = true;
if (options?.pruneMainBlob) {
const prunedMain = pruneConnectionLogsForStorage(logs);
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
if (!mainPersisted && unsavedTerminalDataPending) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
}
}
let sidePersisted = true;
if (mainPersisted || !options?.pruneMainBlob) {
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
}));
}
if (!options?.pruneMainBlob) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
}
if (!mainPersisted && unsavedTerminalDataPending) {
console.warn(
"[useVaultState] Failed to persist connection log terminal replay data to localStorage.",
);
} else if (!sidePersisted && unsavedTerminalDataPending && options?.pruneMainBlob && mainPersisted) {
console.warn(
"[useVaultState] Failed to persist connection log terminal replay data side store.",
);
}
}, [syncConnectionLogTerminalDataMap]);
const applyConnectionLogsFromStorage = useCallback((
prev: ConnectionLog[],
storedLogs: ConnectionLog[],
terminalDataMap: ConnectionLogTerminalDataMap,
) => {
connectionLogTerminalDataRef.current = terminalDataMap;
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
}, []);
// Encrypt outside the lock, then under the lock prune script bindings against
// the latest snippet catalog so a queued full-array write cannot restore
// login/connect ids cleared by a concurrent bulk-delete in another window.
const commitEncryptedHostsUnderVaultLock = useCallback(async (
ver: number,
hostsToPersist: Host[],
encrypted: Awaited<ReturnType<typeof encryptHosts>>,
) => {
return withVaultImportLock("vault", async () => {
if (ver !== hostsWriteVersion.current) return "superseded" as const;
const latestSnippets = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const pruned = pruneHostsStaleSnippetBindings(hostsToPersist, latestSnippets);
let payload = encrypted;
if (pruned !== hostsToPersist) {
const normalized = normalizeVaultOrder(pruned);
payload = await encryptHosts(normalized);
if (ver !== hostsWriteVersion.current) return "superseded" as const;
hostsRef.current = normalized;
setHosts(normalized);
}
return localStorageAdapter.write(STORAGE_KEY_HOSTS, payload)
? "written" as const
: "failed" as const;
});
}, []);
const updateHosts = useCallback((data: Host[] | ((prev: Host[]) => Host[])) => {
// Keep object identity for hosts that did not actually change. Callers that
// do `hosts.map(h => h.id === id ? patch(h) : h)` already pass through
// unchanged refs; re-running sanitizeHost on every host would allocate new
// objects for the whole vault and re-render every open terminal.
const prev = hostsRef.current;
const raw = typeof data === "function" ? data(prev) : data;
const prevById = new Map(prev.map((host) => [host.id, host]));
const cleaned = normalizeVaultOrder(raw.map((host) => {
if (prevById.get(host.id) === host) return host;
const sanitized = sanitizeHost(host);
const existing = prevById.get(sanitized.id);
if (existing && hostsEqualForIdentityReuse(existing, sanitized)) {
return existing;
}
return sanitized;
}));
if (
cleaned.length === prev.length
&& cleaned.every((host, index) => host === prev[index])
) {
return Promise.resolve("unchanged" as const);
}
hostsRef.current = cleaned;
setHosts(cleaned);
const ver = ++hostsWriteVersion.current;
// Encrypt outside the lock so importers that hold the lock can still wait
// for in-flight encryption without deadlocking on lock acquisition.
const encryptPromise = encryptHosts(cleaned);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return "superseded" as const;
return commitEncryptedHostsUnderVaultLock(ver, cleaned, enc);
});
hostsWritePendingRef.current = writePromise;
return writePromise;
}, [commitEncryptedHostsUnderVaultLock]);
const readPersistedHosts = useCallback(async (): Promise<Host[]> => {
// Always drain what is safe to wait for. Under the vault lock this is only
// the encrypt phase; outside the lock it also waits for locked disk writes.
await waitForPendingVaultWrites();
while (true) {
const rawHosts = localStorageAdapter.readString(STORAGE_KEY_HOSTS);
const storedHosts = readStoredArray<Host>(STORAGE_KEY_HOSTS, rawHosts);
const decrypted = await decryptHosts(storedHosts);
if (localStorageAdapter.readString(STORAGE_KEY_HOSTS) !== rawHosts) continue;
return normalizeVaultOrder(decrypted.map((host) => sanitizeHost(host)));
}
}, [waitForPendingVaultWrites]);
const updateKeys = useCallback((data: SSHKey[]) => {
const cleaned = normalizeVaultOrder(data);
setKeys(cleaned);
const ver = ++keysWriteVersion.current;
const encryptPromise = encryptKeys(cleaned);
keysEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== keysWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
});
keysWritePendingRef.current = writePromise;
// Stored-key hydration must not read a stale persisted key snapshot over
// this new state before its encrypted write lands.
notifyKeysEncryptedWritePending(writePromise);
return writePromise;
}, []);
const importOrReuseKey = useCallback((draft: Partial<SSHKey>): SSHKey => {
const existing = keys.find((k) => {
if (draft.source === 'reference' && draft.filePath) {
return k.source === 'reference' && k.filePath === draft.filePath;
}
if (draft.privateKey) {
return k.privateKey === draft.privateKey;
}
return false;
});
if (existing) return existing;
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draft.label || 'Imported Key',
type: draft.type || 'ED25519',
privateKey: draft.privateKey || '',
publicKey: draft.publicKey,
certificate: draft.certificate,
passphrase: draft.passphrase,
savePassphrase: draft.savePassphrase,
source: draft.source || 'imported',
category: (draft.category || 'key') as KeyCategory,
created: Date.now(),
filePath: draft.filePath,
order: getNextVaultOrder(keys),
};
const updated = normalizeVaultOrder([...keys, newKey]);
setKeys(updated);
const ver = ++keysWriteVersion.current;
const encryptPromise = encryptKeys(updated);
keysEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== keysWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
});
keysWritePendingRef.current = writePromise;
notifyKeysEncryptedWritePending(writePromise);
return newKey;
}, [keys]);
const updateIdentities = useCallback((data: Identity[]) => {
const cleaned = normalizeVaultOrder(data);
setIdentities(cleaned);
const ver = ++identitiesWriteVersion.current;
const encryptPromise = encryptIdentities(cleaned);
identitiesEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== identitiesWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
});
identitiesWritePendingRef.current = writePromise;
return writePromise;
}, []);
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
const cleaned = normalizeVaultOrder(data);
setProxyProfiles(cleaned);
const ver = ++proxyProfilesWriteVersion.current;
return encryptProxyProfiles(cleaned).then((enc) => {
if (ver === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
}, []);
const updateSnippets = useCallback((
data: Snippet[] | ((current: Snippet[]) => Snippet[]),
options?: { replace?: boolean },
) => {
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
// Callers pass a full array derived from this window's view; a popup delete
// (or another window's import) may land on disk before our write runs.
// Keep the first outstanding ancestor across superseded local saves so a
// second edit does not rebase against the first save's optimistic memory.
const replace =
options?.replace === true || snippetsWriteReplaceRef.current;
const updater = typeof data === "function" ? data : null;
const current = snippetsRef.current;
const base = snippetsWriteBaseRef.current ?? current;
if (options?.replace === true) {
snippetsWriteReplaceRef.current = true;
}
if (replace) {
// Restore/import/clear must not keep an additive ancestor — storage events
// would otherwise merge disk-only ids back into memory while replace waits.
// Keep this cleared when a later create/edit supersedes the replacement
// owner so that save stays in replace mode instead of capturing []/restored
// memory as an additive rebase base against the stale disk catalog.
snippetsWriteBaseRef.current = null;
} else if (snippetsWriteBaseRef.current === null) {
snippetsWriteBaseRef.current = base;
}
const cleaned = normalizeVaultOrder(
typeof data === "function" ? data(current) : data,
);
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
// Keep live snapshot ahead of React commit so same-tick readers (delete,
// agent bridge) do not observe a stale pre-write array.
snippetsRef.current = cleaned;
setSnippets(cleaned);
// Serialize with deleteSelectedSnippets / plugin importer under the shared
// vault lock, then rebase onto the latest persisted snapshot so a queued
// full-array write cannot resurrect concurrently deleted snippets (or drop
// concurrent additions). Explicit replace (clear / sync restore / import)
// skips the additive rebase so a concurrent disk-only add cannot survive.
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const latestPersisted = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const rebased = normalizeVaultOrder(replace
? cleaned
: updater
? updater(rebaseSnippetVaultWrite({
base,
ours: current,
theirs: latestPersisted,
}))
: rebaseSnippetVaultWrite({
base,
ours: cleaned,
theirs: latestPersisted,
}));
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
if (!persisted) {
// Keep the pre-update ancestor. Clearing it here would make the next
// save rebase against the optimistic array while disk still lacks the
// add, so rebase would treat the add as a concurrent delete.
notify.error(
"Snippets could not be saved. Free some local storage space and try again.",
"Scripts",
);
return "failed" as const;
}
// Disk caught up; next save should treat this write as its ancestor.
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
if (snippetsWriteOwnerRef.current === ver) {
snippetsRef.current = rebased;
setSnippets(rebased);
}
return "written" as const;
});
snippetsWritePendingRef.current = writePromise;
return writePromise;
}, []);
// Cross-window safe: merge binding cleanup into the latest persisted
// hosts/snippets under the shared vault lock. Popup terminals own a separate
// useVaultState instance — writing hostsRef from that window can discard a
// main-window host edit, or a storage-event version bump can cancel the host
// write after snippets were already removed.
const deleteSelectedSnippets = useCallback(async (selectedSnippetIds: ReadonlySet<string>) => {
if (selectedSnippetIds.size === 0) {
return {
snippets: snippetsRef.current,
hosts: hostsRef.current,
deletedCount: 0,
};
}
while (true) {
await waitForPendingVaultWrites();
const attempt = await withVaultImportLock("vault", async () => {
const writeVersions = {
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
};
const rawHosts = localStorageAdapter.readString(STORAGE_KEY_HOSTS);
const rawSnippets = localStorageAdapter.readString(STORAGE_KEY_SNIPPETS);
const storedHosts = readStoredArray<Host>(STORAGE_KEY_HOSTS, rawHosts);
const storedSnippets = readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, rawSnippets);
const latestHosts = normalizeVaultOrder(
(await decryptHosts(storedHosts)).map((host) => sanitizeHost(host)),
);
const latestSnippets = normalizeVaultOrder(storedSnippets);
const result = deleteSelectedSnippetsFromVault(
latestSnippets,
latestHosts,
selectedSnippetIds,
);
if (result.deletedCount === 0) return result;
const encryptedHosts = await encryptHosts(result.hosts);
const changedWhilePreparing = (
writeVersions.hosts !== hostsWriteVersion.current
|| writeVersions.snippets !== snippetsWriteVersion.current
|| localStorageAdapter.readString(STORAGE_KEY_HOSTS) !== rawHosts
|| localStorageAdapter.readString(STORAGE_KEY_SNIPPETS) !== rawSnippets
);
if (changedWhilePreparing) return null;
++hostsWriteVersion.current;
++snippetsWriteVersion.current;
// Journaled pair write: a partial hosts/snippets persist would drop
// login/connect bindings on restart while leaving the snippets behind.
// Callers fire-and-forget this promise after closing the confirm dialog,
// so storage rejection must not become an unhandled rejection.
try {
commitPluginImporterTransaction(localStorageAdapter, [
[STORAGE_KEY_HOSTS, encryptedHosts],
[STORAGE_KEY_SNIPPETS, result.snippets],
]);
} catch {
notify.error(
"Snippets could not be deleted. Free some local storage space and try again.",
"Scripts",
);
return {
snippets: latestSnippets,
hosts: latestHosts,
deletedCount: 0,
};
}
hostsRef.current = result.hosts;
snippetsRef.current = result.snippets;
// Persisted delete is the new ancestor; drop any stale rebase base from
// a superseded/queued updateSnippets that never landed.
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(result.hosts);
setSnippets(result.snippets);
hostsWritePendingRef.current = Promise.resolve("unchanged" as const);
snippetsWritePendingRef.current = Promise.resolve("unchanged" as const);
return result;
});
if (attempt !== null) return attempt;
}
}, [waitForPendingVaultWrites]);
const updateSnippetPackages = useCallback((data: string[]) => {
setSnippetPackages(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
}, []);
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const { notes: cleaned, persisted } = commitVaultNotesWrite({
data,
write: (key, value) => localStorageAdapter.write(key, value),
});
// Keep the in-session catalog updated so an autosave quota failure does not
// snap the editor back to stale props and discard the user's draft. Disk
// may still be behind — surface that explicitly.
notesRef.current = cleaned;
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
if (!persisted) {
const now = Date.now();
// Debounced autosave can hit quota repeatedly; avoid toast spam.
if (now - notesPersistFailureNotifiedAtRef.current > 10_000) {
notesPersistFailureNotifiedAtRef.current = now;
notify.error(
"Notes could not be saved. Free some local storage space and try again.",
"Notes",
);
}
return false;
}
return true;
}, []);
const updateNoteGroups = useCallback((data: unknown) => {
const cleaned = normalizeNoteGroups(data);
noteGroupsRef.current = cleaned;
setNoteGroups(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
publishNotesSnapshot({ notes: notesRef.current, noteGroups: cleaned });
}, []);
const updateCustomGroups = useCallback((
data: string[] | ((current: string[]) => string[]),
) => {
// Functional updates must see the latest in-memory snapshot. Storage may
// still hold an older value while an encrypt+locked write is in flight.
const next = typeof data === "function" ? data(customGroupsRef.current) : data;
customGroupsRef.current = next;
const groupsVer = ++customGroupsWriteVersion.current;
setCustomGroups(next);
const cleanedGroupConfigs = buildGroupConfigsForGroups(next, groupConfigs);
groupConfigsRef.current = cleanedGroupConfigs;
setGroupConfigs(cleanedGroupConfigs);
const configsVer = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleanedGroupConfigs);
groupConfigsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
return withVaultImportLock("vault", async () => {
if (groupsVer === customGroupsWriteVersion.current) {
localStorageAdapter.write(STORAGE_KEY_GROUPS, next);
}
if (configsVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
});
groupConfigsWritePendingRef.current = writePromise;
}, [groupConfigs]);
const updateKnownHosts = useCallback((data: KnownHost[]) => {
const cleaned = normalizeVaultOrder(data);
setKnownHosts(cleaned);
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, cleaned);
}, []);
const updateManagedSources = useCallback((
data: ManagedSource[] | ((current: ManagedSource[]) => ManagedSource[]),
) => {
const next = typeof data === "function" ? data(managedSourcesRef.current) : data;
managedSourcesRef.current = next;
setManagedSources(next);
const ver = ++managedSourcesWriteVersion.current;
const writePromise = withVaultImportLock("vault", async () => {
// Latest ref wins if another update ran while waiting for the lock.
if (ver !== managedSourcesWriteVersion.current) return "superseded" as const;
return localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next)
? "written" as const
: "failed" as const;
});
managedSourcesWritePendingRef.current = writePromise;
return writePromise;
}, []);
const commitVaultGroupMutation = useCallback(async (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const captureVersions = () => ({
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
groups: customGroupsWriteVersion.current,
configs: groupConfigsWriteVersion.current,
sources: managedSourcesWriteVersion.current,
});
const versionsAreCurrent = (versions: ReturnType<typeof captureVersions>) => (
versions.hosts === hostsWriteVersion.current
&& versions.snippets === snippetsWriteVersion.current
&& versions.groups === customGroupsWriteVersion.current
&& versions.configs === groupConfigsWriteVersion.current
&& versions.sources === managedSourcesWriteVersion.current
);
const stateMatchesLiveSnapshot = (current: VaultGroupMutationState) => (
JSON.stringify(current.hosts) === JSON.stringify(hostsRef.current)
&& JSON.stringify(current.snippets) === JSON.stringify(snippetsRef.current)
&& JSON.stringify(current.groups) === JSON.stringify(customGroupsRef.current)
&& JSON.stringify(current.configs) === JSON.stringify(groupConfigsRef.current)
&& JSON.stringify(current.managedSources) === JSON.stringify(managedSourcesRef.current)
);
const runCommit = async (
versions: ReturnType<typeof captureVersions>,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const result = await commitVaultGroupMutationPersistence({
storage: localStorageAdapter,
mutate,
prepareState: (state) => {
const groups = Array.from(new Set(state.groups));
return {
groups,
configs: buildGroupConfigsForGroups(groups, state.configs),
hosts: normalizeVaultOrder(state.hosts.map((host) => sanitizeHost(host))),
managedSources: state.managedSources,
snippets: normalizeVaultOrder(state.snippets),
};
},
decryptHosts: async (items) => normalizeVaultOrder(
(await decryptHosts(items)).map((host) => sanitizeHost(host)),
),
decryptConfigs: async (items) => normalizeVaultOrder(
(await decryptGroupConfigs(items)).map(sanitizeGroupConfig),
),
encryptHosts,
encryptConfigs: encryptGroupConfigs,
isCurrent: () => versionsAreCurrent(versions),
validateCurrent: lock ? stateMatchesLiveSnapshot : undefined,
});
if (!result.ok) return result;
const nextState = result.state;
++hostsWriteVersion.current;
++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = snippetsWriteVersion.current;
++customGroupsWriteVersion.current;
++groupConfigsWriteVersion.current;
++managedSourcesWriteVersion.current;
hostsRef.current = nextState.hosts;
snippetsRef.current = nextState.snippets;
customGroupsRef.current = nextState.groups;
managedSourcesRef.current = nextState.managedSources;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextState.hosts);
setSnippets(nextState.snippets);
setCustomGroups(nextState.groups);
setManagedSources(nextState.managedSources);
setGroupConfigs(nextState.configs);
groupConfigsRef.current = nextState.configs;
return { ok: true, state: nextState };
};
if (lock) {
const versions = captureVersions();
return withVaultImportLockIfNeeded("vault", () => runCommit(versions), lock);
}
while (true) {
await waitForPendingVaultWrites();
const versions = captureVersions();
const result = await withVaultImportLock("vault", () => runCommit(versions));
if (!("superseded" in result)) return result;
}
}, [waitForPendingVaultWrites]);
const readPersistedManagedSources = useCallback((): ManagedSource[] => (
readStoredArray<ManagedSource>(
STORAGE_KEY_MANAGED_SOURCES,
localStorageAdapter.readString(STORAGE_KEY_MANAGED_SOURCES),
)
), []);
const commitVaultImportTransaction = useCallback(async (
nextHosts: Host[],
updateGroups: (current: string[]) => string[],
updateSources: (current: ManagedSource[]) => ManagedSource[],
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
): Promise<
| {
status: "persisted";
groups: string[];
sources: ManagedSource[];
groupConfigs: GroupConfig[];
}
| { status: "superseded" }
> => {
// Hold the shared lock for the entire check → encrypt → write path so no
// ordinary vault save can interleave. Callers already inside withVaultImportLock
// must pass the active lock handle to continue without re-acquiring.
const runCommit = async (): Promise<
| {
status: "persisted";
groups: string[];
sources: ManagedSource[];
groupConfigs: GroupConfig[];
}
| { status: "superseded" }
> => {
const version = hostsWriteVersion.current;
const groupConfigsVersion = groupConfigsWriteVersion.current;
const cleanedHosts = normalizeVaultOrder(nextHosts.map((host) => sanitizeHost(host)));
const baselineRaw = new Map([
[STORAGE_KEY_HOSTS, localStorageAdapter.readString(STORAGE_KEY_HOSTS)],
[STORAGE_KEY_GROUPS, localStorageAdapter.readString(STORAGE_KEY_GROUPS)],
[STORAGE_KEY_MANAGED_SOURCES, localStorageAdapter.readString(STORAGE_KEY_MANAGED_SOURCES)],
[STORAGE_KEY_GROUP_CONFIGS, localStorageAdapter.readString(STORAGE_KEY_GROUP_CONFIGS)],
]);
const groups = updateGroups(readStoredArray<string>(
STORAGE_KEY_GROUPS,
baselineRaw.get(STORAGE_KEY_GROUPS) ?? null,
));
const sources = updateSources(readStoredArray<ManagedSource>(
STORAGE_KEY_MANAGED_SOURCES,
baselineRaw.get(STORAGE_KEY_MANAGED_SOURCES) ?? null,
));
const storedGroupConfigs = readStoredArray<GroupConfig>(
STORAGE_KEY_GROUP_CONFIGS,
baselineRaw.get(STORAGE_KEY_GROUP_CONFIGS) ?? null,
);
const storedHosts = readStoredArray<Host>(
STORAGE_KEY_HOSTS,
baselineRaw.get(STORAGE_KEY_HOSTS) ?? null,
);
const [latestPersistedHosts, latestGroupConfigs] = await Promise.all([
decryptHosts(storedHosts).then((decrypted) => (
normalizeVaultOrder(decrypted.map((host) => sanitizeHost(host)))
)),
decryptGroupConfigs(storedGroupConfigs),
]);
if (
version !== hostsWriteVersion.current
|| groupConfigsVersion !== groupConfigsWriteVersion.current
) {
return { status: "superseded" };
}
if (expectedHosts) {
const normalizedExpectedHosts = normalizeVaultOrder(
expectedHosts.map((host) => sanitizeHost(host)),
);
if (JSON.stringify(latestPersistedHosts) !== JSON.stringify(normalizedExpectedHosts)) {
return { status: "superseded" };
}
}
const nextGroupConfigs = buildGroupConfigsForGroups(
groups,
updateGroupConfigs?.(latestGroupConfigs) ?? latestGroupConfigs,
);
const [encryptedHosts, encryptedGroupConfigs] = await Promise.all([
encryptHosts(cleanedHosts),
encryptGroupConfigs(nextGroupConfigs),
]);
if (
version !== hostsWriteVersion.current
|| groupConfigsVersion !== groupConfigsWriteVersion.current
|| [...baselineRaw].some(([key, value]) => localStorageAdapter.readString(key) !== value)
) {
return { status: "superseded" };
}
const result = persistVaultImportMetadata(
localStorageAdapter,
() => groups,
() => sources,
[
[STORAGE_KEY_HOSTS, encryptedHosts],
[STORAGE_KEY_GROUP_CONFIGS, encryptedGroupConfigs],
],
);
++hostsWriteVersion.current;
++customGroupsWriteVersion.current;
++groupConfigsWriteVersion.current;
customGroupsRef.current = result.groups;
managedSourcesRef.current = result.sources;
hostsRef.current = cleanedHosts;
groupConfigsRef.current = nextGroupConfigs;
setHosts(cleanedHosts);
setCustomGroups(result.groups);
setManagedSources(result.sources);
setGroupConfigs(nextGroupConfigs);
return {
status: "persisted",
groups: result.groups,
sources: result.sources,
groupConfigs: nextGroupConfigs,
};
};
return withVaultImportLockIfNeeded("vault", runCommit, lock);
}, []);
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
// Sanitize on the write path too — applySyncPayload / importVaultData
// route legacy payloads through here, and without this step a saved
// pingfang-sc / comic-sans-ms override from an older client would
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
groupConfigsRef.current = cleaned;
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleaned);
groupConfigsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== groupConfigsWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
});
groupConfigsWritePendingRef.current = writePromise;
return writePromise;
}, []);
const clearVaultData = useCallback(() => {
updateHosts([]);
updateKeys([]);
updateIdentities([]);
updateProxyProfiles([]);
updateSnippets([], { replace: true });
updateSnippetPackages([]);
updateNotes([]);
updateNoteGroups([]);
updateCustomGroups([]);
updateKnownHosts([]);
updateManagedSources([]);
updateGroupConfigs([]);
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
}, [
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
]);
// Keep store in sync for storage-event / external setShellHistory paths.
// Append/clear/load also publish synchronously so History never flashes empty.
useLayoutEffect(() => {
publishShellHistorySnapshot(shellHistory);
}, [shellHistory]);
// Notes catalog for Notes / AI side panels — keep TerminalLayer off the hot path.
useLayoutEffect(() => {
publishNotesSnapshot({ notes, noteGroups });
}, [notes, noteGroups]);
useLayoutEffect(() => {
registerNotesActions({ updateNotes, updateNoteGroups });
return () => {
registerNotesActions(null);
};
}, [updateNotes, updateNoteGroups]);
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
setShellHistory((prev) => {
const updated = mergeGlobalHistoryOnAppend(prev, entry);
if (updated === prev) return prev;
// Persist immediately so crash between commit and layout effect cannot drop history.
// Store republish is also done in useLayoutEffect for load/storage-event paths.
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
return updated;
});
},
[],
);
const clearShellHistory = useCallback(() => {
setShellHistory([]);
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, []);
publishShellHistorySnapshot([]);
}, []);
const removeShellHistoryEntry = useCallback((entryId: string) => {
setShellHistory((prev) => {
const updated = removeGlobalHistoryEntry(prev, entryId);
if (updated === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
publishShellHistorySnapshot(updated);
return updated;
});
}, []);
// Connection logs management
const addConnectionLog = useCallback(
(log: Omit<ConnectionLog, "id">) => {
const newLog: ConnectionLog = {
...log,
id: crypto.randomUUID(),
};
setConnectionLogs((prev) => {
// Keep only the last 500 non-saved entries plus all saved entries
const savedLogs = prev.filter((l) => l.saved);
const unsavedLogs = prev.filter((l) => !l.saved);
const updated = [newLog, ...unsavedLogs].slice(0, 500);
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
);
persistConnectionLogState(final, { pruneMainBlob: true });
return final;
});
return newLog.id;
},
[persistConnectionLogState],
);
const updateConnectionLog = useCallback(
(id: string, updates: Partial<ConnectionLog>) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
);
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
},
[persistConnectionLogState],
);
const toggleConnectionLogSaved = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
);
persistConnectionLogState(updated, { pruneMainBlob: false });
return updated;
});
}, [persistConnectionLogState]);
const deleteConnectionLog = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.filter((log) => log.id !== id);
const map = { ...connectionLogTerminalDataRef.current };
delete map[id];
connectionLogTerminalDataRef.current = map;
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
}, [persistConnectionLogState]);
const clearUnsavedConnectionLogs = useCallback(() => {
setConnectionLogs((prev) => {
const saved = prev.filter((log) => log.saved);
persistConnectionLogState(saved, { pruneMainBlob: true });
return saved;
});
}, [persistConnectionLogState]);
// Connection logs for Vault logs section — keep App domain bags off session churn.
useLayoutEffect(() => {
publishConnectionLogsSnapshot({ connectionLogs });
}, [connectionLogs]);
useLayoutEffect(() => {
registerConnectionLogsActions({
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
});
return () => {
registerConnectionLogsActions(null);
};
}, [
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
]);
// Convert a known host to a managed host
const convertKnownHostToHost = useCallback((knownHost: KnownHost): Host => {
const newHost: Host = {
id: `host-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
label: knownHost.hostname,
hostname: knownHost.hostname,
port: knownHost.port,
username: "", // Will be set when connecting
os: "linux",
group: "",
tags: [],
protocol: "ssh",
order: getNextVaultOrder(hosts),
};
// Update the known host to mark it as converted using functional update
setKnownHosts((prevKnownHosts) => {
const updated = prevKnownHosts.map((kh) =>
kh.id === knownHost.id ? { ...kh, convertedToHostId: newHost.id } : kh,
);
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, updated);
return updated;
});
// Add to hosts using functional update
setHosts((prevHosts) => {
const updated = normalizeVaultOrder([...prevHosts, sanitizeHost(newHost)]);
const ver = ++hostsWriteVersion.current;
const encryptPromise = encryptHosts(updated);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return commitEncryptedHostsUnderVaultLock(ver, updated, enc);
});
hostsWritePendingRef.current = writePromise;
return updated;
});
return newHost;
}, [commitEncryptedHostsUnderVaultLock, hosts]);
useEffect(() => {
let cancelled = false;
const init = async () => {
try {
await withVaultImportLock("vault", async () => {
recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS);
});
if (cancelled) return;
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (cancelled) return;
if (ver === hostsWriteVersion.current) {
const sanitized = normalizeVaultOrder(
migrateHostsFromLegacyLineTimestamps(
decrypted.map((host) => sanitizeHost(host)),
readLegacyLineTimestampsEnabled(),
),
);
hostsRef.current = sanitized;
setHosts(sanitized);
// Always re-encrypt the batch. Stale enc:v1 placeholders are left
// unchanged by encryptCredentialValue (no double-wrap); plaintext
// siblings still need encryption when safeStorage was previously
// unavailable for some records. Route through the locked writer so
// a concurrent snippet delete can prune login/connect bindings
// before this migration blob lands.
const encryptPromise = encryptHosts(sanitized);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return commitEncryptedHostsUnderVaultLock(ver, sanitized, enc);
});
hostsWritePendingRef.current = writePromise;
}
} else {
updateHosts(INITIAL_HOSTS);
}
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (cancelled) return;
if (keyVer === keysWriteVersion.current) {
const orderedKeys = normalizeVaultOrder(decryptedKeys);
setKeys(orderedKeys);
encryptKeys(orderedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (cancelled) return;
if (idVer === identitiesWriteVersion.current) {
const orderedIdentities = normalizeVaultOrder(decryptedIds);
setIdentities(orderedIdentities);
encryptIdentities(orderedIdentities).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
const savedProxyProfiles =
localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
if (savedProxyProfiles) {
const proxyVer = ++proxyProfilesWriteVersion.current;
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
if (cancelled) return;
if (proxyVer === proxyProfilesWriteVersion.current) {
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
setProxyProfiles(orderedProfiles);
encryptProxyProfiles(orderedProfiles).then((enc) => {
if (proxyVer === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
}
}
if (cancelled) return;
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
const savedNotes = localStorageAdapter.read<VaultNote[]>(STORAGE_KEY_NOTES);
const savedNoteGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_NOTE_GROUPS);
if (savedSnippets) {
const orderedSnippets = normalizeVaultOrder(savedSnippets);
snippetsRef.current = orderedSnippets;
setSnippets(orderedSnippets);
// Persist order backfill only when fields changed. Never rewrite the
// pre-lock snapshot unlocked: another renderer can delete under the
// vault lock after this read and before this write, resurrecting
// snippets while host-binding cleanup stays committed. Re-normalize
// the live disk catalog under the same lock as deleteSelectedSnippets.
const needsOrderPersist = orderedSnippets.some(
(snippet, index) => snippet !== savedSnippets[index],
);
if (needsOrderPersist) {
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const latest = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, latest);
if (!persisted) {
notify.error(
"Snippets could not be saved. Free some local storage space and try again.",
"Scripts",
);
return "failed" as const;
}
if (snippetsWriteOwnerRef.current === ver) {
snippetsRef.current = latest;
setSnippets(latest);
}
return "written" as const;
});
snippetsWritePendingRef.current = writePromise;
}
}
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedNotes) {
const cleanedNotes = normalizeVaultNotes(savedNotes);
setNotes(cleanedNotes);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleanedNotes);
}
if (savedNoteGroups) {
const cleanedNoteGroups = normalizeNoteGroups(savedNoteGroups);
setNoteGroups(cleanedNoteGroups);
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleanedNoteGroups);
}
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
// records saved by older builds may be missing the `fingerprint` /
// `keyType` fields the verifier compares against; backfill them now
// so the next SSH connect can match without falling into the brittle
// re-derivation path that caused the repeated "fingerprint changed"
// warnings in #972.
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) {
const normalized = normalizeKnownHosts(savedKnownHosts);
const orderedKnownHosts = normalizeVaultOrder(normalized);
setKnownHosts(orderedKnownHosts);
if (normalized !== savedKnownHosts || orderedKnownHosts !== normalized) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, orderedKnownHosts);
}
}
// Load shell history
const savedShellHistory = loadSanitizedShellHistory();
if (savedShellHistory) {
setShellHistory(savedShellHistory);
publishShellHistorySnapshot(savedShellHistory);
}
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
const terminalDataMap = readConnectionLogTerminalDataMap();
connectionLogTerminalDataRef.current = terminalDataMap;
if (savedConnectionLogs) {
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
}
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) {
managedSourcesRef.current = savedManagedSources;
setManagedSources(savedManagedSources);
}
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (cancelled) return;
if (gcVer === groupConfigsWriteVersion.current) {
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
groupConfigsRef.current = sanitizedGC;
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}
}
} finally {
// StrictMode remount cancels the first init; only the surviving effect
// may publish "vault ready" or terminals can boot against empty keys.
if (!cancelled) {
setIsInitialized(true);
setVaultInitialized(true);
}
}
};
void init();
return () => {
cancelled = true;
};
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
useEffect(() => {
if (!isInitialized) return;
const version = ++pluginCredentialCatalogVersion.current;
void pluginExtensionBridge.updateCredentialCatalog([]).then(async () => {
if (version !== pluginCredentialCatalogVersion.current) return;
const [encryptedIdentities, encryptedKeys] = await Promise.all([
encryptIdentities(identities),
encryptKeys(keys),
]);
if (version !== pluginCredentialCatalogVersion.current) return;
const catalog = new Map<string, string | null>();
const addCredential = (id: string, ciphertext: string | undefined) => {
if (id.length < 16 || id.length > 256 || !isEncryptedCredentialPlaceholder(ciphertext)) return;
if (catalog.has(id)) catalog.set(id, null);
else catalog.set(id, ciphertext);
};
for (const identity of encryptedIdentities) {
addCredential(identity.id, identity.password);
}
for (const key of encryptedKeys) {
addCredential(key.id, key.privateKey);
}
const entries = [...catalog.entries()].flatMap(([id, ciphertext]) => (
ciphertext ? [{ id, ciphertext }] : []
));
if (version === pluginCredentialCatalogVersion.current) {
await pluginExtensionBridge.updateCredentialCatalog(entries);
}
}).catch(() => {
// Plugin development mode and OS-backed encryption are both optional.
// Clearing happens before asynchronous preparation so removed or stale
// Vault credentials cannot remain usable if encryption/update fails.
});
}, [identities, isInitialized, keys]);
useEffect(() => {
if (typeof window === "undefined") return;
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage) return;
const key = event.key;
if (!key) return;
if (key === STORAGE_KEY_HOSTS) {
const next = safeParse<Host[]>(event.newValue) ?? [];
// Bump write version to invalidate any in-flight encrypt from this
// window — the cross-window data is newer and must not be overwritten.
++hostsWriteVersion.current;
const seq = ++hostsReadSeq.current;
const writeAtStart = hostsWriteVersion.current;
decryptHosts(next).then((dec) => {
// Discard if a newer storage event arrived OR a local write occurred
// during the decrypt (writeVersion would have advanced).
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
setHosts(normalizeVaultOrder(dec.map((host) => sanitizeHost(host))));
});
return;
}
if (key === STORAGE_KEY_KEYS) {
const raw = safeParse<unknown[]>(event.newValue) ?? [];
const migratedKeys: SSHKey[] = [];
for (const entry of raw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record || isLegacyUnsupportedKey(record)) continue;
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
++keysWriteVersion.current;
const seq = ++keysReadSeq.current;
const writeAtStart = keysWriteVersion.current;
decryptKeys(migratedKeys).then((dec) => {
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
setKeys(normalizeVaultOrder(dec));
});
return;
}
if (key === STORAGE_KEY_IDENTITIES) {
const next = safeParse<Identity[]>(event.newValue) ?? [];
++identitiesWriteVersion.current;
const seq = ++identitiesReadSeq.current;
const writeAtStart = identitiesWriteVersion.current;
decryptIdentities(next).then((dec) => {
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
setIdentities(normalizeVaultOrder(dec));
});
return;
}
if (key === STORAGE_KEY_PROXY_PROFILES) {
const next = safeParse<ProxyProfile[]>(event.newValue) ?? [];
++proxyProfilesWriteVersion.current;
const seq = ++proxyProfilesReadSeq.current;
const writeAtStart = proxyProfilesWriteVersion.current;
decryptProxyProfiles(next).then((dec) => {
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
setProxyProfiles(normalizeVaultOrder(dec));
});
return;
}
if (key === STORAGE_KEY_SNIPPETS) {
// StorageEvent.newValue is the value at fire time. A peer write can be
// delivered only after this window's replace already committed and
// cleared snippetsWriteReplaceRef — adopting that older payload would
// resurrect the pre-replacement catalog in memory (and on the next edit).
if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_SNIPPETS)) {
return;
}
const next = normalizeVaultOrder(safeParse<Snippet[]>(event.newValue) ?? []);
// Invalidate write-version readers, but do not clear snippetsWriteOwnerRef:
// an in-flight updateSnippets rebases onto this disk snapshot under the
// vault lock instead of being cancelled (which would drop local edits).
++snippetsWriteVersion.current;
const pendingBase = snippetsWriteBaseRef.current;
if (pendingBase !== null) {
// A queued local save still owns an outstanding ancestor. Replacing
// optimistic state with the remote snapshot (and clearing that base)
// would make the next local edit derive from remote-only and drop the
// first mutation when it supersedes the queued owner.
const merged = normalizeVaultOrder(
rebaseSnippetVaultWrite({
base: pendingBase,
ours: snippetsRef.current,
theirs: next,
}),
);
snippetsRef.current = merged;
setSnippets(merged);
return;
}
if (snippetsWriteReplaceRef.current) {
// Clear/restore/import left base null while replace is outstanding.
// Adopting the remote catalog here would pollute memory; a later local
// edit would still run in replace mode and overwrite disk with the old
// catalog plus that edit.
return;
}
snippetsRef.current = next;
setSnippets(next);
return;
}
if (key === STORAGE_KEY_GROUPS) {
const next = safeParse<string[]>(event.newValue) ?? [];
++customGroupsWriteVersion.current;
customGroupsRef.current = next;
setCustomGroups(next);
return;
}
if (key === STORAGE_KEY_SNIPPET_PACKAGES) {
const next = safeParse<string[]>(event.newValue) ?? [];
setSnippetPackages(next);
return;
}
if (key === STORAGE_KEY_NOTES) {
const next = safeParse<VaultNote[]>(event.newValue) ?? [];
setNotes(normalizeVaultNotes(next));
return;
}
if (key === STORAGE_KEY_NOTE_GROUPS) {
const next = safeParse<string[]>(event.newValue) ?? [];
setNoteGroups(normalizeNoteGroups(next));
return;
}
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(normalizeVaultOrder(normalizeKnownHosts(next)));
return;
}
if (key === STORAGE_KEY_SHELL_HISTORY) {
const next = sanitizeGlobalHistoryEntries(
safeParse<ShellHistoryEntry[]>(event.newValue) ?? [],
);
setShellHistory(next);
publishShellHistorySnapshot(next);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
return;
}
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
++managedSourcesWriteVersion.current;
managedSourcesRef.current = next;
setManagedSources(next);
return;
}
if (key === STORAGE_KEY_GROUP_CONFIGS) {
const next = safeParse<GroupConfig[]>(event.newValue) ?? [];
++groupConfigsWriteVersion.current;
const seq = ++groupConfigsReadSeq.current;
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current) {
const normalized = normalizeVaultOrder(dec.map(sanitizeGroupConfig));
groupConfigsRef.current = normalized;
setGroupConfigs(normalized);
}
});
return;
}
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = readConnectionLogTerminalDataMap();
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
}
};
window.addEventListener("storage", handleStorage);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, [applyConnectionLogsFromStorage]);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === hostId);
if (idx < 0) return prev;
const now = Date.now();
if (prev[idx].lastConnectedAt === now) return prev;
const next = prev.slice();
next[idx] = { ...prev[idx], lastConnectedAt: now };
const ver = ++hostsWriteVersion.current;
const encryptPromise = encryptHosts(next);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
});
hostsWritePendingRef.current = writePromise;
return next;
});
}, [commitEncryptedHostsUnderVaultLock]);
const updateHostDistro = useCallback((hostId: string, distro: string) => {
const normalized = normalizeDistroId(distro);
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, distro: normalized } : h,
);
const ver = ++hostsWriteVersion.current;
const encryptPromise = encryptHosts(next);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
});
hostsWritePendingRef.current = writePromise;
return next;
});
}, [commitEncryptedHostsUnderVaultLock]);
const exportData = useCallback(
(): ExportableVaultData => ({
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts,
groupConfigs,
}),
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts, groupConfigs],
);
const importData = useCallback(
(payload: Partial<ExportableVaultData>): Promise<void> => {
const encryptedWrites: Promise<void>[] = [];
if (payload.hosts) {
// Cloud payloads no longer carry `lastConnectedAt` (#2629), but any
// remote reconciliation replaces the whole local host array. Re-attach
// each matching local host's device-local timestamp so applying a
// remote snippet/settings change cannot clear this device's recent-
// hosts list. Timestamps present on the incoming hosts (local backups,
// legacy cloud snapshots) still win as before.
encryptedWrites.push(
updateHosts(
retainLocalHostLastConnectedAt(payload.hosts, hostsRef.current) ?? payload.hosts,
).then(() => undefined),
);
}
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
if (payload.snippets) {
// Full-snapshot restore/import must replace, not additive-rebase; otherwise
// a concurrent disk-only snippet survives and the restore looks incomplete.
encryptedWrites.push(
updateSnippets(payload.snippets, { replace: true }).then(() => undefined),
);
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
if (payload.noteGroups) updateNoteGroups(payload.noteGroups);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
return Promise.all(encryptedWrites).then(() => undefined);
},
[
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateCustomGroups,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
updateKnownHosts,
updateGroupConfigs,
],
);
const commitPluginImporterData = useCallback(async ({
drafts,
destination,
}: PluginImporterCommitRequest): Promise<number> => {
while (true) {
// Drain in-flight encrypt work outside the lock so concurrent edits can
// finish encrypting (and later write) without deadlocking on acquisition.
await waitForPendingVaultWrites();
const attempt = await withVaultImportLock("vault", async () => {
const writeVersions = {
hosts: hostsWriteVersion.current,
keys: keysWriteVersion.current,
identities: identitiesWriteVersion.current,
snippets: snippetsWriteVersion.current,
groups: customGroupsWriteVersion.current,
groupConfigs: groupConfigsWriteVersion.current,
};
const raw = new Map(
[...PLUGIN_IMPORT_TRANSACTION_KEYS].map((key) => [key, localStorageAdapter.readString(key)]),
);
const storedHosts = readStoredArray<Host>(
STORAGE_KEY_HOSTS,
raw.get(STORAGE_KEY_HOSTS) ?? null,
);
const storedKeys = readStoredArray<SSHKey>(
STORAGE_KEY_KEYS,
raw.get(STORAGE_KEY_KEYS) ?? null,
);
const storedIdentities = readStoredArray<Identity>(
STORAGE_KEY_IDENTITIES,
raw.get(STORAGE_KEY_IDENTITIES) ?? null,
);
const storedSnippets = readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
raw.get(STORAGE_KEY_SNIPPETS) ?? null,
);
const storedGroups = readStoredArray<string>(
STORAGE_KEY_GROUPS,
raw.get(STORAGE_KEY_GROUPS) ?? null,
);
const storedGroupConfigs = readStoredArray<GroupConfig>(
STORAGE_KEY_GROUP_CONFIGS,
raw.get(STORAGE_KEY_GROUP_CONFIGS) ?? null,
);
const [latestHosts, latestKeys, latestIdentities, latestGroupConfigs] = await Promise.all([
decryptHosts(storedHosts),
decryptKeys(storedKeys),
decryptIdentities(storedIdentities),
decryptGroupConfigs(storedGroupConfigs),
]);
const merged = mergePluginImporterDrafts({
hosts: normalizeVaultOrder(latestHosts.map((host) => sanitizeHost(host))),
keys: normalizeVaultOrder(latestKeys),
identities: normalizeVaultOrder(latestIdentities),
snippets: normalizeVaultOrder(storedSnippets),
customGroups: storedGroups,
}, drafts);
const committed = applyPluginImporterDestination(
merged,
latestHosts.length,
destination,
storedGroups,
{
identities: latestIdentities.length,
keys: latestKeys.length,
},
);
const nextHosts = normalizeVaultOrder(committed.hosts.map((host) => sanitizeHost(host)));
const nextKeys = normalizeVaultOrder(committed.keys);
const nextIdentities = normalizeVaultOrder(committed.identities);
const nextSnippets = normalizeVaultOrder(committed.snippets);
const nextGroups = [...committed.customGroups];
const groupOrderByPath = new Map(
nextGroups.map((groupPath, index) => [groupPath, (index + 1) * 1000]),
);
const existingConfigByPath = new Map(
latestGroupConfigs.map((config) => [config.path, config]),
);
const nextGroupConfigs = normalizeVaultOrder([
...nextGroups.map((groupPath) => sanitizeGroupConfig({
...(existingConfigByPath.get(groupPath) ?? { path: groupPath }),
path: groupPath,
order: groupOrderByPath.get(groupPath),
})),
...latestGroupConfigs
.filter((config) => !groupOrderByPath.has(config.path))
.map(sanitizeGroupConfig),
]);
const [encryptedHosts, encryptedKeys, encryptedIdentities, encryptedGroupConfigs] = await Promise.all([
encryptHosts(nextHosts),
encryptKeys(nextKeys),
encryptIdentities(nextIdentities),
encryptGroupConfigs(nextGroupConfigs),
]);
// Do not wait for pending writers here — they may be queued on this lock.
const changedWhilePreparing = (
writeVersions.hosts !== hostsWriteVersion.current
|| writeVersions.keys !== keysWriteVersion.current
|| writeVersions.identities !== identitiesWriteVersion.current
|| writeVersions.snippets !== snippetsWriteVersion.current
|| writeVersions.groups !== customGroupsWriteVersion.current
|| writeVersions.groupConfigs !== groupConfigsWriteVersion.current
|| [...PLUGIN_IMPORT_TRANSACTION_KEYS].some(
(key) => localStorageAdapter.readString(key) !== raw.get(key),
)
);
if (changedWhilePreparing) return null;
++hostsWriteVersion.current;
++keysWriteVersion.current;
++identitiesWriteVersion.current;
++snippetsWriteVersion.current;
++customGroupsWriteVersion.current;
++groupConfigsWriteVersion.current;
commitPluginImporterTransaction(localStorageAdapter, [
[STORAGE_KEY_HOSTS, encryptedHosts],
[STORAGE_KEY_KEYS, encryptedKeys],
[STORAGE_KEY_IDENTITIES, encryptedIdentities],
[STORAGE_KEY_SNIPPETS, nextSnippets],
[STORAGE_KEY_GROUPS, nextGroups],
[STORAGE_KEY_GROUP_CONFIGS, encryptedGroupConfigs],
]);
customGroupsRef.current = nextGroups;
snippetsRef.current = nextSnippets;
hostsRef.current = nextHosts;
groupConfigsRef.current = nextGroupConfigs;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextHosts);
setKeys(nextKeys);
setIdentities(nextIdentities);
setSnippets(nextSnippets);
setCustomGroups(nextGroups);
setGroupConfigs(nextGroupConfigs);
return committed.addedCount;
});
if (attempt !== null) return attempt;
// Concurrent memory edits advanced write versions; release and retry so
// their locked disk writes can complete first.
}
}, [waitForPendingVaultWrites]);
const importDataFromString = useCallback(
(jsonString: string): Promise<void> => {
const data = JSON.parse(jsonString);
return importData(data);
},
[importData],
);
return {
isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts,
shellHistory,
connectionLogs,
managedSources,
groupConfigs,
updateHosts,
readPersistedHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
deleteSelectedSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
readPersistedManagedSources,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
removeShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
updateHostDistro,
updateHostLastConnected,
convertKnownHostToHost,
exportData,
importDataFromString,
commitPluginImporterData,
clearVaultData,
};
};