/** * useAutoSync - Auto-sync Hook for Cloud Sync * * Provides automatic sync capabilities: * - Sync when data changes (hosts, keys, snippets, port forwarding rules) * - Check remote version on app startup * - Debounced sync to avoid too frequent API calls */ import { useCallback, useEffect, useRef } from 'react'; import { useCloudSync } from './useCloudSync'; import { useI18n } from '../i18n/I18nProvider'; import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager'; import { netcattyBridge } from '../../infrastructure/services/netcattyBridge'; import type { SyncPayload } from '../../domain/sync'; import { toast } from '../../components/ui/toast'; interface AutoSyncConfig { // Data to sync hosts: SyncPayload['hosts']; keys: SyncPayload['keys']; identities?: SyncPayload['identities']; snippets: SyncPayload['snippets']; customGroups: SyncPayload['customGroups']; portForwardingRules?: SyncPayload['portForwardingRules']; knownHosts?: SyncPayload['knownHosts']; // Callbacks onApplyPayload: (payload: SyncPayload) => void; } // Get manager singleton for direct state access const manager = getCloudSyncManager(); type SyncTrigger = 'auto' | 'manual'; interface SyncNowOptions { trigger?: SyncTrigger; } export const useAutoSync = (config: AutoSyncConfig) => { const { t } = useI18n(); const sync = useCloudSync(); const syncTimeoutRef = useRef(null); const lastSyncedDataRef = useRef(''); const hasCheckedRemoteRef = useRef(false); const isInitializedRef = useRef(false); // Build sync payload const buildPayload = useCallback((): SyncPayload => { return { hosts: config.hosts, keys: config.keys, identities: config.identities, snippets: config.snippets, customGroups: config.customGroups, portForwardingRules: config.portForwardingRules, knownHosts: config.knownHosts, syncedAt: Date.now(), }; }, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]); // Create a hash of current data for comparison const getDataHash = useCallback(() => { const data = { hosts: config.hosts, keys: config.keys, identities: config.identities, snippets: config.snippets, portForwardingRules: config.portForwardingRules, }; return JSON.stringify(data); }, [config.hosts, config.keys, config.identities, config.snippets, config.portForwardingRules]); // Sync now handler - get fresh state directly from manager const syncNow = useCallback(async (options?: SyncNowOptions) => { const trigger: SyncTrigger = options?.trigger ?? 'auto'; try { // Get fresh state directly from CloudSyncManager singleton let state = manager.getState(); const hasProvider = Object.values(state.providers).some(p => p.status === 'connected'); const syncing = state.syncState === 'SYNCING'; if (!hasProvider) { throw new Error(t('sync.autoSync.noProvider')); } if (syncing) { throw new Error(t('sync.autoSync.alreadySyncing')); } // If another window unlocked, reuse the in-memory session password from main process. if (state.securityState !== 'UNLOCKED') { const bridge = netcattyBridge.get(); const sessionPassword = await bridge?.cloudSyncGetSessionPassword?.(); if (sessionPassword) { const ok = await sync.unlock(sessionPassword); if (!ok) { void bridge?.cloudSyncClearSessionPassword?.(); } } } // Re-check after unlock attempt state = manager.getState(); if (state.securityState !== 'UNLOCKED') { throw new Error(t('sync.autoSync.vaultLocked')); } const payload = buildPayload(); const results = await sync.syncNow(payload); for (const result of results.values()) { if (!result.success) { if (result.conflictDetected) { throw new Error(t('sync.autoSync.conflictDetected')); } throw new Error(result.error || t('sync.autoSync.syncFailed')); } } lastSyncedDataRef.current = getDataHash(); } catch (error) { if (trigger === 'manual') { throw error; } console.error('[AutoSync] Sync failed:', error); toast.error( error instanceof Error ? error.message : t('common.unknownError'), t('sync.autoSync.failedTitle'), ); } }, [sync, buildPayload, getDataHash, t]); // Check remote version and pull if newer (on startup) const checkRemoteVersion = useCallback(async () => { const state = manager.getState(); const hasProvider = Object.values(state.providers).some(p => p.status === 'connected'); const unlocked = state.securityState === 'UNLOCKED'; if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) { return; } hasCheckedRemoteRef.current = true; // Find connected provider const connectedProvider = state.providers.github.status === 'connected' ? 'github' : state.providers.google.status === 'connected' ? 'google' : state.providers.onedrive.status === 'connected' ? 'onedrive' : state.providers.webdav.status === 'connected' ? 'webdav' : state.providers.s3.status === 'connected' ? 's3' : null; if (!connectedProvider) return; try { console.log('[AutoSync] Checking remote version...'); const remotePayload = await sync.downloadFromProvider(connectedProvider); if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) { console.log('[AutoSync] Remote is newer, applying...'); config.onApplyPayload(remotePayload); toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle')); } } catch (error) { console.error('[AutoSync] Failed to check remote version:', error); // Don't show error toast for initial check - it's not critical } }, [sync, config, t]); // Debounced auto-sync when data changes useEffect(() => { // Skip if not ready if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) { return; } // Skip initial render if (!isInitializedRef.current) { isInitializedRef.current = true; lastSyncedDataRef.current = getDataHash(); return; } const currentHash = getDataHash(); // Skip if data hasn't changed if (currentHash === lastSyncedDataRef.current) { return; } // Clear existing timeout if (syncTimeoutRef.current) { clearTimeout(syncTimeoutRef.current); } // Debounce sync by 3 seconds syncTimeoutRef.current = setTimeout(() => { console.log('[AutoSync] Data changed, syncing...'); syncNow(); }, 3000); return () => { if (syncTimeoutRef.current) { clearTimeout(syncTimeoutRef.current); } }; }, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, getDataHash, syncNow]); // Check remote version on startup/unlock useEffect(() => { if (sync.hasAnyConnectedProvider && sync.isUnlocked && !hasCheckedRemoteRef.current) { // Delay check to ensure everything is loaded const timer = setTimeout(() => { checkRemoteVersion(); }, 1000); return () => clearTimeout(timer); } }, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]); // Reset check flag when provider disconnects useEffect(() => { if (!sync.hasAnyConnectedProvider) { hasCheckedRemoteRef.current = false; } }, [sync.hasAnyConnectedProvider]); return { syncNow, buildPayload, isSyncing: sync.isSyncing, isConnected: sync.hasAnyConnectedProvider, autoSyncEnabled: sync.autoSyncEnabled, }; }; export default useAutoSync;