From 89e5ac60f62604b114e1465585695e7e861f8adb Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 12 Jul 2026 23:04:59 -0700 Subject: [PATCH] refactor(sync-ui): split runtime into focused modules - Extract sync-ui-runtime.ts into sync-ui-hosts-state, sync-ui-ipc-handlers, sync-ui-run-lifecycle, sync-ui-snapshots - Reject sync --ui combined with --remote-cmd or --db in cli-parser-builder --- launcher/config/cli-parser-builder.test.ts | 18 +- launcher/config/cli-parser-builder.ts | 2 + src/main/runtime/sync-ui-hosts-state.ts | 23 ++ src/main/runtime/sync-ui-ipc-handlers.ts | 63 ++++ src/main/runtime/sync-ui-run-lifecycle.ts | 183 +++++++++++ src/main/runtime/sync-ui-runtime.ts | 338 ++++----------------- src/main/runtime/sync-ui-snapshots.ts | 74 +++++ 7 files changed, 416 insertions(+), 285 deletions(-) create mode 100644 src/main/runtime/sync-ui-hosts-state.ts create mode 100644 src/main/runtime/sync-ui-ipc-handlers.ts create mode 100644 src/main/runtime/sync-ui-run-lifecycle.ts create mode 100644 src/main/runtime/sync-ui-snapshots.ts diff --git a/launcher/config/cli-parser-builder.test.ts b/launcher/config/cli-parser-builder.test.ts index 6aff92c5..70ff8592 100644 --- a/launcher/config/cli-parser-builder.test.ts +++ b/launcher/config/cli-parser-builder.test.ts @@ -87,7 +87,10 @@ test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => { const makeTemp = parseCliPrograms(['sync', '--make-temp'], 'subminer'); assert.deepEqual(makeTemp.invocations.syncCliTokens, ['--make-temp']); - const removeTemp = parseCliPrograms(['sync', '--remove-temp', '/tmp/subminer-sync-x'], 'subminer'); + const removeTemp = parseCliPrograms( + ['sync', '--remove-temp', '/tmp/subminer-sync-x'], + 'subminer', + ); assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']); }); @@ -112,3 +115,16 @@ test('parseCliPrograms captures sync --ui', () => { /--ui cannot be combined/, ); }); + +test('parseCliPrograms rejects sync --ui with --remote-cmd', () => { + assert.throws( + () => parseCliPrograms(['sync', '--ui', '--remote-cmd', '/opt/SubMiner.AppImage'], 'subminer'), + { message: 'Sync --ui cannot be combined with other sync options.' }, + ); +}); + +test('parseCliPrograms rejects sync --ui with --db', () => { + assert.throws(() => parseCliPrograms(['sync', '--ui', '--db', '/tmp/db.sqlite'], 'subminer'), { + message: 'Sync --ui cannot be combined with other sync options.', + }); +}); diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index df95b311..486af1e5 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -340,6 +340,8 @@ export function parseCliPrograms( check || makeTemp || removeTemp || + options.remoteCmd !== undefined || + options.db !== undefined || options.json === true || options.force === true ) { diff --git a/src/main/runtime/sync-ui-hosts-state.ts b/src/main/runtime/sync-ui-hosts-state.ts new file mode 100644 index 00000000..b9328297 --- /dev/null +++ b/src/main/runtime/sync-ui-hosts-state.ts @@ -0,0 +1,23 @@ +import { + readSyncHostsState, + writeSyncHostsState, + type SyncHostsState, +} from '../../shared/sync/sync-hosts-store'; + +interface SyncUiHostsStateDeps { + hostsFilePath: string; + broadcastStateChanged: () => void; +} + +export function createSyncUiHostsState(deps: SyncUiHostsStateDeps) { + function readState(): SyncHostsState { + return readSyncHostsState(deps.hostsFilePath); + } + + function writeState(state: SyncHostsState): void { + writeSyncHostsState(deps.hostsFilePath, state); + deps.broadcastStateChanged(); + } + + return { readState, writeState }; +} diff --git a/src/main/runtime/sync-ui-ipc-handlers.ts b/src/main/runtime/sync-ui-ipc-handlers.ts new file mode 100644 index 00000000..e6acbda9 --- /dev/null +++ b/src/main/runtime/sync-ui-ipc-handlers.ts @@ -0,0 +1,63 @@ +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { removeSyncHost, upsertSyncHost } from '../../shared/sync/sync-hosts-store'; +import type { + SyncUiHostUpdateRequest, + SyncUiRunRequest, + SyncUiSnapshot, + SyncUiStartResult, +} from '../../types/sync-ui'; + +interface SyncUiIpcHandlersDeps { + ipcMain: { + handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown; + }; + nowMs: () => number; + revealPath: (targetPath: string) => void; + pickSnapshotFile: () => Promise; + getSnapshot: () => SyncUiSnapshot; + readState: () => SyncUiSnapshot['hosts']; + writeState: (state: SyncUiSnapshot['hosts']) => void; + runHostSync: (request: SyncUiRunRequest) => SyncUiStartResult; + cancelRun: () => boolean; + checkHost: (host: string) => unknown; + createSnapshot: () => SyncUiStartResult; + mergeSnapshotFile: (filePath: string, force?: boolean) => SyncUiStartResult; + deleteSnapshot: (filePath: string) => void; +} + +export function registerSyncUiIpcHandlers(deps: SyncUiIpcHandlersDeps): void { + const channels = IPC_CHANNELS.request; + deps.ipcMain.handle(channels.syncUiGetSnapshot, () => deps.getSnapshot()); + deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => { + deps.writeState( + upsertSyncHost(deps.readState(), update as SyncUiHostUpdateRequest, deps.nowMs()), + ); + }); + deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => { + deps.writeState(removeSyncHost(deps.readState(), String(host))); + }); + deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => { + const value = Number(minutes); + if (!Number.isFinite(value) || value < 1 || value > 24 * 60) { + throw new Error('Auto-sync interval must be between 1 and 1440 minutes.'); + } + deps.writeState({ ...deps.readState(), autoSyncIntervalMinutes: Math.floor(value) }); + }); + deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) => + deps.runHostSync(request as SyncUiRunRequest), + ); + deps.ipcMain.handle(channels.syncUiCancelRun, () => deps.cancelRun()); + deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => deps.checkHost(String(host))); + deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => deps.createSnapshot()); + deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) => + deps.mergeSnapshotFile(String(filePath), force === true), + ); + deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => { + deps.deleteSnapshot(String(filePath)); + }); + deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => { + deps.revealPath(String(filePath)); + return true; + }); + deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile()); +} diff --git a/src/main/runtime/sync-ui-run-lifecycle.ts b/src/main/runtime/sync-ui-run-lifecycle.ts new file mode 100644 index 00000000..90c80b14 --- /dev/null +++ b/src/main/runtime/sync-ui-run-lifecycle.ts @@ -0,0 +1,183 @@ +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { isValidSyncHost } from '../../shared/sync/sync-hosts-store'; +import type { SyncProgressEvent } from '../../shared/sync/sync-events'; +import type { + SyncUiCheckResult, + SyncUiRunKind, + SyncUiRunState, + SyncUiStartResult, +} from '../../types/sync-ui'; +import { + runSyncLauncher, + type SyncLauncherRunHandle, + type SyncLauncherRunResult, +} from './sync-launcher-client'; + +interface ActiveRun { + id: number; + kind: SyncUiRunKind; + host: string | null; + handle: SyncLauncherRunHandle; + resultSeen: boolean; + completion?: Promise; +} + +interface SyncUiRunLifecycleDeps { + runLauncher: typeof runSyncLauncher; + resolveLauncherCommand: () => string[]; + log?: (message: string) => void; + notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void; + sendToWindow: (channel: string, payload?: unknown) => void; + broadcastStateChanged: () => void; +} + +export function createSyncUiRunLifecycle(deps: SyncUiRunLifecycleDeps) { + let runCounter = 0; + let currentRun: ActiveRun | null = null; + + function launchRun( + kind: SyncUiRunKind, + host: string | null, + args: string[], + options: { + notify?: boolean; + timeoutMs?: number; + emitProgress?: boolean; + onEvent?: (event: SyncProgressEvent) => void; + } = {}, + ): { start: SyncUiStartResult; done: Promise | null } { + if (currentRun) { + return { + start: { started: false, runId: null, reason: 'A sync operation is already running.' }, + done: null, + }; + } + const emitProgress = options.emitProgress ?? true; + runCounter += 1; + const runId = runCounter; + const run: ActiveRun = { + id: runId, + kind, + host, + resultSeen: false, + handle: deps.runLauncher({ + command: deps.resolveLauncherCommand(), + args, + timeoutMs: options.timeoutMs, + onEvent: (event: SyncProgressEvent) => { + if (event.type === 'result') run.resultSeen = true; + options.onEvent?.(event); + if (emitProgress) { + deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event }); + } + }, + onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`), + }), + }; + currentRun = run; + run.completion = run.handle.done + .then((result: SyncLauncherRunResult) => { + if (currentRun?.id === runId) currentRun = null; + if (emitProgress && !run.resultSeen) { + deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, { + runId, + kind, + host, + event: { type: 'result', ok: result.ok, error: result.error }, + }); + } + deps.broadcastStateChanged(); + if (options.notify && deps.notify) { + const target = host ?? 'local database'; + deps.notify( + result.ok + ? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' } + : { + title: 'Sync failed', + body: `${target}: ${result.error ?? 'unknown error'}`, + variant: 'error', + }, + ); + } + }) + .catch((error) => { + deps.log?.( + `[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + return { start: { started: true, runId, reason: null }, done: run.handle.done }; + } + + function startRun( + kind: SyncUiRunKind, + host: string | null, + args: string[], + options: { notify?: boolean } = {}, + ): SyncUiStartResult { + return launchRun(kind, host, args, options).start; + } + + function checkHost(host: string): Promise { + const trimmed = host.trim(); + const failed = (error: string): SyncUiCheckResult => ({ + host: trimmed, + sshOk: false, + remoteCommand: null, + remoteVersion: null, + ok: false, + error, + }); + if (!isValidSyncHost(trimmed)) { + return Promise.resolve(failed(`Invalid sync host: ${host}`)); + } + let checkResult: SyncUiCheckResult | null = null; + const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], { + timeoutMs: 30_000, + emitProgress: false, + onEvent: (event) => { + if (event.type === 'check-result') { + checkResult = { + host: event.host, + sshOk: event.sshOk, + remoteCommand: event.remoteCommand, + remoteVersion: event.remoteVersion, + ok: event.ok, + error: event.error, + }; + } + }, + }); + if (!start.started || !done) { + return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.')); + } + return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.')); + } + + function cancelRun(): boolean { + if (!currentRun) return false; + currentRun.handle.cancel(); + return true; + } + + async function shutdown(): Promise { + const run = currentRun; + if (!run) return; + run.handle.cancel(); + await (run.completion ?? run.handle.done.then(() => undefined)); + } + + function runState(): SyncUiRunState { + return currentRun + ? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host } + : { running: false, runId: null, kind: null, host: null }; + } + + return { + startRun, + checkHost, + cancelRun, + shutdown, + runState, + isRunning: () => currentRun !== null, + }; +} diff --git a/src/main/runtime/sync-ui-runtime.ts b/src/main/runtime/sync-ui-runtime.ts index c5784708..d0de180d 100644 --- a/src/main/runtime/sync-ui-runtime.ts +++ b/src/main/runtime/sync-ui-runtime.ts @@ -1,28 +1,15 @@ -import fs from 'node:fs'; -import path from 'node:path'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { isValidSyncHost, - readSyncHostsState, - removeSyncHost, upsertSyncHost, - writeSyncHostsState, type SyncDirection, - type SyncHostsState, } from '../../shared/sync/sync-hosts-store'; -import type { SyncProgressEvent } from '../../shared/sync/sync-events'; -import type { - SyncUiCheckResult, - SyncUiHostUpdateRequest, - SyncUiRunKind, - SyncUiRunRequest, - SyncUiRunState, - SyncUiSnapshot, - SyncUiSnapshotFile, - SyncUiStartResult, -} from '../../types/sync-ui'; -import type { SyncLauncherRunHandle, SyncLauncherRunResult } from './sync-launcher-client'; +import type { SyncUiRunRequest, SyncUiSnapshot } from '../../types/sync-ui'; +import { createSyncUiHostsState } from './sync-ui-hosts-state'; +import { registerSyncUiIpcHandlers } from './sync-ui-ipc-handlers'; +import { createSyncUiRunLifecycle } from './sync-ui-run-lifecycle'; import { runSyncLauncher } from './sync-launcher-client'; +import { createSyncUiSnapshots } from './sync-ui-snapshots'; interface SyncUiWindowLike { isDestroyed(): boolean; @@ -46,27 +33,7 @@ export interface SyncUiRuntimeDeps { notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void; } -interface ActiveRun { - id: number; - kind: SyncUiRunKind; - host: string | null; - handle: SyncLauncherRunHandle; - resultSeen: boolean; - completion?: Promise; -} - -// Milliseconds are part of the stamp so two snapshots taken in the same second -// do not land on the same path and silently overwrite each other. -function formatSnapshotName(nowMs: number): string { - const iso = new Date(nowMs).toISOString(); - const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-'); - return `immersion-${stamp}.sqlite`; -} - export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { - let runCounter = 0; - let currentRun: ActiveRun | null = null; - function sendToWindow(channel: string, payload?: unknown): void { const window = deps.getWindow(); if (!window || window.isDestroyed()) return; @@ -77,146 +44,35 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { sendToWindow(IPC_CHANNELS.event.syncUiStateChanged); } - function readState(): SyncHostsState { - return readSyncHostsState(deps.hostsFilePath); - } - - function writeState(state: SyncHostsState): void { - writeSyncHostsState(deps.hostsFilePath, state); - broadcastStateChanged(); - } - - function listSnapshots(): SyncUiSnapshotFile[] { - let names: string[]; - try { - names = fs.readdirSync(deps.snapshotsDir); - } catch { - return []; - } - const files: SyncUiSnapshotFile[] = []; - for (const name of names) { - if (!name.endsWith('.sqlite')) continue; - const filePath = path.join(deps.snapshotsDir, name); - try { - const stat = fs.statSync(filePath); - if (!stat.isFile()) continue; - files.push({ - path: filePath, - name, - sizeBytes: stat.size, - modifiedAtMs: stat.mtimeMs, - }); - } catch { - // file disappeared between readdir and stat - } - } - files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs); - return files; - } - - function runState(): SyncUiRunState { - return currentRun - ? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host } - : { running: false, runId: null, kind: null, host: null }; - } + const hostsState = createSyncUiHostsState({ + hostsFilePath: deps.hostsFilePath, + broadcastStateChanged, + }); + const runLifecycle = createSyncUiRunLifecycle({ + runLauncher: deps.runLauncher, + resolveLauncherCommand: deps.resolveLauncherCommand, + log: deps.log, + notify: deps.notify, + sendToWindow, + broadcastStateChanged, + }); + const snapshots = createSyncUiSnapshots({ + snapshotsDir: deps.snapshotsDir, + nowMs: deps.nowMs, + startRun: runLifecycle.startRun, + broadcastStateChanged, + }); function getSnapshot(): SyncUiSnapshot { return { dbPath: deps.getDbPath(), - hosts: readState(), + hosts: hostsState.readState(), snapshotsDir: deps.snapshotsDir, - snapshots: listSnapshots(), - run: runState(), + snapshots: snapshots.listSnapshots(), + run: runLifecycle.runState(), }; } - // Shared run lifecycle: single-run mutex, launcher spawn, cleanup + state - // broadcast on completion. `emitProgress: false` runs silently (no renderer - // progress events); `onEvent` taps every NDJSON event either way. - function launchRun( - kind: SyncUiRunKind, - host: string | null, - args: string[], - options: { - notify?: boolean; - timeoutMs?: number; - emitProgress?: boolean; - onEvent?: (event: SyncProgressEvent) => void; - } = {}, - ): { start: SyncUiStartResult; done: Promise | null } { - if (currentRun) { - return { - start: { started: false, runId: null, reason: 'A sync operation is already running.' }, - done: null, - }; - } - const emitProgress = options.emitProgress ?? true; - runCounter += 1; - const runId = runCounter; - const run: ActiveRun = { - id: runId, - kind, - host, - resultSeen: false, - handle: deps.runLauncher({ - command: deps.resolveLauncherCommand(), - args, - timeoutMs: options.timeoutMs, - onEvent: (event: SyncProgressEvent) => { - if (event.type === 'result') run.resultSeen = true; - options.onEvent?.(event); - if (emitProgress) { - sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event }); - } - }, - onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`), - }), - }; - currentRun = run; - run.completion = run.handle.done - .then((result: SyncLauncherRunResult) => { - if (currentRun?.id === runId) currentRun = null; - // If the launcher died without emitting a result event (spawn failure, - // kill), synthesize one so the renderer can settle its progress view. - if (emitProgress && !run.resultSeen) { - sendToWindow(IPC_CHANNELS.event.syncUiProgress, { - runId, - kind, - host, - event: { type: 'result', ok: result.ok, error: result.error }, - }); - } - broadcastStateChanged(); - if (options.notify && deps.notify) { - const target = host ?? 'local database'; - deps.notify( - result.ok - ? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' } - : { - title: 'Sync failed', - body: `${target}: ${result.error ?? 'unknown error'}`, - variant: 'error', - }, - ); - } - }) - .catch((error) => { - deps.log?.( - `[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - return { start: { started: true, runId, reason: null }, done: run.handle.done }; - } - - function startRun( - kind: SyncUiRunKind, - host: string | null, - args: string[], - options: { notify?: boolean } = {}, - ): SyncUiStartResult { - return launchRun(kind, host, args, options).start; - } - function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) { const host = request.host.trim(); if (!isValidSyncHost(host)) { @@ -228,132 +84,46 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { if (direction === 'pull') args.push('--pull'); if (request.force) args.push('--force'); args.push('--json'); - const result = startRun('host-sync', host, args, options); + const result = runLifecycle.startRun('host-sync', host, args, options); if (result.started) { - // Remember the host (and the direction used) before the launcher's own - // bookkeeping lands, so it shows up in the UI immediately. - writeState(upsertSyncHost(readState(), { host, direction: request.direction }, deps.nowMs())); + // Persist before launcher bookkeeping lands so the host appears immediately. + hostsState.writeState( + upsertSyncHost( + hostsState.readState(), + { host, direction: request.direction }, + deps.nowMs(), + ), + ); } return result; } - function checkHost(host: string): Promise { - const trimmed = host.trim(); - const failed = (error: string): SyncUiCheckResult => ({ - host: trimmed, - sshOk: false, - remoteCommand: null, - remoteVersion: null, - ok: false, - error, - }); - if (!isValidSyncHost(trimmed)) { - return Promise.resolve(failed(`Invalid sync host: ${host}`)); - } - let checkResult: SyncUiCheckResult | null = null; - const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], { - timeoutMs: 30_000, - emitProgress: false, - onEvent: (event) => { - if (event.type === 'check-result') { - checkResult = { - host: event.host, - sshOk: event.sshOk, - remoteCommand: event.remoteCommand, - remoteVersion: event.remoteVersion, - ok: event.ok, - error: event.error, - }; - } - }, - }); - if (!start.started || !done) { - return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.')); - } - return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.')); - } - - function createSnapshot(): SyncUiStartResult { - const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs())); - return startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']); - } - - function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult { - if (!fs.existsSync(filePath)) { - return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` }; - } - const args = ['sync', '--merge', filePath]; - if (force) args.push('--force'); - args.push('--json'); - return startRun('merge', null, args); - } - - function deleteSnapshot(filePath: string): void { - const resolved = path.resolve(filePath); - const dir = path.resolve(deps.snapshotsDir); - if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) { - throw new Error('Refusing to delete a file outside the snapshots directory.'); - } - fs.rmSync(resolved, { force: true }); - broadcastStateChanged(); - } - - function cancelRun(): boolean { - if (!currentRun) return false; - currentRun.handle.cancel(); - return true; - } - - async function shutdown(): Promise { - const run = currentRun; - if (!run) return; - run.handle.cancel(); - await (run.completion ?? run.handle.done.then(() => undefined)); - } - function registerHandlers(): void { - const channels = IPC_CHANNELS.request; - deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot()); - deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => { - writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs())); + registerSyncUiIpcHandlers({ + ipcMain: deps.ipcMain, + nowMs: deps.nowMs, + revealPath: deps.revealPath, + pickSnapshotFile: deps.pickSnapshotFile, + getSnapshot, + readState: hostsState.readState, + writeState: hostsState.writeState, + runHostSync, + cancelRun: runLifecycle.cancelRun, + checkHost: runLifecycle.checkHost, + createSnapshot: snapshots.createSnapshot, + mergeSnapshotFile: snapshots.mergeSnapshotFile, + deleteSnapshot: snapshots.deleteSnapshot, }); - deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => { - writeState(removeSyncHost(readState(), String(host))); - }); - deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => { - const value = Number(minutes); - if (!Number.isFinite(value) || value < 1 || value > 24 * 60) { - throw new Error('Auto-sync interval must be between 1 and 1440 minutes.'); - } - writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) }); - }); - deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) => - runHostSync(request as SyncUiRunRequest), - ); - deps.ipcMain.handle(channels.syncUiCancelRun, () => cancelRun()); - deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => checkHost(String(host))); - deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => createSnapshot()); - deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) => - mergeSnapshotFile(String(filePath), force === true), - ); - deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => { - deleteSnapshot(String(filePath)); - }); - deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => { - deps.revealPath(String(filePath)); - return true; - }); - deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile()); } return { registerHandlers, getSnapshot, - readState, + readState: hostsState.readState, runHostSync, - checkHost, - cancelRun, - shutdown, - isRunning: () => currentRun !== null, + checkHost: runLifecycle.checkHost, + cancelRun: runLifecycle.cancelRun, + shutdown: runLifecycle.shutdown, + isRunning: runLifecycle.isRunning, }; } diff --git a/src/main/runtime/sync-ui-snapshots.ts b/src/main/runtime/sync-ui-snapshots.ts new file mode 100644 index 00000000..f12075d9 --- /dev/null +++ b/src/main/runtime/sync-ui-snapshots.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { SyncUiSnapshotFile, SyncUiStartResult } from '../../types/sync-ui'; + +interface SyncUiSnapshotsDeps { + snapshotsDir: string; + nowMs: () => number; + startRun: (kind: 'snapshot' | 'merge', host: null, args: string[]) => SyncUiStartResult; + broadcastStateChanged: () => void; +} + +function formatSnapshotName(nowMs: number): string { + // Milliseconds prevent snapshots taken in the same second from overwriting each other. + const iso = new Date(nowMs).toISOString(); + const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-'); + return `immersion-${stamp}.sqlite`; +} + +export function createSyncUiSnapshots(deps: SyncUiSnapshotsDeps) { + function listSnapshots(): SyncUiSnapshotFile[] { + let names: string[]; + try { + names = fs.readdirSync(deps.snapshotsDir); + } catch { + return []; + } + const files: SyncUiSnapshotFile[] = []; + for (const name of names) { + if (!name.endsWith('.sqlite')) continue; + const filePath = path.join(deps.snapshotsDir, name); + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) continue; + files.push({ + path: filePath, + name, + sizeBytes: stat.size, + modifiedAtMs: stat.mtimeMs, + }); + } catch { + // file disappeared between readdir and stat + } + } + files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs); + return files; + } + + function createSnapshot(): SyncUiStartResult { + const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs())); + return deps.startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']); + } + + function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult { + if (!fs.existsSync(filePath)) { + return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` }; + } + const args = ['sync', '--merge', filePath]; + if (force) args.push('--force'); + args.push('--json'); + return deps.startRun('merge', null, args); + } + + function deleteSnapshot(filePath: string): void { + const resolved = path.resolve(filePath); + const dir = path.resolve(deps.snapshotsDir); + if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) { + throw new Error('Refusing to delete a file outside the snapshots directory.'); + } + fs.rmSync(resolved, { force: true }); + deps.broadcastStateChanged(); + } + + return { listSnapshots, createSnapshot, mergeSnapshotFile, deleteSnapshot }; +}