Files
Netcatty/infrastructure/services/cloudSync/stateAndSecurityMethods.ts
T
陈大猫 b1f199c4d7 fix(sync): show sync dates and pending-only green dot (#1765) (#1770)
Add yyyymmdd hhmm timestamps to cloud sync history and only show the green status dot when local changes are still pending upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:12:03 +08:00

596 lines
21 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import {
SYNC_CONSTANTS,
SYNC_STORAGE_KEYS,
cleanOneDriveErrorMessage,
generateDeviceId,
getDefaultDeviceName,
isOneDriveReauthRequiredMessage,
} from '../../../domain/sync';
import {
DEFAULT_CLOUD_SYNC_STRATEGY,
normalizeCloudSyncStrategy,
} from '../../../domain/syncStrategy';
import { EncryptionService } from '../EncryptionService';
import { createAdapter } from '../adapters';
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
import {
decryptProviderSecrets,
encryptProviderSecrets,
} from '../../persistence/secureFieldAdapter';
import type { CloudAdapter } from '../adapters';
import type {
CloudProvider,
MasterKeyConfig,
ProviderConnection,
SecurityState,
SyncHistoryEntry,
} from '../../../domain/sync';
import type { SyncManagerState } from '../CloudSyncManager';
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
export function loadInitialStateImpl(this: any): SyncManagerState {
// Load persisted configuration
const masterKeyConfig = this.loadFromStorage<MasterKeyConfig>(
SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG
);
const deviceId = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_ID)
|| generateDeviceId();
const deviceName = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_NAME)
|| getDefaultDeviceName();
const syncConfig = this.loadFromStorage<{
autoSync: boolean;
interval: number;
localVersion: number;
localUpdatedAt: number;
remoteVersion: number;
remoteUpdatedAt: number;
syncStrategy?: unknown;
}>(SYNC_STORAGE_KEYS.SYNC_CONFIG);
// Load sync history
const syncHistory = this.loadFromStorage<SyncHistoryEntry[]>(SYNC_HISTORY_STORAGE_KEY) || [];
// Determine initial security state
const securityState: SecurityState = masterKeyConfig ? 'LOCKED' : 'NO_KEY';
// Load provider connections
const providers: Record<CloudProvider, ProviderConnection> = {
github: this.loadProviderConnection('github'),
google: this.loadProviderConnection('google'),
onedrive: this.loadProviderConnection('onedrive'),
webdav: this.loadProviderConnection('webdav'),
s3: this.loadProviderConnection('s3'),
};
// Save device ID if new
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_ID, deviceId);
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_NAME, deviceName);
return {
securityState,
syncState: 'IDLE',
masterKeyConfig,
unlockedKey: null,
providers,
deviceId,
deviceName,
localVersion: syncConfig?.localVersion || 0,
localUpdatedAt: syncConfig?.localUpdatedAt || 0,
remoteVersion: syncConfig?.remoteVersion || 0,
remoteUpdatedAt: syncConfig?.remoteUpdatedAt || 0,
currentConflict: null,
lastError: null,
autoSyncEnabled: syncConfig?.autoSync || false,
autoSyncInterval: syncConfig?.interval || SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
syncStrategy: normalizeCloudSyncStrategy(syncConfig?.syncStrategy ?? DEFAULT_CLOUD_SYNC_STRATEGY),
syncHistory,
pendingLocalSync: false,
};
}
export function loadProviderConnectionImpl(this: any,provider: CloudProvider): ProviderConnection {
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
const stored = this.loadFromStorage<Partial<ProviderConnection>>(key);
// Determine the correct status: if tokens or config exist, should be 'connected'
// Never restore 'syncing' or 'error' status - those are transient
const status: ProviderConnection['status'] = (stored?.tokens || stored?.config)
? 'connected'
: 'disconnected';
return {
provider,
...stored,
status, // Must be last to override any stored 'syncing' or 'error' status
} as ProviderConnection;
}
export async function initProviderDecryptionImpl(this: any): Promise<void> {
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
for (const p of providers) {
try {
const conn = this.state.providers[p];
if (conn.tokens || conn.config) {
const seq = ++this.providerDecryptSeq[p];
const decrypted = await decryptProviderSecrets(conn);
// Only apply if no newer update has occurred during the async gap
if (seq === this.providerDecryptSeq[p]) {
this.state.providers[p] = decrypted;
this.providerDecrypted[p] = true;
}
} else {
// No secrets to decrypt — mark as done
this.providerDecrypted[p] = true;
}
} catch {
// Decryption failed — likely the Electron IPC handler is not yet
// registered. getConnectedAdapter() will retry for this provider.
}
}
this.notifyStateChange();
}
export async function saveProviderConnectionImpl(this: any,
provider: CloudProvider,
connection: ProviderConnection,
authAttemptId?: number
): Promise<void> {
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
// Use write-specific counter so status-only updates cannot discard
// an in-flight encrypted write that must be persisted.
const seq = ++this.providerWriteSeq[provider];
const encrypted = await encryptProviderSecrets(connection);
// Only persist if no newer save has started during the async gap
if (
seq === this.providerWriteSeq[provider] &&
(authAttemptId == null || this.isActiveAuthAttempt(provider, authAttemptId))
) {
this.saveToStorage(key, encrypted);
}
}
export function loadFromStorageImpl<T>(this: any,key: string): T | null {
return localStorageAdapter.read<T>(key);
}
export function saveToStorageImpl(this: any,key: string, value: unknown): void {
localStorageAdapter.write(key, value);
}
export function removeFromStorageImpl(this: any,key: string): void {
localStorageAdapter.remove(key);
}
export function setupCrossWindowSyncImpl(this: any): void {
if (this.hasStorageListener) return;
if (typeof window === 'undefined') return;
window.addEventListener('storage', this.handleStorageEvent);
this.hasStorageListener = true;
}
export function safeJsonParseImpl<T>(this: any,value: string | null): T | null {
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
export function handleStorageEventImpl(this: any, event: StorageEvent): void {
if (event.storageArea !== window.localStorage) return;
const key = event.key;
if (!key) return;
// Handle master key config changes (e.g., when set up in settings window)
if (key === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG) {
const nextConfig = this.safeJsonParse<MasterKeyConfig>(event.newValue);
if (nextConfig) {
const currentConfig = this.state.masterKeyConfig as MasterKeyConfig | null;
const configChanged = !currentConfig
|| currentConfig.verificationHash !== nextConfig.verificationHash
|| currentConfig.salt !== nextConfig.salt
|| currentConfig.kdf !== nextConfig.kdf
|| currentConfig.kdfIterations !== nextConfig.kdfIterations;
if (!configChanged) return;
// Master key was set up or changed in another window. Lock this
// window so it cannot keep syncing with the stale in-memory password.
this.bumpSyncSecurityGeneration?.();
this.state.masterKeyConfig = nextConfig;
this.state.securityState = 'LOCKED';
this.state.unlockedKey = null;
this.masterPassword = null;
this.stopAutoSync();
this.notifyStateChange();
} else if (this.state.masterKeyConfig) {
// Master key was removed in another window
this.bumpSyncSecurityGeneration?.();
this.state.masterKeyConfig = null;
this.state.securityState = 'NO_KEY';
this.state.unlockedKey = null;
this.masterPassword = null;
this.notifyStateChange();
}
return;
}
// Sync versions + auto-sync settings
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
const next = this.safeJsonParse<{
autoSync?: boolean;
interval?: number;
localVersion?: number;
localUpdatedAt?: number;
remoteVersion?: number;
remoteUpdatedAt?: number;
syncStrategy?: unknown;
}>(event.newValue) || {
autoSync: false,
interval: SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
localVersion: 0,
localUpdatedAt: 0,
remoteVersion: 0,
remoteUpdatedAt: 0,
syncStrategy: DEFAULT_CLOUD_SYNC_STRATEGY,
};
this.state.autoSyncEnabled = Boolean(next.autoSync);
this.state.autoSyncInterval = Math.max(
SYNC_CONSTANTS.MIN_SYNC_INTERVAL,
Math.min(
SYNC_CONSTANTS.MAX_SYNC_INTERVAL,
Number(next.interval ?? SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL)
)
);
this.state.localVersion = Number(next.localVersion ?? 0);
this.state.localUpdatedAt = Number(next.localUpdatedAt ?? 0);
this.state.remoteVersion = Number(next.remoteVersion ?? 0);
this.state.remoteUpdatedAt = Number(next.remoteUpdatedAt ?? 0);
this.state.syncStrategy = normalizeCloudSyncStrategy(next.syncStrategy);
this.notifyStateChange();
return;
}
// Sync history list
if (key === SYNC_HISTORY_STORAGE_KEY) {
const nextHistory = this.safeJsonParse<SyncHistoryEntry[]>(event.newValue) || [];
this.state.syncHistory = Array.isArray(nextHistory) ? nextHistory : [];
this.notifyStateChange();
return;
}
// Sync provider connections (connect/disconnect, account, tokens, last sync)
const providerByKey: Partial<Record<string, CloudProvider>> = {
[SYNC_STORAGE_KEYS.PROVIDER_GITHUB]: 'github',
[SYNC_STORAGE_KEYS.PROVIDER_GOOGLE]: 'google',
[SYNC_STORAGE_KEYS.PROVIDER_ONEDRIVE]: 'onedrive',
[SYNC_STORAGE_KEYS.PROVIDER_WEBDAV]: 'webdav',
[SYNC_STORAGE_KEYS.PROVIDER_S3]: 's3',
};
const provider = providerByKey[key];
if (provider) {
const rawNext = this.loadProviderConnection(provider);
const seq = ++this.providerDecryptSeq[provider];
// Also bump write seq so any in-flight save from this window for the
// same provider is discarded — the cross-window data is newer.
++this.providerWriteSeq[provider];
// Decrypt secrets asynchronously, then update state.
// Use sequence counter to discard stale results when multiple events
// for the same provider arrive in quick succession.
decryptProviderSecrets(rawNext).then((next) => {
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
const prev = this.state.providers[provider];
const preserveTransientStatus =
prev.status === 'connecting' || prev.status === 'syncing';
this.state.providers[provider] = {
...next,
status: preserveTransientStatus ? prev.status : next.status,
error: preserveTransientStatus ? prev.error : next.error,
};
const nextTokens = next.tokens;
const nextConfig = next.config;
const adapter = this.adapters.get(provider);
if (!nextTokens && !nextConfig) {
if (adapter) {
adapter.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
return;
}
const tokenChanged =
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
const configChanged =
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
adapter.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
}).catch(() => {
// Decryption failure in cross-window handler is non-fatal
});
}
}
export async function getConnectedAdapterImpl(this: any,provider: CloudProvider): Promise<CloudAdapter> {
// Ensure startup decryption has finished before reading tokens
await this.decryptionReady;
// If this provider's secrets were not successfully decrypted at
// startup (IPC handler not registered yet), retry now.
if (!this.providerDecrypted[provider]) {
const conn = this.state.providers[provider];
if (conn.tokens || conn.config) {
try {
const seq = ++this.providerDecryptSeq[provider];
const decrypted = await decryptProviderSecrets(conn);
if (seq === this.providerDecryptSeq[provider]) {
this.state.providers[provider] = decrypted;
this.providerDecrypted[provider] = true;
// Evict any adapter cached with the old (encrypted) tokens
// so a fresh one is built from the decrypted credentials below.
const stale = this.adapters.get(provider);
if (stale) {
stale.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
}
} catch {
// Still failing — will surface when adapter tries to use tokens
}
}
}
const connection = this.state.providers[provider];
const tokens = connection?.tokens;
const config = connection?.config;
if (!tokens && !config) {
throw new Error('Provider not connected');
}
const existing = this.adapters.get(provider);
if (existing?.isAuthenticated) {
attachTokenRefreshPersistence.call(this, provider, existing);
return existing;
}
const adapter = await createAdapter(provider, tokens, connection.resourceId, config);
attachTokenRefreshPersistence.call(this, provider, adapter);
this.adapters.set(provider, adapter);
return adapter;
}
/**
* Wire an OAuth adapter's token-refresh callback so silently refreshed tokens
* are persisted. Without this, an adapter that refreshes its access token only
* updates memory and the next launch loads a stale token and is forced to
* reconnect — OneDrive's rotating refresh tokens go stale after the first
* in-session refresh (#1189), and Google's refreshed access token is likewise
* lost on restart. OneDrive and Google expose setOnTokensRefreshed; adapters
* without it (GitHub, WebDAV, S3) are no-ops.
*/
export function attachTokenRefreshPersistence(
this: any,
provider: CloudProvider,
adapter: CloudAdapter,
): void {
const setCallback = (adapter as {
setOnTokensRefreshed?: (cb: (tokens: import('../../../domain/sync').OAuthTokens) => void) => void;
}).setOnTokensRefreshed;
if (typeof setCallback !== 'function') return;
setCallback.call(adapter, (tokens) => {
persistRefreshedProviderTokensImpl.call(this, provider, tokens);
});
}
/**
* Persist tokens that an adapter refreshed mid-session into provider state and
* encrypted storage. Bumps the decrypt sequence so a concurrent stale decrypt
* (startup / cross-window) can't clobber the rotated tokens; preserves the live
* status/account/resource fields. saveProviderConnection manages its own write
* sequence to serialize the encrypted persist.
*/
export function persistRefreshedProviderTokensImpl(
this: any,
provider: CloudProvider,
tokens: import('../../../domain/sync').OAuthTokens,
): void {
const existing = this.state.providers[provider];
// Provider may have been disconnected during the async refresh — don't
// resurrect a connection that no longer has credentials.
if (!existing?.tokens) return;
// Invalidate any in-flight decrypt (startup / cross-window) so it cannot
// overwrite the rotated tokens we are about to commit.
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...existing,
tokens,
};
void this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange();
}
/**
* Handle a sync error that means OneDrive's refresh token is dead. Clears the
* now-useless tokens and tears down the adapter so the provider drops to a real
* "reconnect" (disconnected) state instead of staying `error`-with-tokens —
* which `isProviderReadyForSync` keeps treating as ready, so auto-sync would
* otherwise retry the dead token forever and never surface a reconnect prompt.
*
* Returns true when it handled a reauth-required OneDrive error so the caller
* can preserve a clean status message. No-op (returns false) for any other
* provider or error.
*/
export function handleProviderReauthRequiredImpl(
this: any,
provider: CloudProvider,
error: unknown,
): boolean {
if (provider !== 'onedrive') return false;
const message = error instanceof Error ? error.message : String(error);
if (!isOneDriveReauthRequiredMessage(message)) return false;
// Idempotent: this error can surface on multiple paths in one sync (preflight
// inspection + the operation's own catch). Once the credentials are already
// cleared there is nothing more to do, but still report handled so callers
// skip the generic error status that would re-add the raw marker message.
const current = this.state.providers[provider];
if (!current?.tokens && !current?.config) return true;
const adapter = this.adapters.get(provider);
if (adapter) {
adapter.signOut();
this.adapters.delete(provider);
}
// Bump decrypt seq so a stale in-flight decrypt cannot resurrect the tokens.
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'error',
account: current?.account,
error: cleanOneDriveErrorMessage(message),
};
void this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange();
return true;
}
export async function setupMasterKeyImpl(this: any,password: string): Promise<void> {
if (this.state.masterKeyConfig) {
throw new Error('Master key already exists. Use changeMasterKey instead.');
}
const config = await EncryptionService.createMasterKeyConfig(password);
this.bumpSyncSecurityGeneration?.();
this.state.masterKeyConfig = config;
this.state.securityState = 'LOCKED';
this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, config);
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'LOCKED' });
// Auto-unlock after setup
await this.unlock(password);
}
export async function unlockImpl(this: any,password: string): Promise<boolean> {
if (!this.state.masterKeyConfig) {
throw new Error('No master key configured');
}
if (this.state.securityState === 'UNLOCKED') {
return true;
}
const unlockedKey = await EncryptionService.unlockMasterKey(
password,
this.state.masterKeyConfig
);
if (!unlockedKey) {
return false;
}
this.state.unlockedKey = unlockedKey;
this.state.securityState = 'UNLOCKED';
this.masterPassword = password;
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'UNLOCKED' });
// Start auto-sync if enabled
if (this.state.autoSyncEnabled) {
this.startAutoSync();
}
return true;
}
export function lockImpl(this: any): void {
if (this.state.securityState !== 'UNLOCKED') {
return;
}
// Clear sensitive data from memory
this.bumpSyncSecurityGeneration?.();
this.state.unlockedKey = null;
this.masterPassword = null;
this.state.securityState = 'LOCKED';
// Stop auto-sync
this.stopAutoSync();
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'LOCKED' });
}
export async function changeMasterKeyImpl(this: any,oldPassword: string, newPassword: string): Promise<boolean> {
if (!this.state.masterKeyConfig) {
throw new Error('No master key configured');
}
const newConfig = await EncryptionService.changeMasterPassword(
oldPassword,
newPassword,
this.state.masterKeyConfig
);
if (!newConfig) {
return false;
}
this.bumpSyncSecurityGeneration?.();
this.state.masterKeyConfig = newConfig;
this.state.securityState = 'UNLOCKED';
this.masterPassword = newPassword;
// Re-derive key with new password
this.state.unlockedKey = await EncryptionService.unlockMasterKey(
newPassword,
newConfig
);
this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, newConfig);
// Notify UI and restart auto-sync (actual re-upload requires a payload from app state)
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'UNLOCKED' });
if (this.state.autoSyncEnabled) {
this.startAutoSync();
}
return true;
}
export async function verifyPasswordImpl(this: any,password: string): Promise<boolean> {
if (!this.state.masterKeyConfig) {
return false;
}
return EncryptionService.verifyPassword(password, this.state.masterKeyConfig);
}