mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
db69d5ac39
* fix: harden sync overwrite recovery * refactor: separate backup retention settings * refactor: align backup retention controls * refactor: simplify backup retention card * fix: address PR #720 deep-review findings - Close the cross-window restore race by holding a time-bounded barrier in localStorage during every destructive apply; useAutoSync skips pushes while it's set, preventing a pre-restore snapshot from clobbering just-restored cloud data. - Round-trip startup three-way merges so merged-in local additions actually reach the cloud instead of living only on the device that ran the merge until the next edit. - Upgrade sync signatures from a 64-char ciphertext prefix to full SHA-256 (v3), closing the tail-mutation replay weakness. - Harden the vault-backup IPC: payload size cap, enum-validated reason, sanitized version strings, strict maxCount, concurrent-call mutex, monotonic createdAt to avoid same-ms ordering ties. - Extract the anchor-change decision into a pure module with unit tests covering no-anchor, resource-id drift, and signature mismatch paths. - Capture the protective backup from the pre-apply closure snapshot so it reflects what's being replaced rather than what was imported. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR #720 follow-up review findings Make protective backup abort-on-failure (was best-effort console.error), preserve nested syncedAt in fingerprint, use UTF-8 byte length for size guard, throw on conflict-inspect failure so stale uploads can't leak through, treat unreadable remote as changed, canonical-JSON signature meta, and hold the version stamp on transient backup failures so the retry path still fires. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address second-pass review findings on PR #720 - Hold version-change stamp when payload is non-meaningful (covers the startup vault-rehydrate race where a transient empty snapshot would permanently skip the upgrade backup). - readBackupRecord stat-checks before readFile so an oversized file in the backup dir cannot OOM the renderer on enumeration. - Reject maxBackups input outside 1..100 instead of silently clamping (matches the i18n error copy and the main-process sanitizer bound). - Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a concurrent auto-sync in another window cannot interleave. - sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto subtle; createSyncedFileSignature returns null, forcing the unreadable-remote → three-way-merge path instead of a weak length-only pseudo-signature. - Document that array order in normalizePayloadForHash is an invariant enforced by producers, not the hash function. - Drop three-way-merge completion logs from console.log to console.info. - Comment the implicit restore → store-listener refresh chain so future refactors don't silently break the UI reload path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address third-pass review findings on PR #720 Resolves I-3 through I-8 and related cleanup items identified in the deep review. Highlights: - replace setTimeout(0) post-merge round-trip with a direct syncAllProviders call using the already-computed merged payload, removing the React-commit race - resolve the empty-vault confirmation promise on unmount so a mid-dialog window teardown doesn't leak the resolver - retry the version-change backup as hosts/keys hydrate, instead of latching on the first (possibly empty) snapshot - heartbeat-refresh the cross-window restore barrier so long applies cannot expose a post-60s window to concurrent auto-sync - add a diagnostic warning when connected providers hold divergent bases (multi-account configurations) - surface a user-visible "Sync paused" toast when startup inspect fails, replacing the previous silent gate-open - tie-break backup list sort by id when createdAt collides - extract applyProtectedSyncPayload so the main and settings windows cannot drift on restore-barrier / protective-backup handling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address deep-review findings on PR #720 Deep re-review surfaced six Important issues that survived the prior four review rounds. All are hardened here: - I1: fsync the protective backup file AND its directory before the rename completes, so a system crash between backup creation and the restore it guards cannot leave a torn/zero-length safety net. - I3: persist an apply-in-progress sentinel across the non-atomic localStorage writes in applySyncPayload. A crash mid-apply now surfaces on the next startup (toast + refuse auto-push) instead of silently pushing the half-applied state over an intact cloud copy. - I2: only open the auto-sync gate (remoteCheckDoneRef) when the startup inspect validated cleanly. Add a bounded exponential-backoff retry so a transient inspect failure self-heals instead of wedging auto-sync until restart. - I5: save the sync base BEFORE advancing the per-provider anchor inside uploadToProvider. A renderer crash between the two writes now degrades to "stale anchor forces re-inspect on next run," which re-merges against the fresh base — eliminating the silent base-drift window where a 3rd-device race could misclassify entries. - I6: main process broadcasts a vaultBackups:changed IPC event on every mutation; useLocalVaultBackups subscribes so protective backups created from the main window show up in the Settings backup list without manual refresh. - I4: update PR description + code comment to match the actual (safer) design: auto-sync gate opens on vault init, with hasMeaningfulSyncData + restore barrier preventing empty-push; the version-change backup is best-effort and retries as data hydrates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: serialize startup checkRemoteVersion and stabilize its deps Re-review flagged that checkRemoteVersion's useCallback depended on `config` — a fresh object literal from App.tsx on every render — so the retry effect restarted with attempt=0 on every vault edit and could spawn overlapping in-flight inspect+apply runs. Two concurrent commitRemoteInspection + onApplyPayload calls could race on the apply-in-progress sentinel around interleaved writes. Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady` through refs so checkRemoteVersion's identity no longer churns with unrelated App state. Add an in-flight guard that returns early when a previous invocation is still awaiting the network, closing the same-window re-entry gap that withRestoreBarrier intentionally doesn't cover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: release in-flight lock on no-connected-provider early return Third-pass review caught that `checkRemoteInFlightRef` was acquired before the `!connectedProvider` check, so that early return leaked the lock and every subsequent retry-timer tick silently no-op'd. Move the acquisition past the early return so the only path that takes the lock reaches the finally-release. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
360 lines
16 KiB
TypeScript
360 lines
16 KiB
TypeScript
/**
|
|
* Sync Payload Builders — Single source of truth for constructing and applying
|
|
* the encrypted cloud-sync payload.
|
|
*
|
|
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
|
|
* must use these helpers to guarantee every field is included and no data is
|
|
* silently dropped.
|
|
*/
|
|
|
|
import type {
|
|
GroupConfig,
|
|
Host,
|
|
Identity,
|
|
KnownHost,
|
|
PortForwardingRule,
|
|
SftpBookmark,
|
|
Snippet,
|
|
SSHKey,
|
|
} from '../domain/models';
|
|
import type { SyncPayload } from '../domain/sync';
|
|
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
|
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
|
import {
|
|
STORAGE_KEY_THEME,
|
|
STORAGE_KEY_UI_THEME_LIGHT,
|
|
STORAGE_KEY_UI_THEME_DARK,
|
|
STORAGE_KEY_ACCENT_MODE,
|
|
STORAGE_KEY_COLOR,
|
|
STORAGE_KEY_UI_FONT_FAMILY,
|
|
STORAGE_KEY_UI_LANGUAGE,
|
|
STORAGE_KEY_CUSTOM_CSS,
|
|
STORAGE_KEY_TERM_THEME,
|
|
STORAGE_KEY_TERM_FONT_FAMILY,
|
|
STORAGE_KEY_TERM_FONT_SIZE,
|
|
STORAGE_KEY_TERM_SETTINGS,
|
|
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
|
STORAGE_KEY_EDITOR_WORD_WRAP,
|
|
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
|
STORAGE_KEY_SFTP_AUTO_SYNC,
|
|
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
|
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
|
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
|
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
|
STORAGE_KEY_CUSTOM_THEMES,
|
|
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
|
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
|
STORAGE_KEY_SHOW_SFTP_TAB,
|
|
} from '../infrastructure/config/storageKeys';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** All vault-owned data that participates in cloud sync. */
|
|
export interface SyncableVaultData {
|
|
hosts: Host[];
|
|
keys: SSHKey[];
|
|
identities: Identity[];
|
|
snippets: Snippet[];
|
|
customGroups: string[];
|
|
snippetPackages?: string[];
|
|
knownHosts: KnownHost[];
|
|
groupConfigs?: GroupConfig[];
|
|
}
|
|
|
|
/**
|
|
* Returns true when the payload contains any meaningful user data worth
|
|
* protecting or syncing.
|
|
*/
|
|
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
|
const hasEntities =
|
|
(payload.hosts?.length ?? 0) > 0 ||
|
|
(payload.keys?.length ?? 0) > 0 ||
|
|
(payload.snippets?.length ?? 0) > 0 ||
|
|
(payload.identities?.length ?? 0) > 0 ||
|
|
(payload.customGroups?.length ?? 0) > 0 ||
|
|
(payload.snippetPackages?.length ?? 0) > 0 ||
|
|
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
|
(payload.knownHosts?.length ?? 0) > 0 ||
|
|
(payload.groupConfigs?.length ?? 0) > 0;
|
|
|
|
if (hasEntities) return true;
|
|
|
|
return Boolean(
|
|
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
|
);
|
|
}
|
|
|
|
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
|
interface SyncPayloadImporters {
|
|
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
|
|
importVaultData: (jsonString: string) => void;
|
|
/** Import port-forwarding rules (lives outside the vault hook). */
|
|
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
|
/** Called after synced settings have been written to localStorage. */
|
|
onSettingsApplied?: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings sync helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
|
const SYNCABLE_TERMINAL_KEYS = [
|
|
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
|
|
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
|
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
|
'smoothScrolling',
|
|
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
|
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
|
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
|
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
|
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
|
] as const;
|
|
|
|
/**
|
|
* Collect all syncable settings from localStorage.
|
|
*/
|
|
export function collectSyncableSettings(): SyncPayload['settings'] {
|
|
const settings: SyncPayload['settings'] = {};
|
|
|
|
// Theme & Appearance
|
|
const theme = localStorageAdapter.readString(STORAGE_KEY_THEME);
|
|
if (theme === 'light' || theme === 'dark' || theme === 'system') settings.theme = theme;
|
|
const lightUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_LIGHT);
|
|
if (lightUi) settings.lightUiThemeId = lightUi;
|
|
const darkUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_DARK);
|
|
if (darkUi) settings.darkUiThemeId = darkUi;
|
|
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
|
|
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
|
|
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
|
if (accent) settings.customAccent = accent;
|
|
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
|
if (uiFont) settings.uiFontFamilyId = uiFont;
|
|
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
|
if (lang) settings.uiLanguage = lang;
|
|
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
|
if (css != null) settings.customCSS = css;
|
|
|
|
// Terminal
|
|
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
|
if (termTheme) settings.terminalTheme = termTheme;
|
|
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
|
if (termFont) settings.terminalFontFamily = termFont;
|
|
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
|
if (termSize != null) settings.terminalFontSize = termSize;
|
|
|
|
// Terminal settings (syncable subset only)
|
|
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
|
if (termSettingsRaw) {
|
|
try {
|
|
const full = JSON.parse(termSettingsRaw);
|
|
const subset: Record<string, unknown> = {};
|
|
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
|
if (key in full) subset[key] = full[key];
|
|
}
|
|
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
|
} catch { /* ignore corrupt data */ }
|
|
}
|
|
|
|
// Custom terminal themes
|
|
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
|
if (customThemesRaw) {
|
|
try {
|
|
const parsed = JSON.parse(customThemesRaw);
|
|
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Keyboard
|
|
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
|
if (kb) {
|
|
try {
|
|
settings.customKeyBindings = JSON.parse(kb);
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Editor
|
|
const wordWrap = localStorageAdapter.readString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
|
if (wordWrap === 'true' || wordWrap === 'false') settings.editorWordWrap = wordWrap === 'true';
|
|
|
|
// SFTP
|
|
const dblClick = localStorageAdapter.readString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
|
if (dblClick === 'open' || dblClick === 'transfer') settings.sftpDoubleClickBehavior = dblClick;
|
|
const autoSync = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
|
if (autoSync === 'true' || autoSync === 'false') settings.sftpAutoSync = autoSync === 'true';
|
|
const hidden = localStorageAdapter.readString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
|
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
|
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
|
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
|
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
|
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
|
|
|
// SFTP Bookmarks (global only — local bookmarks are device-specific)
|
|
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
|
|
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
|
|
|
|
|
|
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
|
if (showRecent != null) settings.showRecentHosts = showRecent;
|
|
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
|
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
|
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
|
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
|
|
|
return Object.keys(settings).length > 0 ? settings : undefined;
|
|
}
|
|
|
|
/**
|
|
* Apply synced settings to localStorage. Merges terminal settings
|
|
* to preserve platform-specific fields.
|
|
*/
|
|
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
|
// Theme & Appearance
|
|
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
|
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
|
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
|
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
|
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
|
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
|
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
|
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
|
|
|
// Terminal
|
|
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
|
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
|
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
|
|
|
// Terminal settings — merge with existing to preserve platform-specific keys
|
|
if (settings.terminalSettings) {
|
|
let existing: Record<string, unknown> = {};
|
|
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
|
if (raw) {
|
|
try { existing = JSON.parse(raw); } catch { /* ignore */ }
|
|
}
|
|
const merged = { ...existing };
|
|
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
|
if (key in settings.terminalSettings) {
|
|
merged[key] = settings.terminalSettings[key];
|
|
}
|
|
}
|
|
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
|
}
|
|
|
|
// Custom terminal themes
|
|
if (settings.customTerminalThemes != null) {
|
|
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(settings.customTerminalThemes));
|
|
}
|
|
|
|
// Keyboard
|
|
if (settings.customKeyBindings != null) {
|
|
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
|
|
}
|
|
|
|
// Editor
|
|
if (settings.editorWordWrap != null) localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(settings.editorWordWrap));
|
|
|
|
// SFTP
|
|
if (settings.sftpDoubleClickBehavior != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, settings.sftpDoubleClickBehavior);
|
|
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
|
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
|
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
|
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
|
|
|
// SFTP Bookmarks (global only)
|
|
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
|
|
|
|
// Immersive mode (legacy — always enabled, ignore incoming value)
|
|
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
|
|
if (settings.showOnlyUngroupedHostsInRoot != null) {
|
|
localStorageAdapter.writeBoolean(
|
|
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
|
settings.showOnlyUngroupedHostsInRoot,
|
|
);
|
|
}
|
|
if (settings.showSftpTab != null) {
|
|
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a complete `SyncPayload` from local data.
|
|
*
|
|
* Port-forwarding rules are optional because they are managed by a separate
|
|
* state hook (`usePortForwardingState`). Callers should strip transient
|
|
* runtime fields (status, error, lastUsedAt) before passing them in.
|
|
*/
|
|
export function buildSyncPayload(
|
|
vault: SyncableVaultData,
|
|
portForwardingRules?: PortForwardingRule[],
|
|
): SyncPayload {
|
|
return {
|
|
hosts: vault.hosts,
|
|
keys: vault.keys,
|
|
identities: vault.identities,
|
|
snippets: vault.snippets,
|
|
customGroups: vault.customGroups,
|
|
snippetPackages: vault.snippetPackages,
|
|
knownHosts: vault.knownHosts,
|
|
groupConfigs: vault.groupConfigs,
|
|
portForwardingRules,
|
|
settings: collectSyncableSettings(),
|
|
syncedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply a downloaded `SyncPayload` to local state via the provided importers.
|
|
*
|
|
* This ensures both vault data and port-forwarding rules are imported
|
|
* consistently across windows.
|
|
*/
|
|
export function applySyncPayload(
|
|
payload: SyncPayload,
|
|
importers: SyncPayloadImporters,
|
|
): void {
|
|
// Build the vault import object. knownHosts is only included when the
|
|
// payload explicitly carries the field (even if it's []). Legacy cloud
|
|
// snapshots may omit it entirely — in that case we leave the local
|
|
// known-hosts list untouched rather than destructively wiping it.
|
|
const vaultImport: Record<string, unknown> = {
|
|
hosts: payload.hosts,
|
|
keys: payload.keys,
|
|
identities: payload.identities,
|
|
snippets: payload.snippets,
|
|
customGroups: payload.customGroups,
|
|
};
|
|
if (payload.snippetPackages !== undefined) {
|
|
vaultImport.snippetPackages = payload.snippetPackages;
|
|
}
|
|
if (payload.knownHosts !== undefined) {
|
|
vaultImport.knownHosts = payload.knownHosts;
|
|
}
|
|
if (Array.isArray(payload.groupConfigs)) {
|
|
vaultImport.groupConfigs = payload.groupConfigs;
|
|
}
|
|
|
|
importers.importVaultData(JSON.stringify(vaultImport));
|
|
|
|
// Only import port-forwarding rules when the payload explicitly carries
|
|
// them. Absent field = "payload was created before this feature existed",
|
|
// so local rules are preserved. Explicitly present [] = "remote has no
|
|
// rules, clear local state".
|
|
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
|
importers.importPortForwardingRules(payload.portForwardingRules);
|
|
}
|
|
|
|
// Apply synced settings
|
|
if (payload.settings) {
|
|
applySyncableSettings(payload.settings);
|
|
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
|
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
|
importers.onSettingsApplied?.();
|
|
}
|
|
}
|