mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
6b9b71ac8a
* feat(scripts): add terminal script automation, recording, and side panel Introduce nct script runtime in the main process with terminal-side recording, execution overlay, Monaco editor, connect-script queues, and scripts side panel integration. Fix recording to wire scriptRecorderRef into xterm, use waitForPrompt for root/user shells, and save recorded scripts through the global editor dialog. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): address Bugbot review findings for recording and side panel Refresh script run state in side panel memo, guard toolbar recording against multi-session conflicts, reset connect scripts on reconnect, resolve focused session via getActiveTerminalSessionId, and avoid wiping host edit form when snippets list updates. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): prevent pending script re-run and improve workspace run feedback Track one-shot pendingScript execution separately from onConnect queue so reconnects do not replay Run-now scripts. Fall back to focused session when running on all tabs outside a workspace, and toast when every tab is still connecting. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): consume connect-script window once and filter serial hosts Mark the onConnect script evaluation as consumed even when the queue is empty so later snippet/host edits do not rerun automation mid-session. Restore serial-host filtering in SelectHostPanel confirm handler. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): grace period before giving up on connect-script queue Only mark onConnect automation as consumed after scripts run or a short post-connect grace window expires, so late-loading snippets still trigger while mid-session Vault edits do not. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): gate connect-script consumption on vault hydration Replace the grace timer with vault-init and unresolved host script binding checks so late-loading snippets still run onConnect automation while mid-session edits stay suppressed. Clear pending script run state on disconnect so reconnect retries workspace pending scripts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): honor run targets and retry failed connect automation Route QuickScriptEditorDialog Run through onRunSnippet for multi-host workspaces. Defer connect/pending script completion markers until each script succeeds and retry the queue after failures. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): stabilize pending runs and global connect queue sync Keep pending script run state across reconnects, run onConnect scripts before one-shot workspace pending scripts, and strip per-host queue entries when an onConnect script targets all hosts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): defer host queue snippet sync and guard connect runs Persist snippet target links only when the host panel is saved, block duplicate connect-script scheduling while a batch is running, and require runnable hosts before showing Run in the snippets editor footer. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): retry connect failures and surface output-trigger errors Use a retry tick in connect-script effect deps, toast automation failures from output triggers, and require runnable hosts before QuickScript Run. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): track id-less pending runs and re-arm output triggers Remember anonymous pendingScript objects after first run and allow onOutput triggers to fire again when new terminal output arrives. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): unblock paused connect runs and pass session meta Resolve waitForScriptRun on pause, run workspace scripts per session with hostname context, defer output-trigger arming until runs start, and add noRunnableHosts i18n for QuickScript Run. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): serialize output triggers and defer empty connect finalize Fire at most one onOutput trigger per scan while a run is active, and only finalize an empty connect queue after vault snippets are loaded. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): guard output launch race and script run waits Block overlapping onOutput launches, timeout stuck waitForScriptRun promises, and disable vault Run when no connectable targets exist. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): match output triggers on new chunks only Scan onOutput patterns against recent terminal output instead of buffer length dedupe, and restore overflow-visible on SelectHostPanel new-host flow. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): await sequential workspace runs and output launch Wait for each tab script to finish in sequential workspace mode, and hold output-trigger launch guard until the session run becomes active. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): wait through pause and hold output trigger until done Keep connect and sequential workspace queues blocked until scripts complete or fail, and retain output-trigger guard until the launched run finishes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): harden output trigger wait helpers Add explicit timeouts to output-trigger wait helpers and stop rescanning the buffer after script completion to avoid sticky-match rerun loops. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): allow empty connect queue and match output in new chunks Treat an empty connectScriptIds array as an explicit cleared queue instead of re-migrating from snippet targets, and only fire onOutput triggers when the latest terminal output contains the pattern. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): require fresh output overlap for onOutput triggers Only fire output-matched scripts when the regex match extends into the latest terminal chunk, preventing reruns from stale buffer text. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): promote manual scripts added to host connect queue Diff connect queue changes from raw connectScriptIds so saving a host updates manual scripts to onConnect when they are newly queued. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): dedupe output triggers by match position Track the buffer end offset of each output trigger match and ignore stale re-scans, with a longer wait for run registration before retry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): record output trigger matches only after run starts Propagate automation launch failures to output triggers and commit match dedupe only once the session run is registered as active. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): address Codex review on wait cursor and sensitive codegen Track scan offset so waitFor/waitForAny only match output after the previous wait, and emit sendLine after sensitive prompt in recorded scripts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): harden onOutput triggers against PTY echo and local input Filter server output before pattern matching so onOutput fires only after Enter on real stdout, not on keystroke/paste echo, OSC/SGR sequences, or alternate-screen apps. Add dev diagnostics and scope triggers to the active session when no explicit host targets are configured. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(scripts): remove onOutput trigger debug logging Drop temporary diagnostics module and console traces now that PTY echo filtering is verified; keep the production trigger pipeline unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1110 lines
40 KiB
TypeScript
1110 lines
40 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { migrateHostsFromLegacyLineTimestamps, normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||
import { sanitizeGroupConfig } from "../../domain/groupConfig";
|
||
import { normalizeKnownHosts } from "../../domain/knownHosts";
|
||
import { normalizeNoteGroups, normalizeVaultNotes } from "../../domain/notes";
|
||
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, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
|
||
import {
|
||
buildTerminalDataMapFromLogs,
|
||
mergeConnectionLogsFromStorage,
|
||
mergeTerminalDataIntoLogs,
|
||
mergeTerminalDataMapsForStorage,
|
||
type ConnectionLogTerminalDataMap,
|
||
} from "../../domain/connectionLogTerminalData";
|
||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||
import { setVaultInitialized } from "./vaultInitStore";
|
||
import {
|
||
decryptGroupConfigs,
|
||
decryptHosts,
|
||
decryptIdentities,
|
||
decryptKeys,
|
||
decryptProxyProfiles,
|
||
encryptGroupConfigs,
|
||
encryptHosts,
|
||
encryptIdentities,
|
||
encryptKeys,
|
||
encryptProxyProfiles,
|
||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||
|
||
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 };
|
||
|
||
// 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[]>([]);
|
||
|
||
// 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 groupConfigsWriteVersion = useRef(0);
|
||
|
||
// 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 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);
|
||
}, []);
|
||
|
||
const updateHosts = useCallback((data: Host[]) => {
|
||
const cleaned = normalizeVaultOrder(data.map(sanitizeHost));
|
||
setHosts(cleaned);
|
||
const ver = ++hostsWriteVersion.current;
|
||
return encryptHosts(cleaned).then((enc) => {
|
||
if (ver === hostsWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||
});
|
||
}, []);
|
||
|
||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||
const cleaned = normalizeVaultOrder(data);
|
||
setKeys(cleaned);
|
||
const ver = ++keysWriteVersion.current;
|
||
return encryptKeys(cleaned).then((enc) => {
|
||
if (ver === keysWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||
});
|
||
}, []);
|
||
|
||
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;
|
||
void encryptKeys(updated).then((enc) => {
|
||
if (ver === keysWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||
});
|
||
return newKey;
|
||
}, [keys]);
|
||
|
||
const updateIdentities = useCallback((data: Identity[]) => {
|
||
const cleaned = normalizeVaultOrder(data);
|
||
setIdentities(cleaned);
|
||
const ver = ++identitiesWriteVersion.current;
|
||
return encryptIdentities(cleaned).then((enc) => {
|
||
if (ver === identitiesWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||
});
|
||
}, []);
|
||
|
||
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[]) => {
|
||
const cleaned = normalizeVaultOrder(data);
|
||
setSnippets(cleaned);
|
||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
|
||
}, []);
|
||
|
||
const updateSnippetPackages = useCallback((data: string[]) => {
|
||
setSnippetPackages(data);
|
||
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
|
||
}, []);
|
||
|
||
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
|
||
const cleaned = normalizeVaultNotes(data);
|
||
setNotes(cleaned);
|
||
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
|
||
}, []);
|
||
|
||
const updateNoteGroups = useCallback((data: unknown) => {
|
||
const cleaned = normalizeNoteGroups(data);
|
||
setNoteGroups(cleaned);
|
||
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
|
||
}, []);
|
||
|
||
const updateCustomGroups = useCallback((data: string[]) => {
|
||
setCustomGroups(data);
|
||
localStorageAdapter.write(STORAGE_KEY_GROUPS, data);
|
||
|
||
const groupOrderByPath = new Map<string, number>(
|
||
data.map((path, index) => [path, (index + 1) * 1000]),
|
||
);
|
||
const existingConfigByPath = new Map<string, GroupConfig>(
|
||
groupConfigs.map((config) => [config.path, config]),
|
||
);
|
||
const orderedConfigs = data.map((path) => {
|
||
const existing = existingConfigByPath.get(path);
|
||
const base: GroupConfig = existing ? { ...existing } : { path };
|
||
return sanitizeGroupConfig({
|
||
...base,
|
||
path,
|
||
order: groupOrderByPath.get(path),
|
||
});
|
||
});
|
||
const retainedConfigs = groupConfigs.filter((config) => !groupOrderByPath.has(config.path));
|
||
const cleanedGroupConfigs = normalizeVaultOrder([
|
||
...orderedConfigs,
|
||
...retainedConfigs.map(sanitizeGroupConfig),
|
||
]);
|
||
setGroupConfigs(cleanedGroupConfigs);
|
||
const ver = ++groupConfigsWriteVersion.current;
|
||
void encryptGroupConfigs(cleanedGroupConfigs).then((enc) => {
|
||
if (ver === groupConfigsWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||
});
|
||
}, [groupConfigs]);
|
||
|
||
const updateKnownHosts = useCallback((data: KnownHost[]) => {
|
||
const cleaned = normalizeVaultOrder(data);
|
||
setKnownHosts(cleaned);
|
||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, cleaned);
|
||
}, []);
|
||
|
||
const updateManagedSources = useCallback((data: ManagedSource[]) => {
|
||
setManagedSources(data);
|
||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
|
||
}, []);
|
||
|
||
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));
|
||
setGroupConfigs(cleaned);
|
||
const ver = ++groupConfigsWriteVersion.current;
|
||
return encryptGroupConfigs(cleaned).then((enc) => {
|
||
if (ver === groupConfigsWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||
});
|
||
}, []);
|
||
|
||
const clearVaultData = useCallback(() => {
|
||
updateHosts([]);
|
||
updateKeys([]);
|
||
updateIdentities([]);
|
||
updateProxyProfiles([]);
|
||
updateSnippets([]);
|
||
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,
|
||
]);
|
||
|
||
const addShellHistoryEntry = useCallback(
|
||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||
setShellHistory((prev) => {
|
||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||
if (updated === prev) return prev;
|
||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||
return updated;
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const clearShellHistory = useCallback(() => {
|
||
setShellHistory([]);
|
||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, []);
|
||
}, []);
|
||
|
||
// 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]);
|
||
|
||
// 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;
|
||
encryptHosts(updated).then((enc) => {
|
||
if (ver === hostsWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||
});
|
||
return updated;
|
||
});
|
||
|
||
return newHost;
|
||
}, [hosts]);
|
||
|
||
useEffect(() => {
|
||
const init = async () => {
|
||
try {
|
||
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 (ver === hostsWriteVersion.current) {
|
||
const sanitized = normalizeVaultOrder(
|
||
migrateHostsFromLegacyLineTimestamps(
|
||
decrypted.map(sanitizeHost),
|
||
readLegacyLineTimestampsEnabled(),
|
||
),
|
||
);
|
||
setHosts(sanitized);
|
||
encryptHosts(sanitized).then((enc) => {
|
||
if (ver === hostsWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||
});
|
||
}
|
||
} 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 (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 (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 (proxyVer === proxyProfilesWriteVersion.current) {
|
||
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
|
||
setProxyProfiles(orderedProfiles);
|
||
encryptProxyProfiles(orderedProfiles).then((enc) => {
|
||
if (proxyVer === proxyProfilesWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
setSnippets(orderedSnippets);
|
||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, orderedSnippets);
|
||
}
|
||
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);
|
||
}
|
||
|
||
// 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) 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 (gcVer === groupConfigsWriteVersion.current) {
|
||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||
setGroupConfigs(sanitizedGC);
|
||
encryptGroupConfigs(sanitizedGC).then((enc) => {
|
||
if (gcVer === groupConfigsWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||
});
|
||
}
|
||
}
|
||
} finally {
|
||
setIsInitialized(true);
|
||
setVaultInitialized(true);
|
||
}
|
||
};
|
||
|
||
init();
|
||
}, [updateHosts, updateSnippets]);
|
||
|
||
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(sanitizeHost)));
|
||
});
|
||
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) {
|
||
const next = safeParse<Snippet[]>(event.newValue) ?? [];
|
||
setSnippets(normalizeVaultOrder(next));
|
||
return;
|
||
}
|
||
|
||
if (key === STORAGE_KEY_GROUPS) {
|
||
const next = safeParse<string[]>(event.newValue) ?? [];
|
||
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);
|
||
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) ?? [];
|
||
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)
|
||
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
|
||
});
|
||
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 next = prev.map((h) =>
|
||
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
|
||
);
|
||
const ver = ++hostsWriteVersion.current;
|
||
encryptHosts(next).then((enc) => {
|
||
if (ver === hostsWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||
});
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
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;
|
||
encryptHosts(next).then((enc) => {
|
||
if (ver === hostsWriteVersion.current)
|
||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||
});
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
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) encryptedWrites.push(updateHosts(payload.hosts));
|
||
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) updateSnippets(payload.snippets);
|
||
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 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,
|
||
updateKeys,
|
||
importOrReuseKey,
|
||
updateIdentities,
|
||
updateProxyProfiles,
|
||
updateSnippets,
|
||
updateSnippetPackages,
|
||
updateNotes,
|
||
updateNoteGroups,
|
||
updateCustomGroups,
|
||
updateKnownHosts,
|
||
updateManagedSources,
|
||
updateGroupConfigs,
|
||
addShellHistoryEntry,
|
||
clearShellHistory,
|
||
addConnectionLog,
|
||
updateConnectionLog,
|
||
toggleConnectionLogSaved,
|
||
deleteConnectionLog,
|
||
clearUnsavedConnectionLogs,
|
||
updateHostDistro,
|
||
updateHostLastConnected,
|
||
convertKnownHostToHost,
|
||
exportData,
|
||
importDataFromString,
|
||
clearVaultData,
|
||
};
|
||
};
|