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

874 lines
28 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';
import type {
ConvergentFieldConflict,
ConvergentSyncEnvelopeV2,
ConvergentSyncStateV2,
} from './convergentSync';
import {
BUILTIN_CLOUD_PROVIDERS,
isBuiltinCloudProvider,
providerConnectionStorageKey,
type BuiltinCloudProvider,
type CloudProviderId,
} from './cloudProviderIds';
export type {
ConvergentFieldConflict,
ConvergentSyncEnvelopeV2,
ConvergentSyncStateV2,
} from './convergentSync';
export {
BUILTIN_CLOUD_PROVIDERS,
isBuiltinCloudProvider,
providerConnectionStorageKey,
type BuiltinCloudProvider,
};
/** Built-in short IDs or namespaced plugin contribution IDs. */
export type CloudProvider = CloudProviderId;
// ============================================================================
// 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
// ============================================================================
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;
allowInsecure?: 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
*/
/**
* Opaque host-owned sync credential reference (SecretRef / CredentialRef shape).
* Never stores plaintext secrets — only the reference the plugin connect path needs.
* Secret leases are one-shot and must not be persisted for reconnect.
*/
export interface PluginSyncCredentialRef {
kind: 'secret' | 'credential';
id: string;
key?: string;
}
/** Reasonable upper bounds for durable opaque ref strings persisted at rest. */
const MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS = 512;
const MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS = 256;
/**
* Normalize a value into a durable PluginSyncCredentialRef for reconnect
* persistence. Rejects arrays, leases, and oversized / malformed shapes.
*/
export function normalizeDurablePluginSyncCredentialRef(
value: unknown,
): PluginSyncCredentialRef | undefined {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const kind = record.kind;
const id = record.id;
if (kind !== 'secret' && kind !== 'credential') return undefined;
if (typeof id !== 'string' || id.length < 1 || id.length > MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS) {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(record, 'key') || record.key === undefined) {
return { kind, id };
}
const key = record.key;
if (typeof key !== 'string' || key.length < 1 || key.length > MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS) {
return undefined;
}
return { kind, id, key };
}
export interface ProviderConnection {
provider: CloudProvider;
status: ProviderConnectionStatus;
account?: ProviderAccount;
tokens?: OAuthTokens;
config?: WebDAVConfig | S3Config;
/** Plugin sync providers: persisted SyncConnectPayload.credential for reconnect. */
credential?: PluginSyncCredentialRef;
lastSync?: number; // Unix timestamp
lastSyncVersion?: number;
resourceId?: string; // gistId / fileId / itemId
error?: string;
}
/**
* Whether a connection still has usable credentials/config to retry.
* Plugin configs may be valid falsy JSON scalars (`false`, `0`, `""`) or even
* JSON `null` when a schema uses `type: "null"`. Presence is property
* existence for `config`; do not use truthiness (`||` / `Boolean`).
*/
export const hasProviderConnectionData = (
connection: Pick<ProviderConnection, 'tokens' | 'config' | 'credential'>,
): boolean =>
connection.tokens != null
|| Object.prototype.hasOwnProperty.call(connection, 'config')
|| connection.credential != null;
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)
/** Present only for convergent-sync payloads. Unknown future versions fail closed. */
syncSchemaVersion?: 2;
}
/**
* 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[];
notes?: import('./models').VaultNote[];
noteGroups?: 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;
noteFontFamily?: string;
noteFontSize?: number;
noteCodeFontSize?: number;
// Terminal
terminalTheme?: string;
followAppTerminalTheme?: boolean;
terminalThemeDark?: string;
terminalThemeLight?: string;
terminalFontFamily?: string;
terminalFontSize?: number;
terminalSettings?: Record<string, unknown>;
terminalSidePanelAutoOpen?: boolean;
terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab;
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;
sftpSkipUnchanged?: boolean;
sftpAutoOpenSidebar?: boolean;
sftpFollowTerminalCwd?: boolean;
sftpDefaultViewMode?: 'list' | 'tree';
sftpGlobalBookmarks?: import('./models').SftpBookmark[];
// Vault: show recently connected hosts
showRecentHosts?: boolean;
// Vault: host click activates immediately, or select-then-click-again
hostClickBehavior?: 'connect' | 'select';
// Vault: root list shows only ungrouped hosts
showOnlyUngroupedHostsInRoot?: boolean;
// Top tabs: show standalone SFTP view tab
showSftpTab?: boolean;
// Shortcuts: Cmd/Ctrl+[1...9] and Ctrl+Tab skip pinned Vault/SFTP tabs
shellOnlyTabNumberShortcuts?: boolean;
// Shortcuts: show 1...9 badges on tabs matching number switch shortcuts
showTabNumberBadges?: 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' | 'auto';
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;
responseIdleTimeout?: number;
maxIterations?: number;
agentModelMap?: Record<string, string>;
agentProviderMap?: Record<string, string>;
agentThinkingMap?: Record<string, string>;
webSearchConfig?: Record<string, unknown> | null;
quickMessages?: Array<Record<string, unknown>>;
showTerminalSelectionAction?: boolean;
};
};
/**
* Encrypted-sidecar envelope for plugin user data that must survive missing
* plugins (sync:true settings, account/CRDT baselines). Secrets never appear here.
*/
pluginSidecars?: import('./pluginSyncSidecar').PluginSyncSidecarBundle;
// Sync metadata
syncedAt: number; // When this payload was created
// Reliability metadata used to make sync decisions auditable across devices.
syncMeta?: SyncReliabilityMeta;
/** Encrypted convergent-sync metadata. The adjacent fields remain a complete v1 snapshot. */
convergentSync?: ConvergentSyncEnvelopeV2;
}
/**
* Strip device-local connection telemetry from hosts before any sync
* comparison or upload (#2629). `lastConnectedAt` updates on every successful
* SSH connect and must not dirty auto-sync hashes, three-way merges, or
* inflate cloud versions. Legacy stored bases / remote payloads still carry
* the field, so all three merge sides are normalized with this helper.
*/
export function sanitizeHostsForSync(
hosts: import('./models').Host[] | undefined,
): import('./models').Host[] | undefined {
if (!hosts) return hosts;
return hosts.map((host) => ({ ...host, lastConnectedAt: undefined }));
}
/** Copy a payload with device-local host telemetry stripped for compare/convert. */
export function withHostsSanitizedForSync(payload: SyncPayload): SyncPayload {
return {
...payload,
hosts: sanitizeHostsForSync(payload.hosts) ?? payload.hosts,
};
}
/**
* Re-attach this device's Recently Connected timestamps when a cloud payload
* omits `lastConnectedAt`. Incoming timestamps (local backups, legacy cloud
* snapshots) still win when present.
*/
export function retainLocalHostLastConnectedAt(
incoming: import('./models').Host[] | undefined,
local: import('./models').Host[] | undefined,
): import('./models').Host[] | undefined {
if (!incoming) return incoming;
if (!local?.length) return incoming;
const localById = new Map(local.map((host) => [host.id, host.lastConnectedAt]));
return incoming.map((host) => {
if (host.lastConnectedAt != null) return host;
const localTs = localById.get(host.id);
if (localTs == null) return host;
return { ...host, lastConnectedAt: localTs };
});
}
export const SYNC_PAYLOAD_ENTITY_KEYS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',
'notes',
'noteGroups',
'portForwardingRules',
'knownHosts',
'groupConfigs',
] as const;
export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',
'notes',
'noteGroups',
'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 interface ConvergentProviderMigrationStatus {
provider: CloudProvider;
status: 'ready' | 'empty' | 'unavailable' | 'blocked';
schemaVersion: 1 | 2 | 'future' | 'invalid';
entityCount: number;
hasTrustedBaseline: boolean;
message?: string;
}
export interface ConvergentMigrationPreview {
schemaVersion: 2;
canInitialize: boolean;
entityCounts: Partial<Record<CloudSyncPayloadEntityKey, number>>;
settingsLeafCount: number;
conflictCount: number;
conflicts: ConvergentFieldConflict[];
shrinkFindings: Array<{ provider: CloudProvider; finding: Extract<ShrinkFinding, { suspicious: true }> }>;
providers: ConvergentProviderMigrationStatus[];
oldClientCompatibility: 'materialized-v1-snapshot';
blockedReasons: string[];
}
export interface ConvergentReplicaRecordV2 {
schemaVersion: 2;
state: ConvergentSyncStateV2;
updatedAt: number;
}
export interface ConvergentProviderBaselineV2 {
schemaVersion: 2;
provider: CloudProvider;
remoteVersion: number;
remoteUpdatedAt: number;
remoteDeviceId: string;
materializedPayload: SyncPayload;
state: ConvergentSyncStateV2;
}
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;
/** True when convergent sync already applied mergedPayload and committed its replica atomically. */
mergedPayloadApplied?: boolean;
/** 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;
/** Field-level conflicts retained by convergent sync v2. */
convergentConflicts?: ConvergentFieldConflict[];
/** Number of retained v2 conflicts; duplicated for lightweight status views. */
convergentConflictCount?: number;
}
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',
/** Auto-sync prefs (autoSync / interval / syncStrategy); kept separate from version stamps. */
SYNC_PREFERENCES: 'netcatty_sync_preferences_v1',
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',
/** Registry of connected namespaced plugin sync provider IDs. */
PLUGIN_CLOUD_PROVIDERS: 'netcatty_plugin_cloud_providers_v1',
/** Contribution-available plugin sync provider IDs (live catalog membership). */
AVAILABLE_PLUGIN_SYNC_PROVIDERS: 'netcatty_available_plugin_sync_providers_v1',
/** Last successful sidecar collect (upload fallback when host is offline). */
PLUGIN_SIDECARS_LAST_KNOWN: 'netcatty_plugin_sidecars_last_known_v1',
/** Remote sidecar apply queued while the plugin host was unavailable. */
PLUGIN_SIDECARS_PENDING_REMOTE: 'netcatty_plugin_sidecars_pending_remote_v1',
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
CONVERGENT_REPLICA: 'netcatty_convergent_sync_replica_v2',
CONVERGENT_PROVIDER_BASELINE: 'netcatty_convergent_sync_provider_baseline_v2',
} 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 a sync timestamp as `yyyymmdd hhmm` (e.g. `20250628 1430`).
*/
export const formatSyncDateTime = (timestamp: number): string => {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}${month}${day} ${hours}${minutes}`;
};
/**
* 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`;
return formatSyncDateTime(timestamp);
};
/**
* 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';
}
};