[Init] Initial commit - NetMesh terminal manager
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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,976 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
SYNC_CONSTANTS,
SYNC_STORAGE_KEYS,
isProviderReadyForSync,
} from '../../../domain/sync';
import packageJson from '../../../package.json';
import { EncryptionService } from '../EncryptionService';
import { mergeSyncPayloads } from '../../../domain/syncMerge';
import { stripSyncPayloadEncryptedCredentials, healPoisonedSecretsForMerge } from '../../../domain/credentials';
import {
SYNC_SNAPSHOT_LIMIT,
summarizeSyncChanges,
withSyncReliabilityMeta,
} from '../../../domain/syncReliability';
import { detectSuspiciousShrink, type ShrinkFinding } from '../../../domain/syncGuards';
import { resolveCloudSyncConflictAction, type CloudSyncConflictAction, type CloudSyncStrategy } from '../../../domain/syncStrategy';
import { assertConvergentSyncWriteCompatible } from '../../../domain/convergentSync';
import { getConvergentSyncLocalConfig } from '../convergentSyncConfig';
import { syncAllProvidersConvergentlyImpl } from './convergentSyncRuntimeMethods';
import {
coalesceStoredSyncPreferences,
hasSyncPreferenceFields,
resolveSyncPreferencesForPersist,
resolveSyncVersionsForPersist,
} from './syncConfigPersist';
import type { CloudAdapter } from '../adapters';
import type {
CloudProvider,
ProviderConnection,
SyncedFile,
SyncHistoryEntry,
SyncSnapshotEntry,
SyncPayload,
SyncResult,
} from '../../../domain/sync';
// CloudProvider used when clearing dynamic plugin-provider bases/anchors.
import {
decryptLocalStorageValue,
encryptLocalStorageValue,
} from './encryptedLocalStorage';
function getSyncSecurityGeneration(manager: any): number | undefined {
return typeof manager.getSyncSecurityGeneration === 'function'
? manager.getSyncSecurityGeneration()
: undefined;
}
function assertSyncSecurityGeneration(manager: any, generation?: number): void {
if (typeof manager.assertSyncSecurityGeneration === 'function') {
manager.assertSyncSecurityGeneration(generation);
}
}
async function downloadRemoteForSyncAllImpl(this: any,
provider: CloudProvider,
remoteFile: SyncedFile,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const payload = stripSyncPayloadEncryptedCredentials(
await EncryptionService.decryptPayload(remoteFile, this.masterPassword),
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.updateProviderStatus(provider, 'connected');
const result: SyncResult = {
success: true,
provider,
action: 'download',
version: remoteFile.meta.version,
mergedPayload: payload,
remoteFile,
};
this.emit({ type: 'SYNC_COMPLETED', provider, result });
return result;
}
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
const SYNC_SNAPSHOTS_STORAGE_KEY = 'netcatty_sync_snapshots_v1';
async function loadRawSyncBase(this: any, provider?: CloudProvider): Promise<SyncPayload | null> {
const key = this.state.unlockedKey?.derivedKey;
if (!key || typeof this.loadFromStorage !== 'function') return null;
const encoded = this.loadFromStorage(this.syncBaseKey(provider));
if (!encoded || typeof encoded !== 'string') return null;
return decryptLocalStorageValue<SyncPayload>(encoded, key);
}
async function rememberCurrentSyncBaseSnapshot(this: any, provider?: CloudProvider): Promise<void> {
if (typeof this.syncSnapshotsKey !== 'function') return;
const previous = await loadRawSyncBase.call(this, provider);
if (!previous) return;
const snapshots = await loadSyncSnapshotsImpl.call(this, provider);
const entry: SyncSnapshotEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
timestamp: Date.now(),
...(provider ? { provider } : {}),
payload: previous,
};
await saveSyncSnapshotsImpl.call(this, [entry, ...snapshots].slice(0, SYNC_SNAPSHOT_LIMIT), provider);
}
export async function syncAllProvidersImpl(this: any,
inputPayload?: SyncPayload,
opts: {
overrideShrink?: boolean;
conflictActionOverride?: CloudSyncConflictAction;
applyConvergentPayload?: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>;
} = {},
): Promise<Map<CloudProvider, SyncResult>> {
const results = new Map<CloudProvider, SyncResult>();
let payload = inputPayload;
let wasMerged = false;
const convergentConfig = getConvergentSyncLocalConfig();
if (convergentConfig.initialized && inputPayload) {
if (convergentConfig.enabled) {
return syncAllProvidersConvergentlyImpl.call(this, inputPayload, {
...opts,
applyPayload: opts.applyConvergentPayload,
});
}
const message = 'Convergent sync is paused on this device';
for (const [provider, connection] of Object.entries(
this.state.providers as Record<CloudProvider, ProviderConnection>,
)) {
if (!isProviderReadyForSync(connection)) continue;
results.set(provider as CloudProvider, {
success: false,
provider: provider as CloudProvider,
action: 'none',
error: message,
});
}
return results;
}
const overrideShrinkRequested = opts.overrideShrink === true;
const syncSecurityGeneration = getSyncSecurityGeneration(this);
if (!payload) {
// Caller should provide payload from app state
return results;
}
if (this.state.securityState !== 'UNLOCKED') {
return results; // Or throw? Caller handles it.
}
if (!this.masterPassword) {
return results;
}
const connectedProviders = Object.entries(
this.state.providers as Record<CloudProvider, ProviderConnection>,
)
.filter(([provider, connection]) => {
if (!isProviderReadyForSync(connection)) return false;
if (connection.status === 'error') {
this.state.providers[provider as CloudProvider].status = 'connected';
this.state.providers[provider as CloudProvider].error = undefined;
// Clear cached adapter so a fresh one is created with current (decrypted) tokens
this.adapters.delete(provider as CloudProvider);
}
return true;
})
.map(([p]) => p as CloudProvider);
if (connectedProviders.length === 0) {
return results;
}
this.state.lastError = null;
this.state.syncState = 'SYNCING';
// 1. Parallel Checks
const checkTasks = connectedProviders.map(async (provider) => {
try {
// We handle connection error here to prevent one provider blocking others
const adapter = await this.getConnectedAdapter(provider);
this.updateProviderStatus(provider, 'syncing');
this.emit({ type: 'SYNC_STARTED', provider });
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const check = await this.checkProviderConflict(provider, adapter);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
return { provider, adapter, check };
} catch (error) {
return { provider, error: String(error) };
}
});
const checkResults = await Promise.all(checkTasks);
// 2. Analyze Results & Handle Conflicts — merge ALL conflicting providers
//
// Contract: every connected provider is assumed to mirror the *same*
// logical vault. When providers hold divergent content (e.g. user
// intentionally points GitHub and OneDrive at separate accounts with
// different data), uploading the conflict-merged payload below will
// overwrite provider-unique content on non-conflicting providers. A
// proper fix requires per-provider compare-and-swap (follow-up work,
// see I-1 and `docs/`). Until then, we log a diagnostic warning when
// we detect cross-provider base divergence so the issue is visible in
// support logs.
const conflicts = checkResults.filter((r) => !r.error && r.check?.conflict && r.check?.remoteFile);
// Instrumentation only — detect divergent provider bases (an
// unsupported configuration). Cheap: bases are already persisted
// and we only read their aggregate counts.
if (checkResults.filter((r) => !r.error).length > 1) {
try {
const summaries = await Promise.all(
checkResults
.filter((r) => !r.error)
.map(async (r) => {
const base = await this.loadSyncBase(r.provider as CloudProvider);
return {
provider: r.provider,
hosts: base?.hosts?.length ?? 0,
keys: base?.keys?.length ?? 0,
snippets: base?.snippets?.length ?? 0,
};
}),
);
const signatures = summaries.map((s) => `${s.hosts}/${s.keys}/${s.snippets}`);
const allSame = signatures.every((sig) => sig === signatures[0]);
if (!allSame) {
console.warn(
'[CloudSyncManager] syncAll: connected providers hold divergent bases (multi-account setup?). Uploading the conflict-merged payload will replace each provider\'s current remote. See I-7 in PR #720 for context.',
summaries,
);
// Surface the same finding to the UI so multi-account / intentionally
// diverged configurations can be warned visibly instead of silently
// having one provider's data merged over another's (#779 follow-up).
this.emit({
type: 'PROVIDERS_DIVERGED',
summaries: summaries.map((s) => ({
provider: s.provider as CloudProvider,
hosts: s.hosts,
keys: s.keys,
snippets: s.snippets,
})),
});
}
} catch (diagError) {
// Non-fatal diagnostic; never let it block the sync.
console.warn('[CloudSyncManager] syncAll: base-divergence check failed:', diagError);
}
}
if (conflicts.length > 0) {
const conflictAction = opts.conflictActionOverride
?? resolveCloudSyncConflictAction(this.state.syncStrategy, {
hasConflict: true,
hasRemoteFile: true,
});
if (conflictAction === 'download-remote') {
const newestConflict = conflicts.reduce((latest, entry) => {
const latestUpdatedAt = latest.check?.remoteFile?.meta.updatedAt ?? 0;
const entryUpdatedAt = entry.check?.remoteFile?.meta.updatedAt ?? 0;
return entryUpdatedAt > latestUpdatedAt ? entry : latest;
});
try {
const remoteResult = await downloadRemoteForSyncAllImpl.call(
this,
newestConflict.provider as CloudProvider,
newestConflict.check!.remoteFile!,
syncSecurityGeneration,
);
const sourceProvider = newestConflict.provider as CloudProvider;
results.set(sourceProvider, remoteResult);
payload = remoteResult.mergedPayload ?? payload;
for (const r of checkResults) {
const provider = r.provider as CloudProvider;
if (provider === sourceProvider) continue;
if (r.check) {
r.check.conflict = false;
}
}
} catch (error) {
const msg = String(error);
this.state.syncState = 'ERROR';
this.state.lastError = msg;
this.updateProviderStatus(newestConflict.provider as CloudProvider, 'error', msg);
this.emit({ type: 'SYNC_ERROR', provider: newestConflict.provider as CloudProvider, error: msg });
results.set(newestConflict.provider as CloudProvider, {
success: false,
provider: newestConflict.provider as CloudProvider,
action: 'none',
error: msg,
});
return results;
}
}
if (conflictAction === 'upload-local') {
for (const r of checkResults) {
if (r.check) r.check.conflict = false;
}
} else if (conflictAction === 'smart-merge') {
// Three-way merge: incorporate remote data from every conflicting provider
try {
let merged = payload;
for (const c of conflicts) {
const providerBase = await this.loadSyncBase(c.provider as CloudProvider);
const remoteRaw = await EncryptionService.decryptPayload(
c.check!.remoteFile!,
this.masterPassword,
);
const localHealed = healPoisonedSecretsForMerge(merged, remoteRaw, providerBase);
const remotePayload = healPoisonedSecretsForMerge(
remoteRaw,
merged,
providerBase,
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const result = mergeSyncPayloads(providerBase, localHealed, remotePayload);
merged = result.payload;
}
const mergeResult = {
payload: stripSyncPayloadEncryptedCredentials(merged),
};
console.info('[CloudSyncManager] syncAll: three-way merge completed');
// Replace payload with merged payload for upload to all providers
payload = mergeResult.payload;
wasMerged = true;
// Re-classify: all providers (including the conflicting one) should now upload
// Clear the conflict check result so all go through the upload path
for (const r of checkResults) {
if (r.check) r.check.conflict = false;
}
} catch (mergeError) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
// Merge failed — fall back to conflict UI
console.error('[CloudSyncManager] syncAll: merge failed', mergeError);
const { provider, check } = conflicts[0];
const remoteFile = check!.remoteFile!;
let conflictSummary;
try {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const base = await this.loadSyncBase(provider as CloudProvider);
const remotePayload = await EncryptionService.decryptPayload(remoteFile, this.masterPassword);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
conflictSummary = summarizeSyncChanges(
base,
payload,
remotePayload,
);
} catch {
conflictSummary = undefined;
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.state.syncState = 'CONFLICT';
this.state.currentConflict = {
provider: provider as CloudProvider,
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
localDeviceName: this.state.deviceName,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
...(conflictSummary ? { changeSummary: conflictSummary } : {}),
};
this.emit({
type: 'CONFLICT_DETECTED',
conflict: this.state.currentConflict,
});
for (const r of checkResults) {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
} else if (r.provider === conflicts[0].provider) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
conflictDetected: true,
});
} else {
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
results.set(r.provider as CloudProvider, {
success: true,
provider: r.provider as CloudProvider,
action: 'none',
});
}
}
return results;
}
}
}
// Shrink guard (multi-provider): check the final outgoing payload against
// each provider's stored base. If ANY provider would suffer a suspicious
// shrink, block ALL uploads — the same payload goes to every provider, so
// any one provider's "would lose too much" is a global block. Override flag
// is one-shot and clears regardless of outcome.
const shrinkSuspectByProvider: Array<{
provider: CloudProvider;
finding: Extract<ShrinkFinding, { suspicious: true }>;
}> = [];
const candidateProviders = checkResults
.filter((r) => !r.error && !r.check?.conflict && r.adapter)
.map((r) => r.provider as CloudProvider);
for (const provider of candidateProviders) {
const providerBase = await this.loadSyncBase(provider);
// When no stored base exists, fall back to the remote payload fetched
// during the parallel check above — the shrink guard needs a reference
// or it fails open and lets degraded local state overwrite remote
// (#779). checkResults carries the per-provider remoteFile already.
let providerRemoteRef: SyncPayload | null = null;
if (!providerBase) {
const entry = checkResults.find((r) => r.provider === provider);
const remoteFile = entry?.check?.remoteFile;
if (remoteFile) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
try {
providerRemoteRef = await EncryptionService.decryptPayload(
remoteFile,
this.masterPassword,
);
} catch {
providerRemoteRef = null;
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
}
}
const finding = detectSuspiciousShrink(payload, providerBase, providerRemoteRef);
if (finding.suspicious) {
shrinkSuspectByProvider.push({ provider, finding });
}
}
const shouldBlockAll = shrinkSuspectByProvider.length > 0 && !overrideShrinkRequested;
const shouldForceAll = shrinkSuspectByProvider.length > 0 && overrideShrinkRequested;
if (shouldBlockAll) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = shrinkSuspectByProvider[0].finding;
for (const { provider, finding } of shrinkSuspectByProvider) {
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
results.set(provider, {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding,
});
}
// Process check errors from the parallel check phase so a provider that
// failed during checkProviderConflict is not silently dropped from results.
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
// Providers in candidateProviders that didn't trip the shrink check still
// share the same payload — mark them as not-uploaded so the caller doesn't
// think a "successful" no-op happened.
const blockedProviders = new Set(shrinkSuspectByProvider.map((e) => e.provider));
for (const provider of candidateProviders) {
if (!results.has(provider) && !blockedProviders.has(provider)) {
results.set(provider, {
success: false,
provider,
action: 'none',
error: 'Sync blocked: another provider would lose too much data',
});
this.updateProviderStatus(provider, 'error', 'Sync blocked due to peer provider');
}
}
return results;
}
if (shouldForceAll) {
for (const { provider, finding } of shrinkSuspectByProvider) {
this.emit({ type: 'SYNC_FORCED', provider, finding });
}
}
// 3. Encrypt Once
const validUploads = checkResults.filter(
(r) => !r.error && !r.check?.conflict && r.adapter
) as { provider: CloudProvider; adapter: CloudAdapter }[];
if (validUploads.length === 0) {
// Process errors if any
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
if (Array.from(results.values()).some((r) => r.success)) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
} else {
this.state.syncState = 'ERROR';
}
this.notifyStateChange();
return results;
}
// Use the highest version as base: either local or any remote that was merged
// or forcibly overwritten. Explicit keep-local (conflictActionOverride
// upload-local) can run while syncStrategy is still smartMerge; without
// taking the remote version here, encryptPayload would mint local+1 and
// regress past a higher conflicting remote (e.g. local v1 over remote v5).
let baseVersion = this.state.localVersion;
if (
wasMerged
|| (
conflicts.length > 0
&& (
this.state.syncStrategy !== 'smartMerge'
|| opts.conflictActionOverride === 'upload-local'
)
)
) {
for (const c of conflicts) {
const rv = c.check?.remoteFile?.meta?.version ?? 0;
if (rv > baseVersion) baseVersion = rv;
}
}
// 4. Parallel Uploads — each provider gets metadata derived from its own
// base, then that exact payload is persisted as the provider base
// inside uploadToProvider BEFORE the per-provider anchor advances.
// Ordering matters: a crash between the two writes must leave the
// stale anchor re-triggering inspection on next startup, not a
// fresh anchor paired with a stale base.
const uploadTasks = validUploads.map(async ({ provider, adapter }) => {
try {
const entry = checkResults.find((result) => result.provider === provider);
assertConvergentSyncWriteCompatible(entry?.check?.remoteFile?.meta, payload);
const providerBase = await this.loadSyncBase(provider);
let providerRemoteRef: SyncPayload | null = null;
if (!providerBase) {
const remoteFile = entry?.check?.remoteFile;
if (remoteFile) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
try {
providerRemoteRef = await EncryptionService.decryptPayload(
remoteFile,
this.masterPassword,
);
} catch {
providerRemoteRef = null;
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
}
}
const providerPayload = withSyncReliabilityMeta(payload, providerBase ?? providerRemoteRef, {
deviceId: this.state.deviceId,
now: Date.now(),
});
const syncedFile = await EncryptionService.encryptPayload(
providerPayload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
packageJson.version,
baseVersion
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const result = await this.uploadToProvider(provider, adapter, syncedFile, providerPayload, syncSecurityGeneration);
results.set(provider, result);
} catch (error) {
const msg = String(error);
this.state.lastError = msg;
this.updateProviderStatus(provider, 'error', msg);
this.emit({ type: 'SYNC_ERROR', provider, error: msg });
results.set(provider, {
success: false,
provider,
action: 'none',
error: msg,
});
}
});
await Promise.all(uploadTasks);
// 5. Final State Update
const resultList = Array.from(results.values());
const hasSuccess = resultList.some((r) => r.success);
const hasConflict = resultList.some((r) => r.conflictDetected);
if (hasConflict) {
// Prefer CONFLICT over IDLE even when another provider succeeded, so the
// conflict UI from uploadToProvider is not wiped by a mixed multi-provider run.
this.state.syncState = 'CONFLICT';
if (wasMerged && payload) {
for (const [p, r] of results) {
if (r.success) {
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
}
}
}
} else if (hasSuccess) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
// If a merge happened, attach the merged payload to successful results
// so callers can apply remote additions to local state
if (wasMerged && payload) {
for (const [p, r] of results) {
if (r.success) {
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
}
}
}
} else {
this.state.syncState = 'ERROR';
// lastError is set by uploadToProvider
}
this.notifyStateChange(); // Notify UI that sync is complete
// Process errors from initial checks (if any)
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
return results;
}
export function setDeviceNameImpl(this: any,name: string): void {
this.state.deviceName = name;
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_NAME, name);
this.notifyStateChange();
}
export function setAutoSyncImpl(this: any,enabled: boolean, intervalMinutes?: number): void {
this.state.autoSyncEnabled = enabled;
const memoryKeys: Array<'autoSync' | 'interval'> = ['autoSync'];
if (intervalMinutes) {
this.state.autoSyncInterval = Math.max(
SYNC_CONSTANTS.MIN_SYNC_INTERVAL,
Math.min(SYNC_CONSTANTS.MAX_SYNC_INTERVAL, intervalMinutes)
);
memoryKeys.push('interval');
}
// Preference write: only the fields this setter owns — leave syncStrategy
// (and interval when unchanged) to whatever is already persisted so another
// window's concurrent edit is not overwritten by stale memory.
this.saveSyncConfig({ preferencesFromMemory: true, memoryKeys });
this.notifyStateChange(); // Notify UI of state change
if (enabled && this.state.securityState === 'UNLOCKED') {
this.startAutoSync();
} else {
this.stopAutoSync();
}
}
export function startAutoSyncImpl(this: any): void {
if (this.autoSyncTimer) {
return;
}
this.autoSyncTimer = setInterval(
() => {
// Auto-sync callback - caller should provide payload
this.emit({ type: 'SYNC_STARTED', provider: 'github' }); // Trigger UI to initiate sync
},
this.state.autoSyncInterval * 60 * 1000
);
}
export function stopAutoSyncImpl(this: any): void {
if (this.autoSyncTimer) {
clearInterval(this.autoSyncTimer);
this.autoSyncTimer = null;
}
}
export function saveSyncConfigImpl(
this: any,
opts?: {
preferencesFromMemory?: boolean;
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
},
): void {
const preferencesFromMemory = opts?.preferencesFromMemory === true;
const memoryKeys = opts?.memoryKeys;
type StoredPrefs = {
autoSync?: boolean;
interval?: number;
syncStrategy?: unknown;
};
type StoredConfig = StoredPrefs & {
localVersion?: number;
localUpdatedAt?: number;
remoteVersion?: number;
remoteUpdatedAt?: number;
};
const adoptPreferences = (nextPrefs: {
autoSync: boolean;
interval: number;
syncStrategy: CloudSyncStrategy;
}): boolean => {
const autoSyncChanged = this.state.autoSyncEnabled !== nextPrefs.autoSync;
const intervalChanged = this.state.autoSyncInterval !== nextPrefs.interval;
const strategyChanged = this.state.syncStrategy !== nextPrefs.syncStrategy;
this.state.autoSyncEnabled = nextPrefs.autoSync;
this.state.autoSyncInterval = nextPrefs.interval;
this.state.syncStrategy = nextPrefs.syncStrategy;
if (autoSyncChanged) {
if (nextPrefs.autoSync && this.state.securityState === 'UNLOCKED') {
this.startAutoSync?.();
} else {
this.stopAutoSync?.();
}
}
return autoSyncChanged || intervalChanged || strategyChanged;
};
const memoryPreferences = {
autoSync: this.state.autoSyncEnabled,
interval: this.state.autoSyncInterval,
syncStrategy: this.state.syncStrategy,
};
// Preference writers only touch SYNC_PREFERENCES so a concurrent
// version bump cannot re-enable auto-sync via a shared RMW blob (#2976).
// When memoryKeys is set, merge owned fields onto the stored snapshot so
// a strategy-only write cannot revive a stale autoSync from memory.
if (preferencesFromMemory) {
const storedPreferences = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as
| StoredPrefs
| null
| undefined;
const storedConfig = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as
| StoredConfig
| null
| undefined;
const nextPreferences = resolveSyncPreferencesForPersist({
memory: memoryPreferences,
stored: coalesceStoredSyncPreferences(storedPreferences, storedConfig),
preferencesFromMemory: true,
memoryKeys,
});
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_PREFERENCES, nextPreferences);
return;
}
const storedPreferences = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as
| StoredPrefs
| null
| undefined;
const storedConfig = this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as
| StoredConfig
| null
| undefined;
const hasSeparatePreferences = Boolean(
storedPreferences && typeof storedPreferences === 'object',
);
const nextVersions = resolveSyncVersionsForPersist({
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
remoteVersion: this.state.remoteVersion,
remoteUpdatedAt: this.state.remoteUpdatedAt,
});
// Version-only saves never write SYNC_PREFERENCES. The dedicated key is
// created only by preference writers (setAutoSync / setSyncStrategy).
// A check-then-write migrate here can overwrite a concurrent
// autoSync=false from another window (#2976).
if (hasSeparatePreferences || !hasSyncPreferenceFields(storedConfig)) {
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_CONFIG, nextVersions);
} else {
// Keep legacy preference fields in SYNC_CONFIG until a preference
// writer splits them out. Take those fields from storage, never
// from this window's possibly stale memory.
const preservedPreferences = resolveSyncPreferencesForPersist({
memory: memoryPreferences,
stored: coalesceStoredSyncPreferences(null, storedConfig),
preferencesFromMemory: false,
});
this.saveToStorage(SYNC_STORAGE_KEYS.SYNC_CONFIG, {
...preservedPreferences,
...nextVersions,
});
}
// Re-read preferences after the version write so a toggle that landed
// during the version persist window is adopted into this process.
const latestPreferences = resolveSyncPreferencesForPersist({
memory: memoryPreferences,
stored: coalesceStoredSyncPreferences(
this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_PREFERENCES) as StoredPrefs | null | undefined,
this.loadFromStorage?.(SYNC_STORAGE_KEYS.SYNC_CONFIG) as StoredConfig | null | undefined,
),
preferencesFromMemory: false,
});
const shouldNotifyPreferenceAdopt = adoptPreferences(latestPreferences);
if (shouldNotifyPreferenceAdopt) {
this.notifyStateChange?.();
}
}
export function syncBaseKeyImpl(this: any,provider?: CloudProvider): string {
const suffix = provider ? `_${provider}` : '';
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
}
export function providerAccountIdKeyImpl(this: any,provider: CloudProvider): string {
return `netcatty.sync.accountId.${provider}`;
}
export function loadProviderAccountIdImpl(this: any,provider: CloudProvider): string | null {
const stored = this.loadFromStorage(this.providerAccountIdKey(provider));
return typeof stored === 'string' ? stored : null;
}
export function saveProviderAccountIdImpl(this: any,provider: CloudProvider, id: string): void {
this.saveToStorage(this.providerAccountIdKey(provider), id);
}
export async function saveSyncBaseImpl(this: any,payload: SyncPayload, provider?: CloudProvider): Promise<void> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) {
throw new Error('Sync base encryption key is unavailable');
}
try {
try {
await rememberCurrentSyncBaseSnapshot.call(this, provider);
} catch (snapshotError) {
console.warn('[CloudSyncManager] Failed to save previous sync snapshot', snapshotError);
}
if (
this.saveToStorage(
this.syncBaseKey(provider),
await encryptLocalStorageValue(payload, key),
) === false
) {
throw new Error('Unable to persist sync base');
}
} catch (error) {
console.warn('[CloudSyncManager] Failed to save sync base', error);
throw error;
}
}
export async function loadSyncBaseImpl(this: any,provider?: CloudProvider): Promise<SyncPayload | null> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) return null;
try {
const encoded = this.loadFromStorage(this.syncBaseKey(provider)) as unknown;
if (!encoded || typeof encoded !== 'string') return null;
return decryptLocalStorageValue<SyncPayload>(encoded, key);
} catch {
return null;
}
}
export function syncSnapshotsKeyImpl(this: any,provider?: CloudProvider): string {
const suffix = provider ? `_${provider}` : '';
return `${SYNC_SNAPSHOTS_STORAGE_KEY}${suffix}`;
}
export async function loadSyncSnapshotsImpl(this: any,provider?: CloudProvider): Promise<SyncSnapshotEntry[]> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) return [];
try {
const encoded = this.loadFromStorage(this.syncSnapshotsKey(provider)) as unknown;
if (!encoded || typeof encoded !== 'string') return [];
const snapshots = await decryptLocalStorageValue<SyncSnapshotEntry[]>(encoded, key);
return Array.isArray(snapshots) ? snapshots : [];
} catch {
return [];
}
}
export async function saveSyncSnapshotsImpl(this: any,snapshots: SyncSnapshotEntry[], provider?: CloudProvider): Promise<void> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) {
throw new Error('Sync snapshot encryption key is unavailable');
}
if (this.saveToStorage(
this.syncSnapshotsKey(provider),
await encryptLocalStorageValue(snapshots.slice(0, SYNC_SNAPSHOT_LIMIT), key),
) === false) throw new Error('Unable to persist sync snapshots');
}
export function clearSyncBaseImpl(this: any): void {
this.removeFromStorage(SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD);
if (typeof this.syncSnapshotsKey === 'function') {
this.removeFromStorage(this.syncSnapshotsKey());
}
const providers = new Set<CloudProvider>([
'github', 'google', 'onedrive', 'webdav', 's3',
]);
for (const id of Object.keys(this.state?.providers ?? {})) {
providers.add(id as CloudProvider);
}
if (typeof this.listRegisteredPluginProviderIds === 'function') {
for (const id of this.listRegisteredPluginProviderIds()) {
providers.add(id as CloudProvider);
}
}
for (const p of providers) {
this.removeFromStorage(this.syncBaseKey(p));
this.removeFromStorage(this.convergentProviderBaselineKey(p));
if (typeof this.syncSnapshotsKey === 'function') {
this.removeFromStorage(this.syncSnapshotsKey(p));
}
}
this.clearSyncAnchor();
}
export function addSyncHistoryEntryImpl(this: any,entry: Omit<SyncHistoryEntry, 'id'>): void {
const newEntry: SyncHistoryEntry = {
...entry,
id: crypto.randomUUID(),
};
// Keep only the last 50 entries
this.state.syncHistory = [newEntry, ...this.state.syncHistory].slice(0, 50);
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, this.state.syncHistory);
this.notifyStateChange(); // Notify UI of new history entry
}
export function resetLocalVersionImpl(this: any): void {
this.state.localVersion = 0;
this.state.localUpdatedAt = 0;
this.state.syncHistory = [];
this.saveSyncConfig();
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, []);
this.clearSyncBase();
this.clearSyncAnchor();
this.notifyStateChange();
}