2176 lines
85 KiB
TypeScript
2176 lines
85 KiB
TypeScript
|
|
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
|
|||
|
|
* (50–73 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,
|
|||
|
|
};
|
|||
|
|
};
|