* Add configurable middle-click terminal behavior * Fix middle-click terminal behavior edge cases
820 lines
37 KiB
TypeScript
820 lines
37 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,
|
|
} from '../domain/models';
|
|
import {
|
|
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
|
SYNC_PAYLOAD_ENTITY_KEYS,
|
|
hasSyncPayloadEntityData,
|
|
type SyncPayload,
|
|
} from '../domain/sync';
|
|
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
|
import {
|
|
nextCustomKeyBindingsSyncVersion,
|
|
parseCustomKeyBindingsStorageRecord,
|
|
serializeCustomKeyBindingsStorageRecord,
|
|
} from '../domain/customKeyBindings';
|
|
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
|
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
|
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
|
import { emitAIStateChanged } from './state/aiStateEvents';
|
|
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
|
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_CUSTOM_KEY_BINDINGS,
|
|
STORAGE_KEY_EDITOR_WORD_WRAP,
|
|
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
|
STORAGE_KEY_SFTP_AUTO_SYNC,
|
|
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
|
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
|
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
|
STORAGE_KEY_SFTP_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_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_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_MAX_ITERATIONS,
|
|
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
|
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
|
STORAGE_KEY_AI_WEB_SEARCH,
|
|
STORAGE_KEY_AI_QUICK_MESSAGES,
|
|
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
|
STORAGE_KEY_PORT_FORWARDING,
|
|
} from '../infrastructure/config/storageKeys';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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[];
|
|
/** Local trust records. Kept in local backups, excluded from cloud sync. */
|
|
knownHosts: KnownHost[];
|
|
groupConfigs?: GroupConfig[];
|
|
}
|
|
|
|
/**
|
|
* Returns true when the payload contains any meaningful user data worth
|
|
* protecting or syncing.
|
|
*/
|
|
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
|
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
|
|
|
return Boolean(
|
|
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
return Boolean(
|
|
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
return rules.map((rule) => ({
|
|
...rule,
|
|
status: 'inactive' as const,
|
|
error: undefined,
|
|
lastUsedAt: undefined,
|
|
}));
|
|
}
|
|
|
|
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', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
|
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
|
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
|
'smoothScrolling',
|
|
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
|
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
|
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
|
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
|
'serverStatsRefreshInterval',
|
|
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
|
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
|
|
'rendererType',
|
|
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
|
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
|
] 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_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_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_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_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
|
STORAGE_KEY_SHOW_SFTP_TAB,
|
|
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
|
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_MAX_ITERATIONS,
|
|
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
|
STORAGE_KEY_AI_AGENT_PROVIDER_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;
|
|
};
|
|
|
|
/**
|
|
* `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 accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
|
if (accent) settings.customAccent = accent;
|
|
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
|
if (uiFont) settings.uiFontFamilyId = uiFont;
|
|
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
|
if (lang) settings.uiLanguage = lang;
|
|
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
|
if (css != null) settings.customCSS = css;
|
|
|
|
// Terminal
|
|
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
|
if (termTheme) settings.terminalTheme = termTheme;
|
|
const 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 termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
|
if (termSize != null) settings.terminalFontSize = termSize;
|
|
|
|
// Terminal settings (syncable subset only)
|
|
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
|
if (termSettingsRaw) {
|
|
try {
|
|
const full = JSON.parse(termSettingsRaw);
|
|
const subset: Record<string, unknown> = {};
|
|
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
|
if (key in full) subset[key] = full[key];
|
|
}
|
|
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
|
} catch { /* ignore corrupt data */ }
|
|
}
|
|
|
|
// Custom terminal themes
|
|
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
|
if (customThemesRaw) {
|
|
try {
|
|
const parsed = JSON.parse(customThemesRaw);
|
|
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Keyboard
|
|
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
|
if (kb) {
|
|
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 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 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 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 === 'autonomous') {
|
|
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 commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
|
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
|
|
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
|
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
|
|
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 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);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Apply synced settings to localStorage. Merges terminal settings
|
|
* to preserve platform-specific fields.
|
|
*/
|
|
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
|
// Theme & Appearance
|
|
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
|
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
|
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
|
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
|
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
|
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
|
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
|
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
|
|
|
// Terminal
|
|
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
|
if (settings.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) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
|
|
|
// Terminal settings — merge with existing to preserve platform-specific keys
|
|
if (settings.terminalSettings) {
|
|
let existing: Record<string, unknown> = {};
|
|
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
|
if (raw) {
|
|
try { existing = JSON.parse(raw); } catch { /* ignore */ }
|
|
}
|
|
const merged = { ...existing };
|
|
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.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.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.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) {
|
|
localStorageAdapter.write(
|
|
STORAGE_KEY_AI_PROVIDERS,
|
|
mergeAiProvidersPreservingLocalApiKeys(ai.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) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
|
|
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
|
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.webSearchConfig !== undefined) {
|
|
if (ai.webSearchConfig === null) {
|
|
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
|
} else {
|
|
localStorageAdapter.write(
|
|
STORAGE_KEY_AI_WEB_SEARCH,
|
|
mergeWebSearchConfigPreservingLocalApiKey(ai.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.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
|
|
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_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: vault.hosts,
|
|
keys: vault.keys,
|
|
identities: vault.identities,
|
|
proxyProfiles: vault.proxyProfiles,
|
|
snippets: vault.snippets,
|
|
customGroups: vault.customGroups,
|
|
snippetPackages: vault.snippetPackages,
|
|
groupConfigs: vault.groupConfigs,
|
|
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
|
settings: collectSyncableSettings(),
|
|
syncedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
/** Build a local backup/restore payload, including local-only trust records. */
|
|
export function buildLocalVaultPayload(
|
|
vault: SyncableVaultData,
|
|
portForwardingRules?: PortForwardingRule[],
|
|
): SyncPayload {
|
|
return {
|
|
...buildSyncPayload(vault, portForwardingRules),
|
|
knownHosts: vault.knownHosts,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 },
|
|
): Promise<void> {
|
|
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
|
|
// 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: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
|
|
keys: payload.keys,
|
|
identities: payload.identities,
|
|
proxyProfiles: payload.proxyProfiles,
|
|
snippets: payload.snippets,
|
|
customGroups: payload.customGroups,
|
|
};
|
|
if (payload.snippetPackages !== undefined) {
|
|
vaultImport.snippetPackages = payload.snippetPackages;
|
|
}
|
|
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
|
vaultImport.knownHosts = payload.knownHosts;
|
|
}
|
|
if (Array.isArray(payload.groupConfigs)) {
|
|
vaultImport.groupConfigs = payload.groupConfigs;
|
|
}
|
|
|
|
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
|
|
// Only import port-forwarding rules when the payload explicitly carries
|
|
// them. Absent field = "payload was created before this feature existed",
|
|
// so local rules are preserved. Explicitly present [] = "remote has no
|
|
// rules, clear local state".
|
|
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
|
importers.importPortForwardingRules(payload.portForwardingRules);
|
|
}
|
|
|
|
// Apply synced settings
|
|
if (payload.settings) {
|
|
applySyncableSettings(payload.settings);
|
|
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
|
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
|
importers.onSettingsApplied?.();
|
|
}
|
|
});
|
|
}
|
|
|
|
export function applySyncPayload(
|
|
payload: SyncPayload,
|
|
importers: SyncPayloadImporters,
|
|
): Promise<void> {
|
|
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
|
}
|
|
|
|
export function applyLocalVaultPayload(
|
|
payload: SyncPayload,
|
|
importers: SyncPayloadImporters,
|
|
): Promise<void> {
|
|
return applyPayload(payload, importers, { includeLocalOnlyData: true });
|
|
}
|