// Desktop LAN sync status + triggers. The engine itself lives in // src/services/desktopSync.ts; this store serializes runs (one at a time), // debounces the automatic triggers, and exposes last-synced state for the UI. // Module-scope timers (not component state) per the React Compiler rules. import { AppState } from 'react-native'; import { create } from 'zustand'; import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; import { DesktopSyncUnsupportedError, applyDesktopSyncConflictResolution, desktopSyncSettingKey, runDesktopSync, } from '@/services/desktopSync'; import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials'; import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient'; import { canStartDesktopSync, decideDesktopSyncEnabled } from '@/services/desktopSyncPolicy'; import type { DesktopSyncConflictResolution, DesktopSyncPlaylistConflict, DesktopSyncSummary, } from '@/types/desktopSync'; const AUTO_SYNC_DEBOUNCE_MS = 5_000; const AUTO_SYNC_MIN_INTERVAL_MS = 15 * 60_000; const DESKTOP_SYNC_ENABLED_SETTING_KEY = 'desktop_sync_enabled_v1'; const LEGACY_AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto'; export type DesktopSyncStatus = 'idle' | 'syncing' | 'error'; export type DesktopSyncAutoReason = 'discovery' | 'connected' | 'foreground'; interface DesktopSyncStore { hydrated: boolean; status: DesktopSyncStatus; /** Wall-clock ms of the last successful sync with the paired desktop. */ lastSyncAt: number | null; lastSummary: DesktopSyncSummary | null; /** Playlists the last run refused to resolve automatically. */ conflicts: DesktopSyncPlaylistConflict[]; /** True while a NEW conflict awaits its once-per-session popup * (SyncConflictPrompt); set by syncNow, cleared on dismiss/resolve. */ conflictPromptVisible: boolean; errorMessage: string; /** False once the paired desktop reported a pre-sync protocol version. */ supported: boolean; /** Master gate for every desktop library-sync trigger. Remote playback is independent. */ desktopSyncEnabled: boolean; hydrate: () => Promise; syncNow: () => Promise; dismissConflictPrompt: () => void; setDesktopSyncEnabled: (enabled: boolean) => Promise; resolveConflict: ( conflict: DesktopSyncPlaylistConflict, resolution: DesktopSyncConflictResolution ) => Promise; maybeAutoSync: (reason: DesktopSyncAutoReason) => void; /** Desktop clicked Sync Now (or resolved a conflict there) — sync promptly, * bypassing the auto-sync interval limits. */ handleSyncRequest: () => void; } let autoSyncDebounceTimer: ReturnType | null = null; // One popup per conflict per app session — dismissing must not re-nag on the // next sync run re-detecting the same conflicts. const promptedConflictUids = new Set(); export const useDesktopSyncStore = create((set, get) => ({ hydrated: false, status: 'idle', lastSyncAt: null, lastSummary: null, conflicts: [], conflictPromptVisible: false, errorMessage: '', supported: true, desktopSyncEnabled: false, hydrate: async () => { try { const masterSetting = await getNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY); const legacySetting = await getNativeSetting(LEGACY_AUTO_SYNC_SETTING_KEY); const enabled = decideDesktopSyncEnabled(masterSetting, legacySetting); set({ desktopSyncEnabled: enabled }); if (masterSetting === null) { await setNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0'); } const connection = await getDesktopRemoteConnection(); if (connection) { const stored = await getNativeSetting(desktopSyncSettingKey(connection)); const lastSyncAt = stored ? Number(stored) : NaN; if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) { set({ lastSyncAt }); } } } catch { // Hydration is best-effort; the first sync will set lastSyncAt. } finally { set({ hydrated: true }); } }, setDesktopSyncEnabled: async (enabled) => { if (!enabled && autoSyncDebounceTimer !== null) { clearTimeout(autoSyncDebounceTimer); autoSyncDebounceTimer = null; } set({ desktopSyncEnabled: enabled }); try { await setNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0'); } catch { // The in-memory value still applies for this session. } }, syncNow: async () => { if (!canStartDesktopSync(get().desktopSyncEnabled, get().status)) return; set({ status: 'syncing', errorMessage: '' }); try { const summary = await runDesktopSync(); let conflictPromptVisible = get().conflictPromptVisible; if (summary.conflicts.length === 0) { promptedConflictUids.clear(); conflictPromptVisible = false; } else if (summary.conflicts.some((conflict) => !promptedConflictUids.has(conflict.syncUid))) { for (const conflict of summary.conflicts) { promptedConflictUids.add(conflict.syncUid); } conflictPromptVisible = true; } set({ status: 'idle', lastSyncAt: summary.finishedAt, lastSummary: summary, conflicts: summary.conflicts, conflictPromptVisible, supported: true, }); } catch (error) { set({ status: 'error', errorMessage: error instanceof Error && error.message.trim() ? error.message : 'Desktop sync failed.', supported: !(error instanceof DesktopSyncUnsupportedError), }); } }, resolveConflict: async (conflict, resolution) => { if (!canStartDesktopSync(get().desktopSyncEnabled, get().status)) return; try { await applyDesktopSyncConflictResolution(conflict, resolution); } catch (error) { set({ status: 'error', errorMessage: error instanceof Error && error.message.trim() ? error.message : 'Failed to resolve the sync conflict.', }); return; } // Drop it optimistically; the follow-up sync re-detects anything unsettled. set((state) => ({ conflicts: state.conflicts.filter((entry) => entry.syncUid !== conflict.syncUid), })); await get().syncNow(); }, dismissConflictPrompt: () => { set({ conflictPromptVisible: false }); }, handleSyncRequest: () => { const { status, desktopSyncEnabled } = get(); if (!canStartDesktopSync(desktopSyncEnabled, status)) return; void get().syncNow(); }, maybeAutoSync: (reason) => { const { status, lastSyncAt, supported, desktopSyncEnabled } = get(); if (!supported || !desktopSyncEnabled) return; if (status === 'syncing') return; if (AppState.currentState !== 'active') return; if (lastSyncAt !== null && Date.now() - lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return; // Discovery/connect events arrive in bursts — coalesce them. if (autoSyncDebounceTimer !== null) clearTimeout(autoSyncDebounceTimer); autoSyncDebounceTimer = setTimeout(() => { autoSyncDebounceTimer = null; void (async () => { const state = useDesktopSyncStore.getState(); if (!state.desktopSyncEnabled) return; if (state.status === 'syncing') return; if (AppState.currentState !== 'active') return; if (state.lastSyncAt !== null && Date.now() - state.lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return; // Automatic triggers are speculative — probe reachability first so an // off-LAN desktop doesn't surface an error banner from an attempt the // user never asked for. const connection = await getDesktopRemoteConnection(); if (!connection) return; const identity = await fetchDesktopRemoteIdentity( connection.baseUrl, connection.certificateFingerprint ); if (!identity) return; void state.syncNow(); })(); }, AUTO_SYNC_DEBOUNCE_MS); void reason; }, }));