Files
Netcatty/application/syncPayload.ts
T
陈大猫 34f9d2a663 chore: 死代码清理与架构分层修复 (#524)
* chore: 移除死代码并修复架构分层违规

- 删除未使用的 ACP 模块 (infrastructure/ai/acp/)
- 删除未使用的 AI 组件 (ExecutionPlan, PermissionDialog)
- 将 syncPayload.ts 从 domain 移至 application 层,修复分层违规
- 移除未使用的导出 (useSecurityState, useProviderStatus, GitHubAuthState,
  getAgentCommandLabel, ImageAttachment, HotkeyActions)
- 收窄 Electron bridge module.exports,移除未使用的导出函数
- 将仅内部使用的函数/类型取消导出 (isSupportedLocale, SyncDashboard)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 二次审查清理 — 移除更多死代码和架构违规

- 移除未使用的 ConversationEmptyState 组件和类型
- 移除未使用的 PromptInputSelect 系列组件 (5 个导出)
- 移除 global.d.ts 中残留的 SMBConfig 类型和 cloudSyncSmb* 方法声明
- 移除 useAutoSync.ts 中未使用的 toast 导入 (同时修复 application→components 反向依赖)
- 清理因删除而产生的多余 import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 消除直接 localStorage 访问,提取 safeSend 共享工具

localStorage 集中化:
- 新增 storageKeys 常量: SIDE_PANEL_WIDTH, PF_RECONNECT_CANCEL, DEBUG_HOTKEYS, DEBUG_UPDATE_DEMO
- TerminalLayer/SettingsApplicationTab/App.tsx/useUpdateCheck 改用 localStorageAdapter
- CloudSyncManager 内部方法改用 localStorageAdapter
- portForwardingService 改用 localStorageAdapter + 集中 key

safeSend 去重:
- 新增 electron/bridges/ipcUtils.cjs 共享模块
- sshBridge/sftpBridge/portForwardingBridge/sshAuthHelper/aiBridge 统一引用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 终审清理 — 移除未使用的 require 和废弃类型别名

- 移除 sftpBridge.cjs 中未使用的 require("node:net")
- 移除 aiBridge.cjs 中未使用的 require("node:path")
- 移除 types.ts 中已废弃的 ChatMessageImage 类型别名

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 修复 ESLint 错误 — 组件不再直接导入 infrastructure

- 新增 useStoredNumber hook,TerminalLayer 通过 hook 访问侧边栏宽度
- SettingsApplicationTab 的 isUpdateDemoMode 改为从 useUpdateCheck hook 传入
- 移除 useCloudSync.ts 中未使用的 CloudSyncManager 导入和 GitHubAuthState 接口

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 提取 notification port,消除 application 层对 components 的依赖

将 toast 通知抽象为 application/notification.ts 端口,
UI 层通过 setNotify 注入实现,useAutoSync 改用 notify 接口。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:14:37 +08:00

302 lines
13 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 {
Host,
Identity,
KnownHost,
PortForwardingRule,
Snippet,
SSHKey,
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
// Input types
// ---------------------------------------------------------------------------
/** All vault-owned data that participates in cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
knownHosts: KnownHost[];
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
/** Called after synced settings have been written to localStorage. */
onSettingsApplied?: () => void;
}
// ---------------------------------------------------------------------------
// Settings sync helpers
// ---------------------------------------------------------------------------
/** Terminal settings keys that are safe to sync (platform-agnostic). */
const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
] as const;
/**
* Collect all syncable settings from localStorage.
*/
export function collectSyncableSettings(): SyncPayload['settings'] {
const settings: SyncPayload['settings'] = {};
// Theme & Appearance
const theme = localStorageAdapter.readString(STORAGE_KEY_THEME);
if (theme === 'light' || theme === 'dark' || theme === 'system') settings.theme = theme;
const lightUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_LIGHT);
if (lightUi) settings.lightUiThemeId = lightUi;
const darkUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_DARK);
if (darkUi) settings.darkUiThemeId = darkUi;
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
if (accent) settings.customAccent = accent;
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
if (uiFont) settings.uiFontFamilyId = uiFont;
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
if (lang) settings.uiLanguage = lang;
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
if (css != null) settings.customCSS = css;
// Terminal
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
if (termTheme) settings.terminalTheme = termTheme;
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (termSize != null) settings.terminalFontSize = termSize;
// Terminal settings (syncable subset only)
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
if (termSettingsRaw) {
try {
const full = JSON.parse(termSettingsRaw);
const subset: Record<string, unknown> = {};
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in full) subset[key] = full[key];
}
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
} catch { /* ignore corrupt data */ }
}
// Custom terminal themes
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
if (customThemesRaw) {
try {
const parsed = JSON.parse(customThemesRaw);
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
} catch { /* ignore */ }
}
// Keyboard
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
if (kb) {
try {
settings.customKeyBindings = JSON.parse(kb);
} catch { /* ignore */ }
}
// Editor
const wordWrap = localStorageAdapter.readString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (wordWrap === 'true' || wordWrap === 'false') settings.editorWordWrap = wordWrap === 'true';
// SFTP
const dblClick = localStorageAdapter.readString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (dblClick === 'open' || dblClick === 'transfer') settings.sftpDoubleClickBehavior = dblClick;
const autoSync = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_SYNC);
if (autoSync === 'true' || autoSync === 'false') settings.sftpAutoSync = autoSync === 'true';
const hidden = localStorageAdapter.readString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
// Immersive mode
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
return Object.keys(settings).length > 0 ? settings : undefined;
}
/**
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
// Terminal
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
// Terminal settings — merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
let existing: Record<string, unknown> = {};
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
if (raw) {
try { existing = JSON.parse(raw); } catch { /* ignore */ }
}
const merged = { ...existing };
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in settings.terminalSettings) {
merged[key] = settings.terminalSettings[key];
}
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
// Custom terminal themes
if (settings.customTerminalThemes != null) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(settings.customTerminalThemes));
}
// Keyboard
if (settings.customKeyBindings != null) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
}
// Editor
if (settings.editorWordWrap != null) localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(settings.editorWordWrap));
// SFTP
if (settings.sftpDoubleClickBehavior != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, settings.sftpDoubleClickBehavior);
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
// Immersive mode
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
}
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
/**
* Build a complete `SyncPayload` from local data.
*
* Port-forwarding rules are optional because they are managed by a separate
* state hook (`usePortForwardingState`). Callers should strip transient
* runtime fields (status, error, lastUsedAt) before passing them in.
*/
export function buildSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
return {
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
portForwardingRules,
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}
/**
* Apply a downloaded `SyncPayload` to local state via the provided importers.
*
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
// Build the vault import object. knownHosts is only included when the
// payload explicitly carries the field (even if it's []). Legacy cloud
// snapshots may omit it entirely — in that case we leave the local
// known-hosts list untouched rather than destructively wiping it.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
};
if (payload.snippetPackages !== undefined) {
vaultImport.snippetPackages = payload.snippetPackages;
}
if (payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
importers.importVaultData(JSON.stringify(vaultImport));
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
importers.onSettingsApplied?.();
}
}