Files
Netcatty/domain/sync.ts
陈大猫 4459aa4ef3 Add terminal zoom disable setting
Adds a Shortcuts setting to disable terminal zoom shortcuts.
2026-06-12 14:33:17 +08:00

662 lines
20 KiB
TypeScript

/**
* Cloud Sync Domain Types & Interfaces
*
* Zero-Knowledge Encrypted Multi-Cloud Sync System
* Supports: GitHub Gist, Google Drive, Microsoft OneDrive, WebDAV, S3 Compatible
*/
import type { ShrinkFinding } from './syncGuards';
// ============================================================================
// Security State Machine
// ============================================================================
/**
* Global Security State Machine
* Controls access to sync operations based on master key status
*/
export type SecurityState =
| 'NO_KEY' // User has not set up a master key - block all sync
| 'LOCKED' // Master key exists but not in memory - show unlock screen
| 'UNLOCKED'; // Master key in memory - sync operations allowed
/**
* Sync Operation State Machine
* Tracks the current sync operation status
*/
export type SyncState =
| 'IDLE' // Waiting for sync trigger
| 'SYNCING' // Active sync operation in progress
| 'CONFLICT' // Version conflict detected - needs resolution
| 'BLOCKED' // Outgoing payload would delete too much — user must choose restore or force-push
| 'ERROR'; // Operation failed - needs attention
/**
* Conflict Resolution Strategy
*/
export type ConflictResolution =
| 'USE_REMOTE' // Download cloud data, overwrite local
| 'USE_LOCAL' // Upload local data, overwrite cloud
| 'AUTO_MERGED'; // Three-way merge was applied automatically
// ============================================================================
// Cloud Provider Types
// ============================================================================
/**
* Supported cloud storage providers
*/
export type CloudProvider = 'github' | 'google' | 'onedrive' | 'webdav' | 's3';
export type WebDAVAuthType = 'basic' | 'digest' | 'token';
export interface WebDAVConfig {
endpoint: string;
authType: WebDAVAuthType;
username?: string;
password?: string;
token?: string;
allowInsecure?: boolean;
}
export interface S3Config {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
prefix?: string;
forcePathStyle?: boolean;
}
/**
* Provider-specific connection status
*/
type ProviderConnectionStatus =
| 'disconnected'
| 'connecting'
| 'connected'
| 'syncing'
| 'error';
/**
* OAuth token storage structure
*/
export interface OAuthTokens {
accessToken: string;
refreshToken?: string;
expiresAt?: number; // Unix timestamp
tokenType: string;
scope?: string;
}
/**
* Marker prefixed onto OneDrive refresh errors when Microsoft reports the
* refresh token can no longer be used (expired / revoked / consent withdrawn).
* Only an error's `message` survives the Electron IPC boundary, so the marker is
* the stable signal that the OneDrive session must be re-authorized. It is added
* in the bridge (electron/bridges/onedriveAuthBridge.cjs) and detected/cleaned
* here so the same logic is shared by infrastructure and UI layers.
*/
export const ONEDRIVE_REAUTH_REQUIRED_MARKER = 'ONEDRIVE_REAUTH_REQUIRED';
/**
* True when an error indicates the OneDrive refresh token is dead and the user
* must reconnect. Robust to the error being re-wrapped (e.g. `new
* Error(String(err))`) as it bubbles through the provider-agnostic pipeline.
*/
export const isOneDriveReauthRequiredMessage = (message: string): boolean =>
message.includes(ONEDRIVE_REAUTH_REQUIRED_MARKER);
/**
* Produce a clean, user-facing message from a (possibly multiply-wrapped) error
* string by dropping everything up to and including the internal reauth marker,
* e.g. "Error: OneDriveReauthRequiredError: ONEDRIVE_REAUTH_REQUIRED: OneDrive
* session expired..." -> "OneDrive session expired...". Returns the original
* string unchanged when the marker is absent.
*/
export const cleanOneDriveErrorMessage = (message: string): string => {
const token = `${ONEDRIVE_REAUTH_REQUIRED_MARKER}:`;
const markerIndex = message.lastIndexOf(token);
if (markerIndex === -1) return message;
return message.slice(markerIndex + token.length).trim();
};
/**
* Provider account information
*/
export interface ProviderAccount {
id: string;
email?: string;
name?: string;
avatarUrl?: string;
}
/**
* Cloud provider connection state
*/
export interface ProviderConnection {
provider: CloudProvider;
status: ProviderConnectionStatus;
account?: ProviderAccount;
tokens?: OAuthTokens;
config?: WebDAVConfig | S3Config;
lastSync?: number; // Unix timestamp
lastSyncVersion?: number;
resourceId?: string; // gistId / fileId / itemId
error?: string;
}
const hasProviderConnectionData = (
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
): boolean => Boolean(connection.tokens || connection.config);
export const isProviderReadyForSync = (
connection: Pick<ProviderConnection, 'status' | 'tokens' | 'config'>,
): boolean =>
connection.status === 'connected'
|| connection.status === 'syncing'
|| (connection.status === 'error' && hasProviderConnectionData(connection));
// ============================================================================
// Encrypted Sync File Schema
// ============================================================================
/**
* Sync file metadata (stored in plaintext for version control)
*/
export interface SyncFileMeta {
version: number; // Incremental version number
updatedAt: number; // Unix timestamp (ms)
deviceId: string; // UUID identifying the device
deviceName?: string; // Human-readable device name
appVersion: string; // App version that created this sync
iv: string; // AES-GCM initialization vector (Base64)
salt: string; // KDF salt for key derivation (Base64)
algorithm: 'AES-256-GCM'; // Encryption algorithm identifier
kdf: 'PBKDF2' | 'Argon2id'; // Key derivation function
kdfIterations?: number; // PBKDF2 iterations (if applicable)
}
/**
* Complete synced file structure
* The payload contains all encrypted user data
*/
export interface SyncedFile {
meta: SyncFileMeta;
payload: string; // Base64 encrypted ciphertext
}
/**
* Decrypted payload structure - contains all syncable data
*/
export interface SyncPayload {
// Core vault data
hosts: import('./models').Host[];
keys: import('./models').SSHKey[];
identities?: import('./models').Identity[];
proxyProfiles?: import('./models').ProxyProfile[];
snippets: import('./models').Snippet[];
customGroups: string[];
snippetPackages?: string[];
// Group configs (connection defaults per host group)
groupConfigs?: import('./models').GroupConfig[];
// Port forwarding rules
portForwardingRules?: import('./models').PortForwardingRule[];
// Known hosts
knownHosts?: import('./models').KnownHost[];
// Settings
settings?: {
// Theme & Appearance
theme?: 'light' | 'dark' | 'system';
lightUiThemeId?: string;
darkUiThemeId?: string;
accentMode?: 'theme' | 'custom';
customAccent?: string;
uiFontFamilyId?: string;
uiLanguage?: string;
customCSS?: string;
// Terminal
terminalTheme?: string;
followAppTerminalTheme?: boolean;
terminalThemeDark?: string;
terminalThemeLight?: string;
terminalFontFamily?: string;
terminalFontSize?: number;
terminalSettings?: Record<string, unknown>;
customTerminalThemes?: Array<{ id: string; name: string; colors: Record<string, string> }>;
// Keyboard
customKeyBindings?: Record<string, { mac?: string; pc?: string }>;
// Editor
editorWordWrap?: boolean;
// SFTP
sftpDoubleClickBehavior?: 'open' | 'transfer';
sftpAutoSync?: boolean;
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpAutoOpenSidebar?: boolean;
sftpFollowTerminalCwd?: boolean;
sftpDefaultViewMode?: 'list' | 'tree';
sftpGlobalBookmarks?: import('./models').SftpBookmark[];
// Vault: show recently connected hosts
showRecentHosts?: boolean;
// Vault: root list shows only ungrouped hosts
showOnlyUngroupedHostsInRoot?: boolean;
// Top tabs: show standalone SFTP view tab
showSftpTab?: boolean;
// Shortcuts: Cmd/Ctrl+[1...9] skip pinned Vault/SFTP tabs
shellOnlyTabNumberShortcuts?: boolean;
// Shortcuts: disable terminal font zoom shortcuts
disableTerminalFontZoom?: boolean;
// Terminal/editor tabs: show left host list sidebar
showHostTreeSidebar?: boolean;
// Workspace focus indicator style
workspaceFocusStyle?: 'dim' | 'border';
// AI configuration
ai?: {
providers?: Array<Record<string, unknown>>;
activeProviderId?: string;
activeModelId?: string;
globalPermissionMode?: 'observer' | 'confirm' | 'autonomous';
toolIntegrationMode?: 'mcp' | 'skills';
hostPermissions?: Array<Record<string, unknown>>;
// externalAgents intentionally omitted: command/args/env are device-local
// (binary paths, OS-specific values) and don't survive cross-device sync.
defaultAgentId?: string;
commandBlocklist?: string[];
commandTimeout?: number;
maxIterations?: number;
agentModelMap?: Record<string, string>;
agentProviderMap?: Record<string, string>;
webSearchConfig?: Record<string, unknown> | null;
quickMessages?: Array<Record<string, unknown>>;
showTerminalSelectionAction?: boolean;
};
};
// Sync metadata
syncedAt: number; // When this payload was created
// Reliability metadata used to make sync decisions auditable across devices.
syncMeta?: SyncReliabilityMeta;
}
export const SYNC_PAYLOAD_ENTITY_KEYS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',
'portForwardingRules',
'knownHosts',
'groupConfigs',
] as const;
export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',
'portForwardingRules',
'groupConfigs',
] as const;
export type SyncPayloadEntityKey = typeof SYNC_PAYLOAD_ENTITY_KEYS[number];
export type CloudSyncPayloadEntityKey = typeof CLOUD_SYNC_PAYLOAD_ENTITY_KEYS[number];
export type SyncChangeEntityKey = CloudSyncPayloadEntityKey | 'settings';
export interface SyncEntityChangeCounts {
added: { local: number; remote: number };
modified: { local: number; remote: number };
deleted: { local: number; remote: number };
}
export interface SyncConflictDetail {
entityType: SyncChangeEntityKey;
id?: string;
kind:
| 'both-added'
| 'both-modified'
| 'local-deleted-remote-modified'
| 'remote-deleted-local-modified';
}
export interface SyncChangeSummary {
hasLocalChanges: boolean;
hasRemoteChanges: boolean;
hasConflicts: boolean;
byEntity: Partial<Record<SyncChangeEntityKey, SyncEntityChangeCounts>>;
conflicts: SyncConflictDetail[];
}
export interface SyncDeletionRecord {
entityType: CloudSyncPayloadEntityKey;
id: string;
deletedAt: number;
deviceId?: string;
}
export interface SyncReliabilityMeta {
schemaVersion: 1;
generatedAt: number;
deviceId?: string;
baseSyncedAt?: number;
localChanged: boolean;
deletions: SyncDeletionRecord[];
changeSummary: SyncChangeSummary;
}
export interface SyncSnapshotEntry {
id: string;
timestamp: number;
provider?: CloudProvider;
payload: SyncPayload;
}
export function hasSyncPayloadEntityData(
payload: SyncPayload,
keys: readonly SyncPayloadEntityKey[] = SYNC_PAYLOAD_ENTITY_KEYS,
): boolean {
return keys.some((key) => {
const value = payload[key];
return Array.isArray(value) && value.length > 0;
});
}
// ============================================================================
// Encryption Types
// ============================================================================
/**
* Encryption result
*/
export interface EncryptionResult {
ciphertext: Uint8Array;
iv: Uint8Array;
salt: Uint8Array;
algorithm: 'AES-256-GCM';
kdf: 'PBKDF2' | 'Argon2id';
kdfIterations?: number;
}
/**
* Decryption input
*/
export interface DecryptionInput {
ciphertext: Uint8Array;
iv: Uint8Array;
salt: Uint8Array;
kdf: 'PBKDF2' | 'Argon2id';
kdfIterations?: number;
}
// ============================================================================
// Master Key Types
// ============================================================================
/**
* Master key configuration stored in safeStorage
*/
export interface MasterKeyConfig {
// Verification hash to confirm correct password
verificationHash: string; // Base64 of hash(derived_key)
salt: string; // Base64 KDF salt
kdf: 'PBKDF2' | 'Argon2id';
kdfIterations?: number;
createdAt: number;
}
/**
* Unlocked master key state (in memory only)
*/
export interface UnlockedMasterKey {
derivedKey: CryptoKey; // AES-256-GCM key
salt: Uint8Array;
unlockedAt: number;
}
// ============================================================================
// Sync Manager Types
// ============================================================================
/**
* Sync operation result
*/
export interface SyncResult {
success: boolean;
provider: CloudProvider;
action: 'upload' | 'download' | 'merge' | 'none';
version?: number;
error?: string;
conflictDetected?: boolean;
/** Present when sync produced or selected a payload that caller should apply locally */
mergedPayload?: import('./sync').SyncPayload;
/** Present with a downloaded payload so callers can commit the remote anchor after local apply succeeds. */
remoteFile?: SyncedFile;
/** True when a shrink-detection guard blocked the upload */
shrinkBlocked?: boolean;
/** The finding that triggered the shrink block or force-push */
finding?: ShrinkFinding;
}
export interface RemoteSyncPayload {
provider: CloudProvider;
payload: SyncPayload;
remoteFile: SyncedFile;
}
/**
* Conflict information for UI
*/
export interface ConflictInfo {
provider: CloudProvider;
localVersion: number;
localUpdatedAt: number;
localDeviceName?: string;
remoteVersion: number;
remoteUpdatedAt: number;
remoteDeviceName?: string;
changeSummary?: SyncChangeSummary;
}
/**
* Sync history record entry
*/
export interface SyncHistoryEntry {
id: string;
timestamp: number;
provider: CloudProvider;
action: 'upload' | 'download' | 'merge' | 'conflict_resolved';
success: boolean;
localVersion: number;
remoteVersion?: number;
deviceName?: string;
error?: string;
}
// ============================================================================
// OAuth Flow Types
// ============================================================================
/**
* GitHub Device Flow response
*/
export interface GitHubDeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
}
/**
* OAuth PKCE challenge
*/
export interface PKCEChallenge {
codeVerifier: string;
codeChallenge: string;
state: string;
}
// ============================================================================
// Event Types
// ============================================================================
/**
* Sync event for UI updates
*/
export type SyncEvent =
| { type: 'SYNC_STARTED'; provider: CloudProvider }
| { type: 'SYNC_PROGRESS'; provider: CloudProvider; progress: number; message: string }
| { type: 'SYNC_COMPLETED'; provider: CloudProvider; result: SyncResult }
| { type: 'SYNC_ERROR'; provider: CloudProvider; error: string }
| { type: 'CONFLICT_DETECTED'; conflict: ConflictInfo }
| { type: 'SYNC_BLOCKED_SHRINK'; provider: CloudProvider; finding: ShrinkFinding }
| { type: 'SYNC_FORCED'; provider: CloudProvider; finding: ShrinkFinding }
| { type: 'CONFLICT_RESOLVED'; resolution: ConflictResolution }
| { type: 'AUTH_REQUIRED'; provider: CloudProvider }
| { type: 'AUTH_COMPLETED'; provider: CloudProvider; account: ProviderAccount }
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState }
| { type: 'SYNC_BLOCKED_CLEARED' }
| {
type: 'PROVIDERS_DIVERGED';
summaries: Array<{
provider: CloudProvider;
hosts: number;
keys: number;
snippets: number;
}>;
};
// ============================================================================
// Storage Keys
// ============================================================================
export const SYNC_STORAGE_KEYS = {
MASTER_KEY_CONFIG: 'netcatty_master_key_config_v1',
DEVICE_ID: 'netcatty_device_id_v1',
DEVICE_NAME: 'netcatty_device_name_v1',
SYNC_CONFIG: 'netcatty_sync_config_v2',
PROVIDER_GITHUB: 'netcatty_provider_github_v1',
PROVIDER_GOOGLE: 'netcatty_provider_google_v1',
PROVIDER_ONEDRIVE: 'netcatty_provider_onedrive_v1',
PROVIDER_WEBDAV: 'netcatty_provider_webdav_v1',
PROVIDER_S3: 'netcatty_provider_s3_v1',
PROVIDER_SMB: 'netcatty_provider_smb_v1',
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
} as const;
// ============================================================================
// Constants
// ============================================================================
const readBuildEnv = (key: string): string | undefined => {
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
const value = env?.[key];
return value && value.trim().length ? value : undefined;
};
export const SYNC_CONSTANTS = {
// Encryption
AES_KEY_LENGTH: 256,
GCM_IV_LENGTH: 12, // bytes
GCM_TAG_LENGTH: 128, // bits
SALT_LENGTH: 32, // bytes
// PBKDF2
PBKDF2_ITERATIONS: 600000, // OWASP recommended minimum
PBKDF2_HASH: 'SHA-256',
// Sync
SYNC_FILE_NAME: 'netcatty-vault.json',
GIST_DESCRIPTION: 'Netcatty Encrypted Vault (DO NOT EDIT MANUALLY)',
// Auto-sync
DEFAULT_AUTO_SYNC_INTERVAL: 5, // minutes
MIN_SYNC_INTERVAL: 1, // minutes
MAX_SYNC_INTERVAL: 60, // minutes
// OAuth
GITHUB_CLIENT_ID: readBuildEnv('VITE_SYNC_GITHUB_CLIENT_ID') || '', // Public client ID for Device Flow
GOOGLE_CLIENT_ID: readBuildEnv('VITE_SYNC_GOOGLE_CLIENT_ID') || '',
GOOGLE_CLIENT_SECRET: readBuildEnv('VITE_SYNC_GOOGLE_CLIENT_SECRET') || '',
ONEDRIVE_CLIENT_ID: readBuildEnv('VITE_SYNC_ONEDRIVE_CLIENT_ID') || '',
// API endpoints
GITHUB_DEVICE_CODE_URL: 'https://github.com/login/device/code',
GITHUB_ACCESS_TOKEN_URL: 'https://github.com/login/oauth/access_token',
GITHUB_API_BASE: 'https://api.github.com',
GOOGLE_AUTH_URL: 'https://accounts.google.com/o/oauth2/v2/auth',
GOOGLE_TOKEN_URL: 'https://oauth2.googleapis.com/token',
GOOGLE_DRIVE_API: 'https://www.googleapis.com/drive/v3',
ONEDRIVE_AUTH_URL: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize',
ONEDRIVE_TOKEN_URL: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
ONEDRIVE_GRAPH_API: 'https://graph.microsoft.com/v1.0',
} as const;
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Generate a unique device ID
*/
export const generateDeviceId = (): string => {
return crypto.randomUUID();
};
/**
* Get default device name based on OS
*/
export const getDefaultDeviceName = (): string => {
const platform = navigator.platform || 'Unknown';
const hostname = 'Netcatty';
return `${hostname} (${platform})`;
};
/**
* Format last sync time for display
*/
export const formatLastSync = (timestamp?: number): string => {
if (!timestamp) return 'Never synced';
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)} min ago`;
if (diff < 86400000) {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const date = new Date(timestamp);
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
/**
* Get status dot color class
*/
export const getSyncDotColor = (status: ProviderConnectionStatus): string => {
switch (status) {
case 'connected': return 'bg-green-500';
case 'syncing': return 'bg-blue-500';
case 'error': return 'bg-red-500';
case 'connecting': return 'bg-yellow-500';
default: return 'bg-muted-foreground';
}
};