mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
Retain count-capped local chat history across primary and overflow chunks instead of silently evicting older chats at the primary JSON budget. Back up the full history before splitting it and recover from the merged store. Preserve main's last-write cache and synced writeString path during rebase. Remember the primary JSON actually written and bypass the cache whenever overflow exists so a matching primary cannot hide missing overflow data.
1523 lines
70 KiB
TypeScript
1523 lines
70 KiB
TypeScript
import { readMonitorPanels, applyMonitorPanels } from './state/monitorPanelsStorage';
|
|
import { normalizeMonitorPanels } from '../domain/monitorPanels';
|
|
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
|
|
/**
|
|
* 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,
|
|
reconcileInboundAutoCloseOnExit,
|
|
retainLocalHostLastConnectedAt,
|
|
sanitizeHostsForSync,
|
|
type SyncPayload,
|
|
} from '../domain/sync';
|
|
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
|
|
import { normalizeGlobalDefaultProxyProfileId } from '../domain/globalDefaultProxy';
|
|
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 {
|
|
latestAISessionsSnapshot,
|
|
readSessionsFromStorage,
|
|
setLatestAISessionsSnapshot,
|
|
writeSessionsForStorage,
|
|
cleanupSdkAgentSessions,
|
|
cleanupDeletedAIChatSessions,
|
|
} from './state/aiStateSnapshots';
|
|
import { getAISessionsSnapshot, publishAISessionsSnapshot } from './state/aiSessionsStore';
|
|
import {
|
|
persistCommandBlocklistSetting,
|
|
readCommandBlocklistSetting,
|
|
} from './state/commandBlocklistSettings';
|
|
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
|
import { applyHostBrowserBookmarks, collectHostBrowserBookmarks, getCachedHostBrowserBookmarks } from './hostBrowserBookmarks';
|
|
import {
|
|
clampTerminalFontSizeValue,
|
|
nextTerminalFontSizeSyncVersion,
|
|
parseTerminalFontSizeRecord,
|
|
serializeTerminalFontSizeRecord,
|
|
} from './state/terminalFontSizeSync';
|
|
import {
|
|
parseCustomAccentRecord,
|
|
serializeCustomAccentRecord,
|
|
} from './state/customAccentSync';
|
|
import { isValidHslToken } from './state/settingsStateDefaults';
|
|
import {
|
|
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
|
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
|
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_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
|
STORAGE_KEY_LOCAL_SHELL_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_GLOBAL_DEFAULT_PROXY_PROFILE,
|
|
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,
|
|
STORAGE_KEY_AI_SESSIONS,
|
|
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
|
} from '../infrastructure/config/storageKeys';
|
|
import { mergeAiSessionLists, mergeSyncPayloads, stripAiSessionDeviceState, stripAiSessionPayloadForSync } from '../domain/syncMerge';
|
|
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 (Array.isArray(payload.aiSessions) && payload.aiSessions.length > 0) 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, chat history, and browser bookmarks are ignored: none proves that the vault's
|
|
* hosts and keys survived local data loss.
|
|
*/
|
|
export function hasCloudSyncEntityData(payload: SyncPayload): boolean {
|
|
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions' && key !== 'browserBookmarks'));
|
|
}
|
|
|
|
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', 'cursorBarWidth', 'highlightCursorLine', 'minimumContrastRatio',
|
|
'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText',
|
|
'shiftEnterForceText',
|
|
'kittyKeyboardProtocolEnabled',
|
|
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
|
'smoothScrolling',
|
|
'rightClickBehavior', 'rightClickLongPressMenu', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
|
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
|
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
|
'autoUploadClipboardImageOnPaste',
|
|
'confirmBeforeMultilinePaste', 'multilinePasteConfirmMinLines',
|
|
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode', 'tabDoubleClickBehavior',
|
|
'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_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
|
STORAGE_KEY_LOCAL_SHELL_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_GLOBAL_DEFAULT_PROXY_PROFILE,
|
|
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) => {
|
|
const id = typeof provider.id === 'string' ? provider.id : undefined;
|
|
const localProvider = id != null ? localById.get(id) : undefined;
|
|
return {
|
|
...provider,
|
|
...(provider.apiKey == null && typeof localProvider?.apiKey === 'string' ? { apiKey: localProvider.apiKey } : {}),
|
|
...(provider.customHeaders == null && localProvider?.customHeaders != null ? { customHeaders: localProvider.customHeaders } : {}),
|
|
};
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
const localShellSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN);
|
|
if (localShellSidePanelAutoOpen != null) settings.localShellSidePanelAutoOpen = localShellSidePanelAutoOpen;
|
|
const localShellSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB);
|
|
if (isTerminalSidePanelAutoOpenTab(localShellSidePanelAutoOpenTab)) {
|
|
settings.localShellSidePanelAutoOpenTab = localShellSidePanelAutoOpenTab;
|
|
}
|
|
|
|
// 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 */ }
|
|
}
|
|
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
|
|
settings.autoCloseOnExitDefaultMigrated = true;
|
|
}
|
|
|
|
// 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 storedDefaultProxyProfileId = localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE);
|
|
if (storedDefaultProxyProfileId !== null) {
|
|
settings.defaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(storedDefaultProxyProfileId) ?? '';
|
|
}
|
|
|
|
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
|
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
|
if (providers) ai.providers = providers.map((provider) => {
|
|
const next = { ...stripDeviceBoundApiKey(provider) };
|
|
delete next.customHeaders;
|
|
return next;
|
|
});
|
|
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(async (provider) => {
|
|
const next = await withPortableApiKey(provider);
|
|
return isRecord(provider.customHeaders)
|
|
? { ...next, customHeaders: await decryptProviderHeaders(provider.customHeaders as Record<string, string>) }
|
|
: next;
|
|
}));
|
|
}
|
|
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']>,
|
|
preparedProviders: Record<string, unknown>[] | undefined,
|
|
): 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 storedColor = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
|
const existing = parseCustomAccentRecord(storedColor);
|
|
const incomingColor = parseCustomAccentRecord(settings.customAccent).color;
|
|
// A missing or malformed record parses to the fallback color, but still
|
|
// needs to be replaced by an explicit incoming value.
|
|
const storedColorIsValid = storedColor !== null && (
|
|
isValidHslToken(storedColor.trim())
|
|
|| storedColor === serializeCustomAccentRecord(existing)
|
|
);
|
|
if (!storedColorIsValid || incomingColor !== existing.color) {
|
|
localStorageAdapter.writeString(
|
|
STORAGE_KEY_COLOR,
|
|
serializeCustomAccentRecord({
|
|
color: incomingColor,
|
|
// 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 storedFontSize = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
|
|
const existing = parseTerminalFontSizeRecord(storedFontSize);
|
|
const incomingFontSize = clampTerminalFontSizeValue(settings.terminalFontSize);
|
|
// A missing or malformed record parses to the default font size. A
|
|
// supported legacy number remains valid without forcing migration.
|
|
const storedFontSizeIsValid = storedFontSize !== null && (
|
|
storedFontSize.trim() === String(existing.fontSize)
|
|
|| storedFontSize === serializeTerminalFontSizeRecord(existing)
|
|
);
|
|
if (!storedFontSizeIsValid || incomingFontSize !== existing.fontSize) {
|
|
localStorageAdapter.writeString(
|
|
STORAGE_KEY_TERM_FONT_SIZE,
|
|
serializeTerminalFontSizeRecord({
|
|
fontSize: incomingFontSize,
|
|
// 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);
|
|
}
|
|
if (settings.localShellSidePanelAutoOpen != null) {
|
|
localStorageAdapter.writeBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, settings.localShellSidePanelAutoOpen);
|
|
}
|
|
if (isTerminalSidePanelAutoOpenTab(settings.localShellSidePanelAutoOpenTab)) {
|
|
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, settings.localShellSidePanelAutoOpenTab);
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
if ('autoCloseOnExit' in settings.terminalSettings) {
|
|
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
|
|
settings.terminalSettings.autoCloseOnExit,
|
|
settings.autoCloseOnExitDefaultMigrated,
|
|
);
|
|
}
|
|
// A legacy true is the old default. A migrated peer's explicit choice must
|
|
// stick, so record the marker before the next local migration pass.
|
|
if (
|
|
settings.autoCloseOnExitDefaultMigrated === true
|
|
|| (
|
|
settings.terminalSettings.autoCloseOnExit === true
|
|
&& settings.autoCloseOnExitDefaultMigrated !== true
|
|
)
|
|
) {
|
|
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
|
}
|
|
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 storedBindings = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
|
const previous = parseCustomKeyBindingsStorageRecord(storedBindings);
|
|
// Keep an explicit empty binding set when this device has no record.
|
|
const incomingBindings = JSON.stringify(settings.customKeyBindings);
|
|
const existingBindings = JSON.stringify(previous?.bindings ?? []);
|
|
if (previous === null || incomingBindings !== existingBindings) {
|
|
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);
|
|
}
|
|
if (typeof settings.defaultProxyProfileId === 'string') {
|
|
localStorageAdapter.writeString(
|
|
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
|
normalizeGlobalDefaultProxyProfileId(settings.defaultProxyProfileId) ?? '',
|
|
);
|
|
}
|
|
|
|
const ai = settings.ai;
|
|
if (ai) {
|
|
if (preparedProviders != null) {
|
|
localStorageAdapter.write(
|
|
STORAGE_KEY_AI_PROVIDERS,
|
|
mergeAiProvidersPreservingLocalApiKeys(preparedProviders),
|
|
);
|
|
}
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* AI chat sessions only leave the device when the user opted in.
|
|
* Attachments are stripped — see {@link stripAiSessionPayloadForSync}.
|
|
*/
|
|
export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
|
|
const enabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
|
|
if (enabled?.enabled !== true) return undefined;
|
|
const sessions = collectLocalAiSessions();
|
|
if (!writeSessionsForStorage(sessions)) {
|
|
throw new Error('Unable to persist chat history for cloud sync');
|
|
}
|
|
return stripAiSessionPayloadForSync(sessions);
|
|
}
|
|
|
|
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
|
|
return latestAISessionsSnapshot
|
|
?? readSessionsFromStorage();
|
|
}
|
|
|
|
/** Build the portable payload; device-local data is added by the backup builder. */
|
|
export function buildSyncPayload(
|
|
vault: SyncableVaultData,
|
|
portForwardingRules?: PortForwardingRule[],
|
|
): SyncPayload {
|
|
const aiSessions = collectAiSessionsForSync();
|
|
return {
|
|
...buildVaultSyncPayload(vault, portForwardingRules),
|
|
...(aiSessions !== undefined ? { aiSessions } : {}),
|
|
};
|
|
}
|
|
|
|
function buildVaultSyncPayload(
|
|
vault: SyncableVaultData,
|
|
portForwardingRules?: PortForwardingRule[],
|
|
): SyncPayload {
|
|
const browserBookmarks = getCachedHostBrowserBookmarks();
|
|
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,
|
|
monitorPanels: readMonitorPanels(),
|
|
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
|
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
|
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 aiSessions = collectAiSessionsForSync();
|
|
const browserBookmarks = await collectHostBrowserBookmarks();
|
|
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,
|
|
monitorPanels: readMonitorPanels(),
|
|
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
|
...(aiSessions !== undefined ? { aiSessions } : {}),
|
|
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
|
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 = {
|
|
...buildVaultSyncPayload(vault, portForwardingRules),
|
|
// Keep device-local recent-connection timestamps in protective backups.
|
|
hosts: vault.hosts,
|
|
settings: collectLocalBackupSettings(),
|
|
knownHosts: vault.knownHosts,
|
|
// Cloud consent and cloud attachment stripping do not apply to local backups.
|
|
aiSessions: collectLocalAiSessions(),
|
|
};
|
|
// 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 browserBookmarks = await collectHostBrowserBookmarks();
|
|
if (browserBookmarks !== undefined) base.browserBookmarks = browserBookmarks;
|
|
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;
|
|
}
|
|
|
|
/** Merge downloaded chat data through the existing state and persistence boundary. */
|
|
async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions']>, includeLocalOnlyData: boolean): Promise<void> {
|
|
const { isAIChatSessionStreaming, stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
|
|
const preserveActive = (id: string) => !includeLocalOnlyData && isAIChatSessionStreaming(id);
|
|
const incoming = includeLocalOnlyData
|
|
? sessions
|
|
: sessions.map(stripAiSessionDeviceState);
|
|
const incomingIds = new Set(incoming.map((session) => session.id));
|
|
const incomingById = new Map(incoming.map((session) => [session.id, session]));
|
|
const portableMessages = (value: typeof incoming[number]) => stripAiSessionPayloadForSync([value])[0].messages;
|
|
const affectedIds = collectLocalAiSessions().filter((local) => {
|
|
if (preserveActive(local.id)) return false;
|
|
const next = incomingById.get(local.id);
|
|
if (!next) return true;
|
|
const candidate = includeLocalOnlyData ? next : mergeAiSessionLists([local], [next])[0];
|
|
return JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(candidate));
|
|
}).map((session) => session.id);
|
|
if (affectedIds.length > 0) {
|
|
await Promise.all(affectedIds.map(stopAIChatSessionBeforeReplacement));
|
|
}
|
|
// Stop finalizers can finish the last local message. Read it again before
|
|
// merging so the stopped turn cannot write into a replaced/deleted session.
|
|
const localSessions = collectLocalAiSessions();
|
|
const localById = new Map(localSessions.map((session) => [session.id, session]));
|
|
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id) && !preserveActive(session.id)).map((session) => session.id);
|
|
const resetSdkSessions: string[] = [];
|
|
// The sync merge has already decided which session IDs survive. Backups
|
|
// restore their exact transcript; cloud survivors still retain local turns.
|
|
const candidates = includeLocalOnlyData ? incoming
|
|
: mergeAiSessionLists(localSessions.filter((session) => incomingIds.has(session.id)), incoming);
|
|
// Keep active cloud sessions intact until a later sync, including remote deletions.
|
|
const retainedActive = localSessions.filter((session) => preserveActive(session.id));
|
|
const mergedSessions = [...candidates.filter((session) => !preserveActive(session.id)), ...retainedActive].map((session) => {
|
|
if (preserveActive(session.id)) return session;
|
|
// Native SDK threads belong to this device. Reuse the local thread
|
|
// only while it already contains the entire merged transcript.
|
|
const local = localById.get(session.id);
|
|
const transcriptChanged = local
|
|
&& JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(session));
|
|
if (transcriptChanged) resetSdkSessions.push(session.id);
|
|
const { externalSessionId: _remoteThread, ...portable } = session;
|
|
return local?.externalSessionId && !transcriptChanged
|
|
? { ...portable, externalSessionId: local.externalSessionId }
|
|
: portable;
|
|
});
|
|
if (!writeSessionsForStorage(mergedSessions)) {
|
|
throw new Error('Unable to save downloaded AI chat history');
|
|
}
|
|
cleanupDeletedAIChatSessions(removedIds);
|
|
await cleanupSdkAgentSessions(resetSdkSessions);
|
|
setLatestAISessionsSnapshot(mergedSessions);
|
|
publishAISessionsSnapshot({ ...getAISessionsSnapshot(), sessions: mergedSessions });
|
|
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
|
}
|
|
|
|
/** Read existing cloud chats before an enabled empty local list can overwrite them. */
|
|
export async function setAiSessionsSyncPreference(
|
|
enabled: boolean,
|
|
loadRemotePayloads: () => Promise<SyncPayload[]>,
|
|
preserveDeletions = false,
|
|
): Promise<void> {
|
|
const wasEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
|
if (enabled && !wasEnabled) {
|
|
const remotes = await loadRemotePayloads();
|
|
// Reuse the ordinary no-base deletion rules, including cloud tombstones,
|
|
// while retaining local-only chats when this is a first-time opt-in.
|
|
const local: SyncPayload = { hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
aiSessions: collectLocalAiSessions(), syncedAt: 0 };
|
|
const merged = remotes.reduce((current, remote) =>
|
|
mergeSyncPayloads(null, current, remote, { preserveDeletions }).payload, local);
|
|
const previous = localStorageAdapter.readString(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
|
|
const { withRestoreBarrier } = await import('./localVaultBackups');
|
|
await withRestoreBarrier(async () => {
|
|
if ((localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL) ?? 0) <= Date.now()) {
|
|
throw new Error('Unable to pause sync while enabling chat history');
|
|
}
|
|
if (!localStorageAdapter.write(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, { enabled })) {
|
|
throw new Error('Unable to save the chat sync preference');
|
|
}
|
|
try {
|
|
await applyAiSessionData(merged.aiSessions ?? [], false);
|
|
} catch (error) {
|
|
if (previous === null) localStorageAdapter.remove(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
|
|
else localStorageAdapter.writeString(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, previous);
|
|
throw error;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (!localStorageAdapter.write(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, { enabled })) {
|
|
throw new Error('Unable to save the chat sync preference');
|
|
}
|
|
}
|
|
|
|
/** Apply downloaded vault data and supported settings through their state boundaries. */
|
|
async function preparePayloadApply(
|
|
payload: SyncPayload,
|
|
importers: SyncPayloadImporters,
|
|
options: {
|
|
includeLocalOnlyData: boolean;
|
|
applyPluginSidecars?: PluginSyncSidecarApplier;
|
|
currentHosts?: Host[];
|
|
},
|
|
): Promise<() => 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 monitorPanelsAtPrepare = readMonitorPanels();
|
|
// Prepare credentials before any vault or settings mutation. Encryption can
|
|
// fail when local credential storage is unavailable; reuse the result below.
|
|
const providers = sanitizedPayload.settings?.ai?.providers;
|
|
const preparedProviders = providers == null ? undefined : await Promise.all(providers.map(async (provider) => {
|
|
const next = await withLocalEncryptedApiKey(provider);
|
|
return isRecord(provider.customHeaders)
|
|
? { ...next, customHeaders: await encryptProviderHeaders(await decryptProviderHeaders(provider.customHeaders as Record<string, string>)) }
|
|
: next;
|
|
}));
|
|
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 () => {
|
|
if (sanitizedPayload.monitorPanels !== undefined) await applyMonitorPanels(normalizeMonitorPanels(sanitizedPayload.monitorPanels), monitorPanelsAtPrepare, options.includeLocalOnlyData);
|
|
// 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);
|
|
}
|
|
if (sanitizedPayload.browserBookmarks !== undefined) {
|
|
await applyHostBrowserBookmarks(sanitizedPayload.browserBookmarks);
|
|
}
|
|
|
|
// AI chat sessions merge append-only: a cloud snapshot may be missing
|
|
// turns that only exist on this device, so union instead of replace.
|
|
const applyAiSessions = options.includeLocalOnlyData
|
|
|| localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
|
if (applyAiSessions && Array.isArray(sanitizedPayload.aiSessions)) {
|
|
await applyAiSessionData(sanitizedPayload.aiSessions, options.includeLocalOnlyData);
|
|
}
|
|
|
|
// Apply synced settings
|
|
if (sanitizedPayload.settings) {
|
|
await applySyncableSettings(sanitizedPayload.settings, preparedProviders);
|
|
// 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 prepareSyncPayloadApply(
|
|
payload: SyncPayload,
|
|
importers: SyncPayloadImporters,
|
|
options?: {
|
|
applyPluginSidecars?: PluginSyncSidecarApplier;
|
|
currentHosts?: Host[];
|
|
},
|
|
): Promise<() => Promise<void>> {
|
|
return preparePayloadApply(payload, importers, {
|
|
includeLocalOnlyData: false,
|
|
applyPluginSidecars: options?.applyPluginSidecars,
|
|
currentHosts: options?.currentHosts,
|
|
});
|
|
}
|
|
|
|
export async function applySyncPayload(
|
|
...args: Parameters<typeof prepareSyncPayloadApply>
|
|
): Promise<void> {
|
|
const applyPreparedPayload = await prepareSyncPayloadApply(...args);
|
|
await applyPreparedPayload();
|
|
}
|
|
|
|
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 applyPreparedPayload = await preparePayloadApply(sanitizedPayload, importers, { includeLocalOnlyData: true });
|
|
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
|
|
return async () => {
|
|
await applyPreparedPayload();
|
|
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();
|
|
}
|