[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
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:
978
application/state/useCloudSync.ts
Normal file
978
application/state/useCloudSync.ts
Normal file
@@ -0,0 +1,978 @@
|
||||
/**
|
||||
* useCloudSync - React Hook for Cloud Sync State Management
|
||||
*
|
||||
* Provides a complete React interface to the CloudSyncManager.
|
||||
* Handles security state machine, provider connections, and sync operations.
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
type SecurityState,
|
||||
type SyncState,
|
||||
type ProviderConnection,
|
||||
type ConflictInfo,
|
||||
type ConflictResolution,
|
||||
type RemoteSyncPayload,
|
||||
type SyncedFile,
|
||||
type SyncPayload,
|
||||
type SyncResult,
|
||||
type SyncHistoryEntry,
|
||||
type ConvergentFieldConflict,
|
||||
type WebDAVConfig,
|
||||
type S3Config,
|
||||
formatLastSync,
|
||||
getSyncDotColor,
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import type { CloudSyncStrategy } from '../../domain/syncStrategy';
|
||||
import type { CloudSyncConflictAction } from '../../domain/syncStrategy';
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
type SyncEventCallback,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
|
||||
import {
|
||||
getConvergentSyncLocalConfig,
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
pauseConvergentSync,
|
||||
refreshConvergentSyncLocalConfigSnapshot,
|
||||
setConvergentSyncLocalConfig,
|
||||
subscribeConvergentSyncLocalConfig,
|
||||
type ConvergentSyncLocalConfig,
|
||||
} from '../../infrastructure/services/convergentSyncConfig';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type ConvergentPayloadApplier = (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
interface CloudSyncRunOptions {
|
||||
overrideShrink?: boolean;
|
||||
conflictActionOverride?: CloudSyncConflictAction;
|
||||
applyConvergentPayload?: ConvergentPayloadApplier;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CloudSyncHook {
|
||||
// State
|
||||
securityState: SecurityState;
|
||||
syncState: SyncState;
|
||||
isUnlocked: boolean;
|
||||
isSyncing: boolean;
|
||||
providers: Record<CloudProvider, ProviderConnection>;
|
||||
currentConflict: ConflictInfo | null;
|
||||
lastError: string | null;
|
||||
deviceName: string;
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncInterval: number;
|
||||
syncStrategy: CloudSyncStrategy;
|
||||
localVersion: number;
|
||||
localUpdatedAt: number;
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
syncHistory: SyncHistoryEntry[];
|
||||
pendingLocalSync: boolean;
|
||||
convergentConflicts: ConvergentFieldConflict[];
|
||||
convergentSyncConfig: ConvergentSyncLocalConfig;
|
||||
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
|
||||
|
||||
// Computed
|
||||
hasAnyConnectedProvider: boolean;
|
||||
connectedProviderCount: number;
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
|
||||
|
||||
// Master Key Actions
|
||||
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
|
||||
unlock: (password: string) => Promise<boolean>;
|
||||
lock: () => void;
|
||||
changeMasterKey: (oldPassword: string, newPassword: string) => Promise<boolean>;
|
||||
verifyPassword: (password: string) => Promise<boolean>;
|
||||
|
||||
// Provider Actions
|
||||
connectGitHub: () => Promise<DeviceFlowState>;
|
||||
completeGitHubAuth: (
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
expiresAt: number,
|
||||
onPending?: () => void,
|
||||
signal?: AbortSignal,
|
||||
authAttemptId?: number
|
||||
) => Promise<void>;
|
||||
connectGoogle: () => Promise<string>;
|
||||
/** Connect a namespaced plugin sync Provider (encrypted-object storage only). */
|
||||
connectPluginProvider: (
|
||||
providerId: string,
|
||||
configuration?: unknown,
|
||||
credential?: unknown,
|
||||
) => Promise<void>;
|
||||
connectOneDrive: () => Promise<string>;
|
||||
connectWebDAV: (config: WebDAVConfig) => Promise<void>;
|
||||
connectS3: (config: S3Config) => Promise<void>;
|
||||
completePKCEAuth: (
|
||||
provider: 'google' | 'onedrive',
|
||||
code: string,
|
||||
redirectUri: string
|
||||
) => Promise<void>;
|
||||
cancelOAuthConnect: () => void;
|
||||
disconnectProvider: (provider: CloudProvider) => Promise<void>;
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload, opts?: CloudSyncRunOptions) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
|
||||
) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
|
||||
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<RemoteSyncPayload | null>;
|
||||
resolveConvergentConflict: (
|
||||
addressKey: string,
|
||||
candidateDot: string,
|
||||
applyPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
) => Promise<{ payload: SyncPayload; results: Map<CloudProvider, SyncResult> }>;
|
||||
downgradeConvergentSync: (
|
||||
confirmed: boolean,
|
||||
buildLocalPayload: () => SyncPayload | Promise<SyncPayload>,
|
||||
applyPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
|
||||
// Gist Revision History
|
||||
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
|
||||
downloadGistRevision: (sha: string) => Promise<{
|
||||
payload: SyncPayload;
|
||||
meta: import('../../domain/sync').SyncFileMeta;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
noteCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
} | null>;
|
||||
|
||||
// Settings
|
||||
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
|
||||
setDeviceName: (name: string) => void;
|
||||
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
|
||||
setConvergentSyncEnabled: (enabled: boolean) => ConvergentSyncLocalConfig;
|
||||
refreshConvergentSyncConfig: () => ConvergentSyncLocalConfig;
|
||||
|
||||
// Local Data Reset
|
||||
resetLocalVersion: () => void;
|
||||
|
||||
// Utilities
|
||||
formatLastSync: (timestamp?: number) => string;
|
||||
getProviderDotColor: (provider: CloudProvider) => string;
|
||||
refresh: () => void;
|
||||
|
||||
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
|
||||
subscribeToEvents: (callback: SyncEventCallback) => () => void;
|
||||
|
||||
// Shrink-block state query (for banner hydration on mount)
|
||||
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
|
||||
}
|
||||
|
||||
type PendingBrowserAuthState = {
|
||||
provider: 'google' | 'onedrive';
|
||||
sessionId: string;
|
||||
authAttemptId?: number;
|
||||
} | null;
|
||||
|
||||
let pendingBrowserAuthState: PendingBrowserAuthState = null;
|
||||
const pendingBrowserAuthListeners = new Set<() => void>();
|
||||
let activeOAuthBrowserHandoff:
|
||||
| { sessionId: string; cancel: () => void }
|
||||
| null = null;
|
||||
const cancelledOAuthSessionIds = new Set<string>();
|
||||
|
||||
const getPendingBrowserAuthState = (): PendingBrowserAuthState => pendingBrowserAuthState;
|
||||
|
||||
const subscribePendingBrowserAuthState = (callback: () => void) => {
|
||||
pendingBrowserAuthListeners.add(callback);
|
||||
return () => pendingBrowserAuthListeners.delete(callback);
|
||||
};
|
||||
|
||||
const setPendingBrowserAuthState = (next: PendingBrowserAuthState) => {
|
||||
pendingBrowserAuthState = next;
|
||||
pendingBrowserAuthListeners.forEach((callback) => callback());
|
||||
};
|
||||
|
||||
const clearPendingBrowserAuthState = (
|
||||
match?: { provider: 'google' | 'onedrive'; sessionId: string; authAttemptId?: number }
|
||||
) => {
|
||||
if (!match) {
|
||||
setPendingBrowserAuthState(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pendingBrowserAuthState &&
|
||||
pendingBrowserAuthState.provider === match.provider &&
|
||||
pendingBrowserAuthState.sessionId === match.sessionId
|
||||
) {
|
||||
setPendingBrowserAuthState(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
|
||||
// Singleton manager instance
|
||||
const manager = getCloudSyncManager();
|
||||
|
||||
// Subscribe function for useSyncExternalStore
|
||||
const subscribe = (callback: () => void) => {
|
||||
return manager.subscribeToStateChanges(callback);
|
||||
};
|
||||
|
||||
// Get snapshot function for useSyncExternalStore
|
||||
const getSnapshot = (): SyncManagerState => {
|
||||
return manager.getState();
|
||||
};
|
||||
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const pendingBrowserAuth = useSyncExternalStore(
|
||||
subscribePendingBrowserAuthState,
|
||||
getPendingBrowserAuthState,
|
||||
getPendingBrowserAuthState
|
||||
);
|
||||
const activeOAuthSessionIdRef = useRef<string | null>(null);
|
||||
const activeOAuthProviderRef = useRef<'google' | 'onedrive' | null>(null);
|
||||
const activeGitHubAuthAbortRef = useRef<AbortController | null>(null);
|
||||
const activeGitHubAuthAttemptIdRef = useRef<number | null>(null);
|
||||
const convergentSyncConfig = useSyncExternalStore(
|
||||
subscribeConvergentSyncLocalConfig,
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
);
|
||||
|
||||
useCloudSyncAutoUnlock({
|
||||
securityState: state.securityState,
|
||||
masterKeyIdentity: state.masterKeyConfig
|
||||
? JSON.stringify(state.masterKeyConfig)
|
||||
: null,
|
||||
manager,
|
||||
bridge: netcattyBridge.get(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.securityState !== 'UNLOCKED') return;
|
||||
if (!getConvergentSyncLocalConfig().initialized) return;
|
||||
void manager.refreshConvergentConflicts().catch(() => {
|
||||
// A missing/corrupt replica is surfaced by the next explicit sync.
|
||||
});
|
||||
}, [state.securityState]);
|
||||
|
||||
// Keep plugin sync provider availability aligned with live contributions so
|
||||
// disabled/uninstalled providers leave the auto-sync ready set after restart.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refreshAvailable = async () => {
|
||||
try {
|
||||
const { pluginExtensionBridge } = await import('./pluginExtensionBridge');
|
||||
const listed = await pluginExtensionBridge.listProviders('sync');
|
||||
if (cancelled) return;
|
||||
const ids = (listed ?? []).map((entry) => {
|
||||
const nested = (entry as { provider?: { id?: string } }).provider;
|
||||
return typeof nested?.id === 'string' ? nested.id : '';
|
||||
}).filter((id) => id.length > 0);
|
||||
manager.setAvailablePluginSyncProviders(ids);
|
||||
} catch {
|
||||
// Transient IPC / host startup failures must not wipe the availability
|
||||
// catalog; leave the previous set until a successful discovery.
|
||||
}
|
||||
};
|
||||
void refreshAvailable();
|
||||
let unsubscribe = () => {};
|
||||
void import('./pluginExtensionBridge').then(({ pluginExtensionBridge }) => {
|
||||
if (cancelled) return;
|
||||
unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
|
||||
void refreshAvailable();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ========== Computed Values ==========
|
||||
|
||||
const hasAnyConnectedProvider = useMemo(() => {
|
||||
return (Object.values(state.providers) as ProviderConnection[]).some(
|
||||
(p) => isProviderReadyForSync(p)
|
||||
);
|
||||
}, [state.providers]);
|
||||
|
||||
const connectedProviderCount = useMemo(() => {
|
||||
return (Object.values(state.providers) as ProviderConnection[]).filter(
|
||||
(p) => isProviderReadyForSync(p)
|
||||
).length;
|
||||
}, [state.providers]);
|
||||
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
|
||||
if (state.syncState === 'BLOCKED') return 'blocked';
|
||||
if (state.syncState === 'CONFLICT') return 'conflict';
|
||||
if (state.syncState === 'ERROR') return 'error';
|
||||
if (state.syncState === 'SYNCING') return 'syncing';
|
||||
|
||||
const statuses = (Object.values(state.providers) as ProviderConnection[]).map(p => p.status);
|
||||
if (statuses.some(s => s === 'syncing')) return 'syncing';
|
||||
if (statuses.some(s => s === 'error')) return 'error';
|
||||
if (statuses.some(s => s === 'connected')) return 'synced';
|
||||
|
||||
return 'none';
|
||||
}, [state.syncState, state.providers]);
|
||||
|
||||
// ========== Master Key Actions ==========
|
||||
// Note: No need for setState calls - useSyncExternalStore automatically updates
|
||||
// when manager emits events and calls notifyStateChange()
|
||||
|
||||
const setupMasterKey = useCallback(async (password: string, confirmPassword: string) => {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
if (password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters');
|
||||
}
|
||||
await manager.setupMasterKey(password);
|
||||
void netcattyBridge.get()?.cloudSyncSetSessionPassword?.(password);
|
||||
}, []);
|
||||
|
||||
const unlock = useCallback(async (password: string): Promise<boolean> => {
|
||||
const ok = await manager.unlock(password);
|
||||
if (ok) {
|
||||
void netcattyBridge.get()?.cloudSyncSetSessionPassword?.(password);
|
||||
}
|
||||
return ok;
|
||||
}, []);
|
||||
|
||||
const lock = useCallback(() => {
|
||||
void netcattyBridge.get()?.cloudSyncClearSessionPassword?.();
|
||||
manager.lock();
|
||||
}, []);
|
||||
|
||||
const changeMasterKey = useCallback(async (
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
): Promise<boolean> => {
|
||||
const ok = await manager.changeMasterKey(oldPassword, newPassword);
|
||||
if (ok) {
|
||||
void netcattyBridge.get()?.cloudSyncSetSessionPassword?.(newPassword);
|
||||
}
|
||||
return ok;
|
||||
}, []);
|
||||
|
||||
const verifyPassword = useCallback(async (password: string): Promise<boolean> => {
|
||||
return manager.verifyPassword(password);
|
||||
}, []);
|
||||
|
||||
// ========== Provider Actions ==========
|
||||
|
||||
const connectGitHub = useCallback(async (): Promise<DeviceFlowState> => {
|
||||
const result = await manager.startProviderAuth('github');
|
||||
if (result.type !== 'device_code') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
activeGitHubAuthAttemptIdRef.current = result.data.authAttemptId ?? null;
|
||||
return result.data;
|
||||
}, []);
|
||||
|
||||
const completeGitHubAuth = useCallback(async (
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
expiresAt: number,
|
||||
onPending?: () => void,
|
||||
signal?: AbortSignal,
|
||||
authAttemptId?: number
|
||||
): Promise<void> => {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
} else if (signal) {
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
}
|
||||
|
||||
activeGitHubAuthAbortRef.current = controller;
|
||||
|
||||
try {
|
||||
await manager.completeGitHubAuth(
|
||||
deviceCode,
|
||||
interval,
|
||||
expiresAt,
|
||||
onPending,
|
||||
controller.signal,
|
||||
authAttemptId
|
||||
);
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
if (activeGitHubAuthAbortRef.current === controller) {
|
||||
activeGitHubAuthAbortRef.current = null;
|
||||
}
|
||||
if (activeGitHubAuthAttemptIdRef.current === (authAttemptId ?? null)) {
|
||||
activeGitHubAuthAttemptIdRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelActivePKCEAuth = useCallback(async () => {
|
||||
const pending = getPendingBrowserAuthState();
|
||||
const sessionId = pending?.sessionId ?? activeOAuthSessionIdRef.current;
|
||||
const provider = pending?.provider ?? activeOAuthProviderRef.current;
|
||||
const authAttemptId = pending?.authAttemptId;
|
||||
if (!sessionId || !provider) return;
|
||||
|
||||
cancelledOAuthSessionIds.add(sessionId);
|
||||
if (activeOAuthBrowserHandoff?.sessionId === sessionId) {
|
||||
activeOAuthBrowserHandoff.cancel();
|
||||
activeOAuthBrowserHandoff = null;
|
||||
}
|
||||
manager.cancelProviderAuthAttempt(provider, authAttemptId);
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
clearPendingBrowserAuthState(
|
||||
pending
|
||||
? {
|
||||
provider: pending.provider,
|
||||
sessionId: pending.sessionId,
|
||||
authAttemptId: pending.authAttemptId,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
try {
|
||||
await netcattyBridge.get()?.cancelOAuthCallback?.(sessionId);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runPKCEAuth = useCallback(
|
||||
async (provider: 'google' | 'onedrive'): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const prepare = bridge?.prepareOAuthCallback;
|
||||
const awaitCallback = bridge?.awaitOAuthCallback;
|
||||
const openExternal = bridge?.openExternal;
|
||||
if (!prepare || !awaitCallback || !openExternal) {
|
||||
throw new Error('OAuth bridge is unavailable');
|
||||
}
|
||||
|
||||
// Only one loopback OAuth flow can be active at a time. If the user
|
||||
// starts another provider while a previous browser hop is still pending,
|
||||
// cancel the stale one first so the new attempt owns the callback port.
|
||||
await cancelActivePKCEAuth();
|
||||
|
||||
// Bind the loopback callback server first so we know which port to put
|
||||
// in the provider's redirect_uri (#823: 45678 may be in use).
|
||||
const { redirectUri, sessionId } = await prepare();
|
||||
activeOAuthSessionIdRef.current = sessionId;
|
||||
activeOAuthProviderRef.current = provider;
|
||||
setPendingBrowserAuthState({ provider, sessionId });
|
||||
|
||||
try {
|
||||
const result = await manager.startProviderAuth(provider, redirectUri);
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
if (cancelledOAuthSessionIds.has(sessionId)) {
|
||||
throw new Error('OAuth flow cancelled');
|
||||
}
|
||||
|
||||
const adapter = manager.getAdapter(provider) as
|
||||
| { getPKCEState?: () => string | null }
|
||||
| undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
const callbackPromise = awaitCallback(expectedState, sessionId);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563).
|
||||
// Once the browser has opened, let the rest of the PKCE handshake
|
||||
// continue in the background so closing the browser later does not
|
||||
// leave the whole settings page locked waiting on a timeout.
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let browserOpened = false;
|
||||
let rejectBrowserPromise: ((error: Error) => void) | null = null;
|
||||
const browserPromise = new Promise<void>((resolve, reject) => {
|
||||
rejectBrowserPromise = reject;
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await openExternal(data.url);
|
||||
browserOpened = true;
|
||||
resolve();
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.(sessionId);
|
||||
reject(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error('Failed to open browser for authentication')
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
activeOAuthBrowserHandoff = {
|
||||
sessionId,
|
||||
cancel: () => {
|
||||
if (openTimer) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = null;
|
||||
}
|
||||
if (rejectBrowserPromise) {
|
||||
rejectBrowserPromise(new Error('OAuth flow cancelled'));
|
||||
rejectBrowserPromise = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
browserPromise,
|
||||
callbackPromise.then(
|
||||
() => {
|
||||
throw new Error('OAuth callback completed before browser handoff');
|
||||
},
|
||||
(error) => {
|
||||
if (browserOpened) {
|
||||
return new Promise<void>(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
if (activeOAuthBrowserHandoff?.sessionId === sessionId) {
|
||||
activeOAuthBrowserHandoff = null;
|
||||
}
|
||||
}
|
||||
setPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
|
||||
const completionPromise = (async () => {
|
||||
try {
|
||||
const { code } = await callbackPromise;
|
||||
await manager.completePKCEAuth(provider, code, data.redirectUri, data.authAttemptId);
|
||||
} catch (error) {
|
||||
const ownsActiveSession =
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const cancelledOrSuperseded =
|
||||
message.includes('cancelled') || message.includes('auth superseded');
|
||||
const timedOut = message.toLowerCase().includes('timeout');
|
||||
if (ownsActiveSession && (cancelledOrSuperseded || timedOut)) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
manager.resetProviderStatus(provider);
|
||||
} else if (ownsActiveSession) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
manager.setProviderError(provider, message);
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider
|
||||
) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
}
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Release the transient "connecting" UI once the browser handoff has
|
||||
// happened. The callback session remains active in the background and
|
||||
// will mark the provider connected when the redirect completes.
|
||||
// Do NOT use resetProviderStatus here — it would restore from the
|
||||
// auth snapshot and delete the adapter we just created, making the
|
||||
// eventual completePKCEAuth call fail with "adapter not initialized".
|
||||
manager.clearConnectingStatus(provider);
|
||||
manager.clearProviderError(provider);
|
||||
void completionPromise;
|
||||
return data.url;
|
||||
} catch (err) {
|
||||
const ownsActiveSession =
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider;
|
||||
try {
|
||||
await bridge?.cancelOAuthCallback?.(sessionId);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
if (ownsActiveSession) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
manager.cancelProviderAuthAttempt(provider);
|
||||
manager.resetProviderStatus(provider);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[cancelActivePKCEAuth]
|
||||
);
|
||||
|
||||
const connectGoogle = useCallback(async (): Promise<string> => {
|
||||
return runPKCEAuth('google');
|
||||
}, [runPKCEAuth]);
|
||||
|
||||
const connectOneDrive = useCallback(async (): Promise<string> => {
|
||||
return runPKCEAuth('onedrive');
|
||||
}, [runPKCEAuth]);
|
||||
|
||||
const completePKCEAuth = useCallback(async (
|
||||
provider: 'google' | 'onedrive',
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<void> => {
|
||||
await manager.completePKCEAuth(provider, code, redirectUri);
|
||||
}, []);
|
||||
|
||||
const disconnectProvider = useCallback(async (provider: CloudProvider): Promise<void> => {
|
||||
await manager.disconnectProvider(provider);
|
||||
}, []);
|
||||
|
||||
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
|
||||
manager.resetProviderStatus(provider);
|
||||
}, []);
|
||||
|
||||
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
|
||||
await manager.connectConfigProvider('webdav', config);
|
||||
}, []);
|
||||
|
||||
const connectS3 = useCallback(async (config: S3Config): Promise<void> => {
|
||||
await manager.connectConfigProvider('s3', config);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Connect a namespaced plugin sync Provider (encrypted-object storage only).
|
||||
* Configuration is opaque plugin-owned JSON; encryption stays host-owned.
|
||||
*/
|
||||
const connectPluginProvider = useCallback(async (
|
||||
providerId: string,
|
||||
configuration: unknown = {},
|
||||
credential?: unknown,
|
||||
): Promise<void> => {
|
||||
await manager.connectPluginProvider(providerId, configuration, credential);
|
||||
}, []);
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const githubAbort = activeGitHubAuthAbortRef.current;
|
||||
if (githubAbort) {
|
||||
manager.cancelProviderAuthAttempt('github', activeGitHubAuthAttemptIdRef.current ?? undefined);
|
||||
activeGitHubAuthAttemptIdRef.current = null;
|
||||
githubAbort.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
void cancelActivePKCEAuth();
|
||||
}, [cancelActivePKCEAuth]);
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
|
||||
manager.setAutoSync(enabled, intervalMinutes);
|
||||
}, []);
|
||||
|
||||
const setDeviceName = useCallback((name: string) => {
|
||||
manager.setDeviceName(name);
|
||||
}, []);
|
||||
|
||||
const setSyncStrategy = useCallback((strategy: CloudSyncStrategy) => {
|
||||
manager.setSyncStrategy(strategy);
|
||||
}, []);
|
||||
|
||||
// ========== Utilities ==========
|
||||
|
||||
const getProviderDotColor = useCallback((provider: CloudProvider): string => {
|
||||
return getSyncDotColor(state.providers[provider].status);
|
||||
}, [state.providers]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
// Force a re-render by triggering state change notification
|
||||
// This is now a no-op since useSyncExternalStore handles updates automatically
|
||||
}, []);
|
||||
|
||||
const ensureUnlocked = useCallback(async (): Promise<void> => {
|
||||
const current = manager.getState();
|
||||
if (current.securityState === 'UNLOCKED') return;
|
||||
if (current.securityState === 'NO_KEY') {
|
||||
throw new Error('No master key configured');
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
if (password) {
|
||||
const ok = await manager.unlock(password);
|
||||
if (ok) return;
|
||||
void bridge?.cloudSyncClearSessionPassword?.();
|
||||
}
|
||||
|
||||
throw new Error('Vault is locked');
|
||||
}, []);
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: CloudSyncRunOptions) => {
|
||||
await ensureUnlocked();
|
||||
const controller = new AbortController();
|
||||
let onAbort: (() => void) | undefined;
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) controller.abort();
|
||||
else {
|
||||
onAbort = () => controller.abort();
|
||||
opts.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const results = await manager.syncAllProviders(payload, { ...opts, signal: controller.signal });
|
||||
const { commitPluginSidecarsAfterSuccessfulSync } = await import('../pluginSyncSidecarBridge');
|
||||
commitPluginSidecarsAfterSuccessfulSync(payload, results.values());
|
||||
return results;
|
||||
} finally {
|
||||
if (opts?.signal && onAbort) opts.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const syncToProviderWithUnlock = useCallback(async (
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
const controller = new AbortController();
|
||||
let onAbort: (() => void) | undefined;
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) controller.abort();
|
||||
else {
|
||||
onAbort = () => controller.abort();
|
||||
opts.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await manager.syncToProvider(provider, payload, { ...opts, signal: controller.signal });
|
||||
const { commitPluginSidecarsAfterSuccessfulSync } = await import('../pluginSyncSidecarBridge');
|
||||
commitPluginSidecarsAfterSuccessfulSync(payload, [result]);
|
||||
return result;
|
||||
} finally {
|
||||
if (opts?.signal && onAbort) opts.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.downloadFromProvider(provider);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const commitRemoteInspectionWithUnlock = useCallback(async (
|
||||
provider: CloudProvider,
|
||||
remoteFile: SyncedFile,
|
||||
payload: SyncPayload,
|
||||
opts: { recordDownload?: boolean } = {},
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
await manager.commitRemoteInspection(provider, remoteFile, payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const subscribeToEvents = useCallback(
|
||||
(callback: SyncEventCallback) => manager.subscribe(callback),
|
||||
[],
|
||||
);
|
||||
|
||||
const getShrinkBlockedFinding = useCallback(
|
||||
() => manager.getShrinkBlockedFinding(),
|
||||
[],
|
||||
);
|
||||
|
||||
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.resolveConflict(resolution);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const resolveConvergentConflictWithUnlock = useCallback(async (
|
||||
addressKey: string,
|
||||
candidateDot: string,
|
||||
applyPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
// Collect live sidecars so conflict apply/upload does not revert plugin
|
||||
// settings that changed after the last provider baseline. Operational
|
||||
// collection failures must abort (not proceed as empty). Host gated off /
|
||||
// non-authoritative collect must omit the field (not last-known cache) so
|
||||
// apply preserves local DB sidecars.
|
||||
const {
|
||||
collectPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
} = await import('../pluginSyncSidecarBridge');
|
||||
let pluginSidecars: SyncPayload['pluginSidecars'] | undefined;
|
||||
try {
|
||||
const collected = await collectPluginSyncSidecarsFromHost({ liveOnly: true });
|
||||
if (collected) pluginSidecars = collected;
|
||||
} catch (error) {
|
||||
if (isPluginSidecarHostUnavailableError(error)) {
|
||||
// Host gated off: omit field so apply preserves local sidecars.
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload, {
|
||||
pluginSidecars,
|
||||
});
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const refreshConvergentSyncConfig = useCallback(() => {
|
||||
return refreshConvergentSyncLocalConfigSnapshot();
|
||||
}, []);
|
||||
|
||||
const setConvergentSyncEnabled = useCallback((enabled: boolean) => {
|
||||
const current = getConvergentSyncLocalConfig();
|
||||
const next = enabled
|
||||
? setConvergentSyncLocalConfig({ enabled: true, initialized: current.initialized })
|
||||
: pauseConvergentSync();
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const downgradeConvergentSyncWithUnlock = useCallback(async (
|
||||
confirmed: boolean,
|
||||
buildLocalPayload: () => SyncPayload | Promise<SyncPayload>,
|
||||
applyPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
return manager.downgradeConvergentSync(confirmed, buildLocalPayload, applyPayload);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
return {
|
||||
// State
|
||||
securityState: state.securityState,
|
||||
syncState: state.syncState,
|
||||
isUnlocked: state.securityState === 'UNLOCKED',
|
||||
isSyncing: state.syncState === 'SYNCING',
|
||||
providers: state.providers,
|
||||
currentConflict: state.currentConflict,
|
||||
lastError: state.lastError,
|
||||
deviceName: state.deviceName,
|
||||
autoSyncEnabled: state.autoSyncEnabled,
|
||||
autoSyncInterval: state.autoSyncInterval,
|
||||
syncStrategy: state.syncStrategy,
|
||||
localVersion: state.localVersion,
|
||||
localUpdatedAt: state.localUpdatedAt,
|
||||
remoteVersion: state.remoteVersion,
|
||||
remoteUpdatedAt: state.remoteUpdatedAt,
|
||||
syncHistory: state.syncHistory,
|
||||
pendingLocalSync: state.pendingLocalSync,
|
||||
convergentConflicts: state.convergentConflicts,
|
||||
convergentSyncConfig,
|
||||
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
|
||||
|
||||
// Computed
|
||||
hasAnyConnectedProvider,
|
||||
connectedProviderCount,
|
||||
overallSyncStatus,
|
||||
|
||||
// Master Key Actions
|
||||
setupMasterKey,
|
||||
unlock,
|
||||
lock,
|
||||
changeMasterKey,
|
||||
verifyPassword,
|
||||
|
||||
// Provider Actions
|
||||
connectGitHub,
|
||||
completeGitHubAuth,
|
||||
connectGoogle,
|
||||
connectOneDrive,
|
||||
connectWebDAV,
|
||||
connectS3,
|
||||
connectPluginProvider,
|
||||
completePKCEAuth,
|
||||
cancelOAuthConnect,
|
||||
disconnectProvider,
|
||||
resetProviderStatus,
|
||||
|
||||
// Sync Actions
|
||||
syncNow: syncNowWithUnlock,
|
||||
syncToProvider: syncToProviderWithUnlock,
|
||||
downloadFromProvider: downloadFromProviderWithUnlock,
|
||||
commitRemoteInspection: commitRemoteInspectionWithUnlock,
|
||||
resolveConflict: resolveConflictWithUnlock,
|
||||
resolveConvergentConflict: resolveConvergentConflictWithUnlock,
|
||||
downgradeConvergentSync: downgradeConvergentSyncWithUnlock,
|
||||
|
||||
// Gist Revision History (#679)
|
||||
getGistRevisionHistory: manager.getGistRevisionHistory.bind(manager),
|
||||
downloadGistRevision: manager.downloadGistRevision.bind(manager),
|
||||
|
||||
// Settings
|
||||
setAutoSync,
|
||||
setDeviceName,
|
||||
setSyncStrategy,
|
||||
setConvergentSyncEnabled,
|
||||
refreshConvergentSyncConfig,
|
||||
|
||||
// Local Data Reset
|
||||
resetLocalVersion: () => manager.resetLocalVersion(),
|
||||
|
||||
// Utilities
|
||||
formatLastSync,
|
||||
getProviderDotColor,
|
||||
refresh,
|
||||
|
||||
// Event subscription
|
||||
subscribeToEvents,
|
||||
|
||||
// Shrink-block state query
|
||||
getShrinkBlockedFinding,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCloudSync;
|
||||
Reference in New Issue
Block a user