mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
286 lines
9.7 KiB
TypeScript
286 lines
9.7 KiB
TypeScript
/**
|
|
* 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 {
|
|
findSyncPayloadEncryptedCredentialPaths,
|
|
} from '../../domain/credentials';
|
|
import type { SyncPayload } from '../../domain/sync';
|
|
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
|
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
|
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<NodeJS.Timeout | null>(null);
|
|
const lastSyncedDataRef = useRef<string>('');
|
|
const hasCheckedRemoteRef = useRef(false);
|
|
const isInitializedRef = useRef(false);
|
|
|
|
// Build sync payload
|
|
const buildPayload = useCallback((): SyncPayload => {
|
|
// If port-forwarding hook state is still [] (async init in progress),
|
|
// fall back to localStorage to avoid uploading an empty array that
|
|
// overwrites the cloud snapshot.
|
|
let effectivePFRules = config.portForwardingRules;
|
|
if (!effectivePFRules || effectivePFRules.length === 0) {
|
|
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
|
STORAGE_KEY_PORT_FORWARDING,
|
|
);
|
|
if (stored && Array.isArray(stored) && stored.length > 0) {
|
|
effectivePFRules = stored.map((rule) => ({
|
|
...rule,
|
|
status: 'inactive' as const,
|
|
error: undefined,
|
|
lastUsedAt: undefined,
|
|
}));
|
|
}
|
|
}
|
|
return {
|
|
hosts: config.hosts,
|
|
keys: config.keys,
|
|
identities: config.identities,
|
|
snippets: config.snippets,
|
|
customGroups: config.customGroups,
|
|
portForwardingRules: effectivePFRules,
|
|
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(() => {
|
|
// Same fallback as buildPayload
|
|
let effectivePFRules = config.portForwardingRules;
|
|
if (!effectivePFRules || effectivePFRules.length === 0) {
|
|
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
|
STORAGE_KEY_PORT_FORWARDING,
|
|
);
|
|
if (stored && Array.isArray(stored) && stored.length > 0) {
|
|
effectivePFRules = stored.map((rule) => ({
|
|
...rule,
|
|
status: 'inactive' as const,
|
|
error: undefined,
|
|
lastUsedAt: undefined,
|
|
}));
|
|
}
|
|
}
|
|
const data = {
|
|
hosts: config.hosts,
|
|
keys: config.keys,
|
|
identities: config.identities,
|
|
snippets: config.snippets,
|
|
customGroups: config.customGroups,
|
|
portForwardingRules: effectivePFRules,
|
|
knownHosts: config.knownHosts,
|
|
};
|
|
return JSON.stringify(data);
|
|
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
|
|
|
|
// 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 encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
|
if (encryptedCredentialPaths.length > 0) {
|
|
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
|
|
throw new Error(t('sync.credentialsUnavailable'));
|
|
}
|
|
|
|
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;
|