mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
4652e78e6a
Introduces encrypted cloud sync via WebDAV and S3-compatible storage, including UI for configuration, validation, and connection management. Enables selection as active sync providers alongside existing options. Updates i18n, state, and domain types to accommodate new providers, unifying connection logic and improving extensibility for future sync backends.
241 lines
7.8 KiB
TypeScript
241 lines
7.8 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 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<NodeJS.Timeout | null>(null);
|
|
const lastSyncedDataRef = useRef<string>('');
|
|
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;
|