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

1262 lines
56 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,
ProxyProfile,
SftpBookmark,
Snippet,
SSHKey,
VaultNote,
} from '../domain/models';
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
retainLocalHostLastConnectedAt,
sanitizeHostsForSync,
type SyncPayload,
} from '../domain/sync';
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
import {
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
serializeCustomKeyBindingsStorageRecord,
} from '../domain/customKeyBindings';
import {
isEncryptedCredentialPlaceholder,
stripSyncPayloadEncryptedCredentials,
} from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import {
persistCommandBlocklistSetting,
readCommandBlocklistSetting,
} from './state/commandBlocklistSettings';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
nextTerminalFontSizeSyncVersion,
parseTerminalFontSizeRecord,
serializeTerminalFontSizeRecord,
} from './state/terminalFontSizeSync';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
} from './state/customAccentSync';
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_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
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_SKIP_UNCHANGED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
} from '../infrastructure/config/storageKeys';
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica';
// ---------------------------------------------------------------------------
// Input types
// ---------------------------------------------------------------------------
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
/** Vault-owned data. Some fields are local-only and excluded from cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
notes?: VaultNote[];
noteGroups?: string[];
/** Local trust records. Kept in local backups, excluded from cloud sync. */
knownHosts: KnownHost[];
groupConfigs?: GroupConfig[];
}
/**
* Returns true when the payload carries non-empty plugin sidecar entries.
*
* An explicit empty `{entries:[]}` bundle is intentionally NOT treated as
* meaningful by itself — last-known cache alone must not authorize pushing a
* wiped hosts/keys vault to cloud (empty-vault upload guard). Sidecar-only
* wipes still sync when the vault also has entities/settings, or via Force Push.
*/
function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
return Array.isArray(payload.pluginSidecars?.entries)
&& payload.pluginSidecars.entries.length > 0;
}
/**
* Returns true when a payload contains entities, settings, or meaningful
* plugin sidecars worth syncing / protecting with empty-vault guards.
* Local-only trust records are intentionally ignored by the cloud variant.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
if (
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
) {
return true;
}
return hasMeaningfulPluginSidecars(payload);
}
/**
* Returns true when a payload contains cloud-sync data.
* Local-only trust records are intentionally ignored.
*/
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
if (
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
) {
return true;
}
// Plugin-only profiles (settings/baselines, no vault entities) must still sync.
return hasMeaningfulPluginSidecars(payload);
}
/**
* Returns true only when the payload contains synced vault entities.
* Settings are intentionally ignored so default settings written on first
* launch do not make a new device look non-empty during cloud restore checks.
*/
export function hasCloudSyncEntityData(payload: SyncPayload): boolean {
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS);
}
export function shouldPromptCloudVaultRecovery(
localPayload: SyncPayload,
remotePayload: SyncPayload,
): boolean {
return !hasCloudSyncEntityData(localPayload) && hasCloudSyncEntityData(remotePayload);
}
export function sanitizePortForwardingRulesForSync(
rules: PortForwardingRule[] | undefined,
): PortForwardingRule[] | undefined {
if (!rules) return rules;
// Runtime phases are never part of vault/cloud sync. lastUsedAt is also
// stripped so runtime activity cannot churn sync hashes.
return toPersistedPortForwardingRules(rules).map((rule) => ({
...rule,
lastUsedAt: undefined,
}));
}
export { retainLocalHostLastConnectedAt, sanitizeHostsForSync };
export function getEffectivePortForwardingRulesForSync(
rules: PortForwardingRule[] | undefined,
): PortForwardingRule[] | undefined {
let effectiveRules = rules;
if (!effectiveRules || effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING);
if (Array.isArray(stored) && stored.length > 0) {
effectiveRules = stored;
}
}
return sanitizePortForwardingRulesForSync(effectiveRules);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
importVaultData: (jsonString: string) => void | Promise<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 = [
'startupCommandDelayMs',
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'highlightCursorLine', 'minimumContrastRatio',
'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText',
'kittyKeyboardProtocolEnabled',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
'autoCloseOnExit', 'disconnectedNoticeMode',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
'serverStatsRefreshInterval',
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
'rendererType',
// Inline image protocol switches are a preference and sync; the per-terminal
// memory limits stay local because they are tuned per device (like hibernate*).
'inlineImagesEnabled', 'inlineImageKittyEnabled', 'inlineImageSixelEnabled', 'inlineImageIipEnabled',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
'autocompleteHistoryScope',
] as const;
export const SYNCABLE_SETTING_STORAGE_KEYS = [
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_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_THEMES,
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_SKIP_UNCHANGED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const readArraySetting = <T = Record<string, unknown>>(key: string): T[] | null => {
const value = localStorageAdapter.read<T[]>(key);
return Array.isArray(value) ? value : null;
};
const readRecordSetting = <T extends Record<string, unknown> = Record<string, unknown>>(key: string): T | null => {
const value = localStorageAdapter.read<T>(key);
return isRecord(value) ? value as T : null;
};
const stripDeviceBoundApiKey = <T extends Record<string, unknown>>(value: T): T => {
if (!isEncryptedCredentialPlaceholder(value.apiKey as string | undefined)) return value;
const next = { ...value };
delete next.apiKey;
return next;
};
const getApiKeyLabel = (value: Record<string, unknown>): string => {
if (typeof value.name === 'string' && value.name.trim()) return value.name;
if (typeof value.id === 'string' && value.id.trim()) return value.id;
if (typeof value.providerId === 'string' && value.providerId.trim()) return value.providerId;
return 'configured provider';
};
const withPortableApiKey = async <T extends Record<string, unknown>>(value: T): Promise<T> => {
const apiKey = value.apiKey;
if (typeof apiKey !== 'string' || !isEncryptedCredentialPlaceholder(apiKey)) return value;
const decrypted = await decryptField(apiKey).catch(() => undefined);
if (!decrypted || decrypted === apiKey || isEncryptedCredentialPlaceholder(decrypted)) {
throw new Error(`Unable to decrypt AI API key for ${getApiKeyLabel(value)}. Sync was stopped to avoid removing the key from cloud sync.`);
}
return { ...value, apiKey: decrypted };
};
const withLocalEncryptedApiKey = async <T extends Record<string, unknown>>(value: T): Promise<T> => {
const apiKey = value.apiKey;
if (typeof apiKey !== 'string' || isEncryptedCredentialPlaceholder(apiKey)) return value;
const encrypted = await encryptField(apiKey).catch(() => undefined);
return { ...value, apiKey: encrypted ?? apiKey };
};
/**
* `collectSyncableSettings` strips device-bound encrypted apiKeys before upload,
* so an incoming providers array typically has no apiKey for providers that
* already exist locally. Re-attach the local apiKey by id; without this merge,
* applying any synced settings change would silently wipe credentials on the
* receiving device.
*/
const mergeAiProvidersPreservingLocalApiKeys = (
incoming: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> => {
const local = readArraySetting(STORAGE_KEY_AI_PROVIDERS) ?? [];
const localById = new Map<string, Record<string, unknown>>();
for (const provider of local) {
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
}
return incoming.map((provider) => {
if (provider.apiKey != null) return provider;
const id = typeof provider.id === 'string' ? provider.id : undefined;
const localProvider = id != null ? localById.get(id) : undefined;
if (localProvider && typeof localProvider.apiKey === 'string') {
return { ...provider, apiKey: localProvider.apiKey };
}
return provider;
});
};
/**
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
* local apiKey when the incoming config still points at the same providerId -
* switching providers must not silently leak a key meant for a different one.
*/
const mergeWebSearchConfigPreservingLocalApiKey = (
incoming: Record<string, unknown>,
): Record<string, unknown> => {
if (incoming.apiKey != null) return incoming;
const local = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (!local || typeof local.apiKey !== 'string') return incoming;
if (local.providerId !== incoming.providerId) return incoming;
return { ...incoming, apiKey: local.apiKey };
};
/**
* 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 accentRaw = localStorageAdapter.readString(STORAGE_KEY_COLOR);
if (accentRaw) settings.customAccent = parseCustomAccentRecord(accentRaw).color;
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;
const noteFontFamily = localStorageAdapter.readString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY);
if (noteFontFamily != null) settings.noteFontFamily = noteFontFamily;
const noteFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE);
if (noteFontSize != null && noteFontSize >= 10 && noteFontSize <= 32) settings.noteFontSize = noteFontSize;
const noteCodeFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE);
if (noteCodeFontSize != null && noteCodeFontSize >= 10 && noteCodeFontSize <= 32) settings.noteCodeFontSize = noteCodeFontSize;
// Terminal
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
if (termTheme) settings.terminalTheme = termTheme;
const followAppTermTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
settings.followAppTerminalTheme = followAppTermTheme === 'true';
}
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSizeRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
if (termSizeRaw != null && termSizeRaw !== '') {
settings.terminalFontSize = parseTerminalFontSizeRecord(termSizeRaw).fontSize;
}
const terminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
if (terminalSidePanelAutoOpen != null) settings.terminalSidePanelAutoOpen = terminalSidePanelAutoOpen;
const terminalSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
if (isTerminalSidePanelAutoOpenTab(terminalSidePanelAutoOpenTab)) {
settings.terminalSidePanelAutoOpenTab = terminalSidePanelAutoOpenTab;
}
// 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) {
const parsed = parseCustomKeyBindingsStorageRecord(kb);
if (parsed) settings.customKeyBindings = parsed.bindings;
}
// 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 skipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
if (skipUnchanged != null) settings.sftpSkipUnchanged = skipUnchanged;
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
if (followTerminalCwd === 'true' || followTerminalCwd === 'false') settings.sftpFollowTerminalCwd = followTerminalCwd === 'true';
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
// 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 hostClickBehavior = localStorageAdapter.readString(STORAGE_KEY_HOST_CLICK_BEHAVIOR);
if (hostClickBehavior === 'connect' || hostClickBehavior === 'select') {
settings.hostClickBehavior = hostClickBehavior;
}
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;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
const showTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
if (showTabNumberBadges != null) settings.showTabNumberBadges = showTabNumberBadges;
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
settings.workspaceFocusStyle = workspaceFocusStyle;
}
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
if (activeModelId != null) ai.activeModelId = activeModelId;
const permissionMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (permissionMode === 'observer' || permissionMode === 'confirm' || permissionMode === 'auto') {
ai.globalPermissionMode = permissionMode;
}
const toolIntegrationMode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
if (toolIntegrationMode === 'mcp' || toolIntegrationMode === 'skills') {
ai.toolIntegrationMode = toolIntegrationMode;
}
const hostPermissions = readArraySetting(STORAGE_KEY_AI_HOST_PERMISSIONS);
if (hostPermissions) ai.hostPermissions = hostPermissions;
// externalAgents intentionally not collected: command/args/env are device-local.
const defaultAgentId = localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT);
if (defaultAgentId != null) ai.defaultAgentId = defaultAgentId;
const storedCommandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (Array.isArray(storedCommandBlocklist)) {
ai.commandBlocklist = readCommandBlocklistSetting();
}
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
const responseIdleTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
if (responseIdleTimeout != null && Number.isFinite(responseIdleTimeout)) {
ai.responseIdleTimeout = responseIdleTimeout;
}
const maxIterations = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS);
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
if (agentModelMap) ai.agentModelMap = agentModelMap;
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
const agentThinkingMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP);
if (agentThinkingMap) ai.agentThinkingMap = agentThinkingMap;
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages) as unknown as Array<Record<string, unknown>>;
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
if (showTerminalSelectionAction != null) {
ai.showTerminalSelectionAction = showTerminalSelectionAction;
}
if (Object.keys(ai).length > 0) settings.ai = ai;
return Object.keys(settings).length > 0 ? settings : undefined;
}
export async function collectCloudSyncableSettings(): Promise<SyncPayload['settings']> {
const settings = collectSyncableSettings();
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (!providers && !webSearchConfig) return settings;
const nextSettings: SyncPayload['settings'] = settings ? { ...settings } : {};
const ai: NonNullable<SyncPayload['settings']>['ai'] = {
...(settings?.ai ?? {}),
};
if (providers) {
ai.providers = await Promise.all(providers.map(withPortableApiKey));
}
if (webSearchConfig) {
ai.webSearchConfig = await withPortableApiKey(webSearchConfig);
}
nextSettings.ai = ai;
return Object.keys(nextSettings).length > 0 ? nextSettings : undefined;
}
function collectLocalBackupSettings(): SyncPayload['settings'] {
const settings = collectSyncableSettings();
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (!providers && !webSearchConfig) return settings;
const nextSettings: SyncPayload['settings'] = settings ? { ...settings } : {};
const ai: NonNullable<SyncPayload['settings']>['ai'] = {
...(settings?.ai ?? {}),
};
if (providers) {
ai.providers = providers;
}
if (webSearchConfig) {
ai.webSearchConfig = webSearchConfig;
}
nextSettings.ai = ai;
return Object.keys(nextSettings).length > 0 ? nextSettings : undefined;
}
/**
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
async function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): Promise<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) {
const existing = parseCustomAccentRecord(localStorageAdapter.readString(STORAGE_KEY_COLOR));
localStorageAdapter.writeString(
STORAGE_KEY_COLOR,
serializeCustomAccentRecord({
color: parseCustomAccentRecord(settings.customAccent).color,
// Bump so peer windows' version gates accept the synced value.
version: Math.max(existing.version, 0) + 1,
}),
);
}
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);
if (settings.noteFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, settings.noteFontFamily);
if (settings.noteFontSize != null && settings.noteFontSize >= 10 && settings.noteFontSize <= 32) {
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE, settings.noteFontSize);
}
if (settings.noteCodeFontSize != null && settings.noteCodeFontSize >= 10 && settings.noteCodeFontSize <= 32) {
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, settings.noteCodeFontSize);
}
// Terminal
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
if (settings.followAppTerminalTheme != null) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
}
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) {
const existing = parseTerminalFontSizeRecord(
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
);
localStorageAdapter.writeString(
STORAGE_KEY_TERM_FONT_SIZE,
serializeTerminalFontSizeRecord({
fontSize: settings.terminalFontSize,
// Bump so peer windows' version gates accept the synced value.
version: nextTerminalFontSizeSyncVersion(existing.version, existing.version),
origin: 'sync-payload',
}),
);
}
if (settings.terminalSidePanelAutoOpen != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
}
if (isTerminalSidePanelAutoOpenTab(settings.terminalSidePanelAutoOpenTab)) {
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
}
// 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 };
const hasIncomingMiddleClickBehavior = 'middleClickBehavior' in settings.terminalSettings;
const hasIncomingMiddleClickPaste = 'middleClickPaste' in settings.terminalSettings;
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in settings.terminalSettings) {
merged[key] = settings.terminalSettings[key];
}
}
if (hasIncomingMiddleClickBehavior) {
const behavior = settings.terminalSettings.middleClickBehavior;
if (
behavior === 'context-menu' ||
behavior === 'paste' ||
behavior === 'disabled'
) {
merged.middleClickPaste = behavior === 'paste';
}
} else if (hasIncomingMiddleClickPaste) {
merged.middleClickBehavior = settings.terminalSettings.middleClickPaste === false
? 'disabled'
: 'paste';
}
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) {
const previous = parseCustomKeyBindingsStorageRecord(
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
);
localStorageAdapter.writeString(
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
serializeCustomKeyBindingsStorageRecord({
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
bindings: 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.sftpSkipUnchanged != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, settings.sftpSkipUnchanged);
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
if (settings.sftpDefaultViewMode != null) {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
}
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
if (settings.hostClickBehavior === 'connect' || settings.hostClickBehavior === 'select') {
localStorageAdapter.writeString(STORAGE_KEY_HOST_CLICK_BEHAVIOR, settings.hostClickBehavior);
}
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);
}
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.showTabNumberBadges != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, settings.showTabNumberBadges);
}
if (settings.disableTerminalFontZoom != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
}
if (settings.showHostTreeSidebar != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
}
if (settings.workspaceFocusStyle != null) {
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
}
const ai = settings.ai;
if (ai) {
if (ai.providers != null) {
const providers = await Promise.all(ai.providers.map(withLocalEncryptedApiKey));
localStorageAdapter.write(
STORAGE_KEY_AI_PROVIDERS,
mergeAiProvidersPreservingLocalApiKeys(providers),
);
}
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
if (ai.activeModelId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, ai.activeModelId);
if (ai.globalPermissionMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, ai.globalPermissionMode);
if (ai.toolIntegrationMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, ai.toolIntegrationMode);
if (ai.hostPermissions != null) localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, ai.hostPermissions);
// externalAgents intentionally not applied: device-local. Legacy snapshots
// that still carry an `externalAgents` field are silently ignored.
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
if (ai.commandBlocklist != null) {
persistCommandBlocklistSetting(migrateLegacyCommandBlocklist(ai.commandBlocklist));
}
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
if (ai.responseIdleTimeout != null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, ai.responseIdleTimeout);
}
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
if (ai.agentThinkingMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, ai.agentThinkingMap);
if (ai.webSearchConfig !== undefined) {
if (ai.webSearchConfig === null) {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
} else {
const webSearchConfig = await withLocalEncryptedApiKey(ai.webSearchConfig);
localStorageAdapter.write(
STORAGE_KEY_AI_WEB_SEARCH,
mergeWebSearchConfigPreservingLocalApiKey(webSearchConfig),
);
}
}
if (ai.quickMessages != null) {
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
}
if (ai.showTerminalSelectionAction != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
ai.showTerminalSelectionAction,
);
}
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
// points at ids the synced provider set doesn't include - either way
// we'd leak overrides bound to ghost providers. Mirrors the same
// cleanup `removeProvider` does for explicit user deletes.
pruneOrphanPerAgentBindings();
// Nudge same-window AI state listeners. localStorage writes only fire
// `storage` events in *other* windows; without this nudge the open
// chat panel keeps showing pre-sync providers/bindings until reload.
notifyAIStateAfterSync(ai);
}
}
function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']): void {
if (!ai) return;
// Every AI storage key that `applySyncableSettings` may have touched
// gets a same-window nudge. `useAIState` listens for these and refreshes
// the corresponding React state by re-reading localStorage.
const touched: Array<string> = [];
if (ai.providers != null) touched.push(STORAGE_KEY_AI_PROVIDERS);
if (ai.activeProviderId != null) touched.push(STORAGE_KEY_AI_ACTIVE_PROVIDER);
if (ai.activeModelId != null) touched.push(STORAGE_KEY_AI_ACTIVE_MODEL);
if (ai.globalPermissionMode != null) touched.push(STORAGE_KEY_AI_PERMISSION_MODE);
if (ai.toolIntegrationMode != null) touched.push(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
if (ai.hostPermissions != null) touched.push(STORAGE_KEY_AI_HOST_PERMISSIONS);
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (ai.responseIdleTimeout != null) touched.push(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
if (ai.agentThinkingMap != null) touched.push(STORAGE_KEY_AI_AGENT_THINKING_MAP);
// agentProviderMap is *always* potentially mutated because the reconcile
// step may have pruned it even if the payload didn't ship one.
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
// The reconcile may also have pruned saved models alongside provider
// bindings, so always nudge the model map too.
if (!touched.includes(STORAGE_KEY_AI_AGENT_MODEL_MAP)) {
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
}
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
if (ai.showTerminalSelectionAction != null) {
touched.push(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
}
for (const key of touched) {
emitAIStateChanged(key);
}
}
function pruneOrphanPerAgentBindings(): void {
const providers = localStorageAdapter.read<Array<{ id?: string }>>(STORAGE_KEY_AI_PROVIDERS) ?? [];
const validIds = new Set(
providers
.map((p) => p?.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0),
);
const providerMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
const modelMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
let providerChanged = false;
let modelChanged = false;
const nextProviderMap: Record<string, string> = {};
const nextModelMap: Record<string, string> = { ...modelMap };
for (const agentId of Object.keys(providerMap)) {
const providerId = providerMap[agentId];
if (providerId && validIds.has(providerId)) {
nextProviderMap[agentId] = providerId;
} else {
providerChanged = true;
// Drop the saved model too - that id belonged to the now-missing
// provider and isn't trustworthy against any other binding.
if (agentId in nextModelMap) {
delete nextModelMap[agentId];
modelChanged = true;
}
}
}
if (providerChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, nextProviderMap);
}
if (modelChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, nextModelMap);
}
}
// ---------------------------------------------------------------------------
// 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: sanitizeHostsForSync(vault.hosts),
keys: vault.keys,
identities: vault.identities,
proxyProfiles: vault.proxyProfiles,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
notes: vault.notes,
noteGroups: vault.noteGroups,
groupConfigs: vault.groupConfigs,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}
export type PluginSyncSidecarCollector = () =>
| Promise<SyncPayload['pluginSidecars'] | null | undefined>
| SyncPayload['pluginSidecars']
| null
| undefined;
export type PluginSyncSidecarApplier = (
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
) => Promise<void> | void;
/**
* Attach a host-collected plugin sidecar bundle to a cloud payload.
* Secrets must already be excluded by the collector; this only drops empty bundles.
*/
export function withPluginSyncSidecars(
payload: SyncPayload,
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
): SyncPayload {
// null/undefined: collector unavailable — leave payload field unchanged
// (callers should reattach last-known before upload).
if (sidecars == null) {
return payload;
}
// Explicit empty entries is a real reset and must remain on the wire so
// apply paths can distinguish it from a legacy payload missing the field.
return {
...payload,
pluginSidecars: {
version: 1,
entries: Array.isArray(sidecars.entries) ? sidecars.entries : [],
},
};
}
async function defaultCollectPluginSyncSidecars(): Promise<SyncPayload['pluginSidecars'] | null | undefined> {
const { collectPluginSyncSidecarsFromHost } = await import('./pluginSyncSidecarBridge');
// Let operational failures (DB/runtime) propagate so cloud upload does not
// silently strip previously synced sidecars from the remote snapshot.
return collectPluginSyncSidecarsFromHost();
}
async function defaultApplyPluginSyncSidecars(
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
): Promise<void> {
const {
applyPluginSyncSidecarsFromHost,
isPluginSidecarHostUnavailableError,
} = await import('./pluginSyncSidecarBridge');
try {
await applyPluginSyncSidecarsFromHost(sidecars);
} catch (error) {
// Only tolerate the host-gated-off / manager-unavailable path so a later
// plugin-enabled session can still apply from last-known cache. DB/runtime
// failures must fail the surrounding sync so the same remote is retried.
if (isPluginSidecarHostUnavailableError(error)) {
return;
}
throw error;
}
}
export async function buildCloudSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
options?: {
collectPluginSidecars?: PluginSyncSidecarCollector;
},
): Promise<SyncPayload> {
const base: SyncPayload = {
hosts: sanitizeHostsForSync(vault.hosts),
keys: vault.keys,
identities: vault.identities,
proxyProfiles: vault.proxyProfiles,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
notes: vault.notes,
noteGroups: vault.noteGroups,
groupConfigs: vault.groupConfigs,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
const collect = options?.collectPluginSidecars ?? defaultCollectPluginSyncSidecars;
const sidecars = await collect();
return withPluginSyncSidecars(base, sidecars);
}
/** Build a local backup/restore payload, including local-only trust records. */
export function buildLocalVaultPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
const base: SyncPayload = {
...buildSyncPayload(vault, portForwardingRules),
// Keep device-local recent-connection timestamps in protective backups.
hosts: vault.hosts,
settings: collectLocalBackupSettings(),
knownHosts: vault.knownHosts,
};
// Protective backups must capture plugin sidecars so restore can recover
// plugin settings/baselines. Prefer the last successful host collect cache
// (sync). Callers that can await should use buildLocalVaultPayloadAsync.
try {
const raw = localStorageAdapter.read<{ version?: number; entries?: unknown }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
if (raw && Array.isArray(raw.entries)) {
return withPluginSyncSidecars(base, {
version: 1,
entries: raw.entries as NonNullable<SyncPayload['pluginSidecars']>['entries'],
});
}
} catch {
// ignore cache read failures; backup still carries vault entities
}
return base;
}
/**
* Async local backup payload that prefers a live host collect when available,
* then falls back to last-known cache. Use for protective/version-change backups.
*/
export async function buildLocalVaultPayloadAsync(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): Promise<SyncPayload> {
const base = buildLocalVaultPayload(vault, portForwardingRules);
const {
collectPluginSyncSidecarsFromHost,
isPluginSidecarHostUnavailableError,
} = await import('./pluginSyncSidecarBridge');
try {
const live = await collectPluginSyncSidecarsFromHost();
if (live) {
// Uploads may still defer wiping last-known until a successful sync
// commit, but protective/version-change backups must snapshot the live
// authoritative-empty bundle so restore cannot resurrect cleared plugin data.
return withPluginSyncSidecars(base, live);
}
} catch (error) {
// Only fall back to last-known when the host is gated off. Operational
// failures must abort protective backups so we never apply over data that
// was not captured in the safety snapshot.
if (!isPluginSidecarHostUnavailableError(error)) {
throw error;
}
}
return base;
}
/**
* 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.
*/
function applyPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: {
includeLocalOnlyData: boolean;
applyPluginSidecars?: PluginSyncSidecarApplier;
currentHosts?: Host[];
},
): Promise<void> {
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
// so a previously poisoned cloud/backup snapshot can still restore host
// shells and let the user re-enter secrets (#2702).
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
let hosts = migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled);
if (!options.includeLocalOnlyData) {
hosts = retainLocalHostLastConnectedAt(hosts, options.currentHosts) ?? hosts;
}
// Build the vault import object. Cloud sync intentionally ignores
// local-only trust records even if legacy cloud snapshots still carry them.
const vaultImport: Record<string, unknown> = {
hosts,
keys: sanitizedPayload.keys,
identities: sanitizedPayload.identities,
proxyProfiles: sanitizedPayload.proxyProfiles,
snippets: sanitizedPayload.snippets,
customGroups: sanitizedPayload.customGroups,
};
if (sanitizedPayload.snippetPackages !== undefined) {
vaultImport.snippetPackages = sanitizedPayload.snippetPackages;
}
if (sanitizedPayload.notes !== undefined) {
vaultImport.notes = sanitizedPayload.notes;
}
if (sanitizedPayload.noteGroups !== undefined) {
vaultImport.noteGroups = sanitizedPayload.noteGroups;
}
if (options.includeLocalOnlyData && sanitizedPayload.knownHosts !== undefined) {
vaultImport.knownHosts = sanitizedPayload.knownHosts;
}
if (Array.isArray(sanitizedPayload.groupConfigs)) {
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
}
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
// 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 (sanitizedPayload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
}
// Apply synced settings
if (sanitizedPayload.settings) {
await applySyncableSettings(sanitizedPayload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
}
// Plugin encrypted sidecars: only apply when the field is present so
// legacy payloads without pluginSidecars do not wipe installed settings.
if (Object.prototype.hasOwnProperty.call(sanitizedPayload, 'pluginSidecars')) {
const applySidecars = options.applyPluginSidecars ?? defaultApplyPluginSyncSidecars;
await applySidecars(sanitizedPayload.pluginSidecars ?? { version: 1, entries: [] });
}
});
}
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
options?: {
applyPluginSidecars?: PluginSyncSidecarApplier;
currentHosts?: Host[];
},
): Promise<void> {
return applyPayload(payload, importers, {
includeLocalOnlyData: false,
applyPluginSidecars: options?.applyPluginSidecars,
currentHosts: options?.currentHosts,
});
}
export async function prepareLocalVaultPayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
dependencies: {
prepareConvergentRestore?: (
payload: SyncPayload,
) => Promise<() => Promise<void>>;
} = {},
): Promise<() => Promise<void>> {
// Sanitize once so vault import and convergent replica commit share the
// same portable secrets view (no device-bound enc:v1 leftovers).
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
const prepareConvergentRestore = dependencies.prepareConvergentRestore
?? prepareRestoredPayloadConvergentWrites;
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
return async () => {
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
await commitConvergentRestore();
};
}
export async function applyLocalVaultPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
dependencies: {
prepareConvergentRestore?: (
payload: SyncPayload,
) => Promise<() => Promise<void>>;
} = {},
): Promise<void> {
const applyPreparedPayload = await prepareLocalVaultPayloadApply(
payload,
importers,
dependencies,
);
await applyPreparedPayload();
}