From 806c56a99b3388bb7de7a396567bf94d9de4f043 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 12 Jul 2026 22:31:59 -0700 Subject: [PATCH] refactor(sync): collapse 11 sync args into forwarded token array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace syncHost/syncSnapshotPath/… with syncCliTokens: string[] passed verbatim to --sync-cli sync - Move sync CLI validation from launcher to parseSyncCliTokens (app-side single owner) - Extract resolveImmersionDbPath to shared db-path.ts importable by both launcher and app - Delete driver.ts; inline types into libsql-driver.ts and hard-wire openLibsqlSyncDb - Drop OpenSyncDb injection from mergeSnapshotIntoDb and createDbSnapshot --- launcher/commands/playback-command.test.ts | 12 +- launcher/commands/sync-command.test.ts | 102 ++++-------- launcher/commands/sync-command.ts | 52 ++---- launcher/config/args-normalizer.test.ts | 48 +----- launcher/config/args-normalizer.ts | 24 +-- launcher/config/cli-parser-builder.test.ts | 81 ++++++---- launcher/config/cli-parser-builder.ts | 96 +++-------- launcher/history-db.ts | 30 +--- launcher/jellyfin.test.ts | 12 +- launcher/mpv.test.ts | 12 +- launcher/sync/engine-merge.test.ts | 16 +- launcher/types.ts | 13 +- src/core/services/stats-sync/cli-args.test.ts | 3 +- src/core/services/stats-sync/cli-args.ts | 6 +- src/core/services/stats-sync/db-path.ts | 42 +++++ src/core/services/stats-sync/driver.ts | 42 ----- src/core/services/stats-sync/libsql-driver.ts | 63 +++++--- src/core/services/stats-sync/merge-catalog.ts | 2 +- src/core/services/stats-sync/merge-rollups.ts | 2 +- .../services/stats-sync/merge-sessions.ts | 2 +- src/core/services/stats-sync/merge.ts | 12 +- src/core/services/stats-sync/shared.ts | 19 +-- src/core/services/stats-sync/ssh.ts | 41 +++-- .../services/stats-sync/sync-flow.test.ts | 18 +-- src/core/services/stats-sync/sync-flow.ts | 88 +++++----- src/core/services/stats-sync/wal-retry.ts | 13 +- src/main.ts | 3 +- src/main/runtime/sync-launcher-client.test.ts | 27 +++- src/main/runtime/sync-launcher-client.ts | 14 +- src/main/runtime/sync-ui-runtime.test.ts | 34 ++-- src/main/runtime/sync-ui-runtime.ts | 153 ++++++++---------- src/main/runtime/windows-mpv-launch.ts | 24 +-- src/main/sync-cli.ts | 93 ++--------- src/preload-syncui.ts | 10 +- src/shared/socket-probe.ts | 28 ++++ src/syncui/style.css | 12 -- src/syncui/syncui.ts | 14 +- src/types/sync-ui.ts | 8 +- 38 files changed, 473 insertions(+), 798 deletions(-) create mode 100644 src/core/services/stats-sync/db-path.ts delete mode 100644 src/core/services/stats-sync/driver.ts create mode 100644 src/shared/socket-probe.ts diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index 3c071253..24907f88 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -37,17 +37,7 @@ function createContext(): LauncherCommandContext { useRofi: false, history: false, sync: false, - syncHost: '', - syncSnapshotPath: '', - syncMergePath: '', - syncDirection: 'both', - syncRemoteCmd: '', - syncDbPath: '', - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], syncUi: false, logLevel: 'info', logRotation: 7, diff --git a/launcher/commands/sync-command.test.ts b/launcher/commands/sync-command.test.ts index f2a2f2f4..66cfc8f6 100644 --- a/launcher/commands/sync-command.test.ts +++ b/launcher/commands/sync-command.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import type { Args } from '../types.js'; import type { LauncherCommandContext } from './context.js'; -import { buildSyncCliArgv, runSyncCommand, type SyncCommandDeps } from './sync-command.js'; +import { runSyncCommand, type SyncCommandDeps } from './sync-command.js'; function makeContext( overrides: Partial, @@ -11,17 +11,7 @@ function makeContext( return { args: { sync: true, - syncHost: '', - syncSnapshotPath: '', - syncMergePath: '', - syncDirection: 'both', - syncRemoteCmd: '', - syncDbPath: '', - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], logLevel: 'warn', ...overrides, } as Args, @@ -35,24 +25,6 @@ function makeContext( } as unknown as LauncherCommandContext; } -function makeArgs(overrides: Partial[0]>) { - return { - syncHost: '', - syncSnapshotPath: '', - syncMergePath: '', - syncDirection: 'both' as const, - syncRemoteCmd: '', - syncDbPath: '', - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', - logLevel: 'warn' as const, - ...overrides, - }; -} - test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => { const spawned: Array<{ appPath: string; appArgs: string[] }> = []; const deps: Partial = { @@ -62,7 +34,7 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () }; assert.equal( - await runSyncCommand(makeContext({ syncHost: 'media-box', syncJson: true }), deps), + await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps), true, ); assert.deepEqual(spawned, [ @@ -76,19 +48,32 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () assert.equal(spawned.length, 1); }); -test('buildSyncCliArgv forwards every sync option', () => { - assert.deepEqual( - buildSyncCliArgv( - makeArgs({ - syncHost: 'media-box', - syncDirection: 'pull', - syncRemoteCmd: '/opt/SubMiner.AppImage', - syncDbPath: '/tmp/db.sqlite', - syncForce: true, - syncJson: true, - logLevel: 'debug', - }), - ), +test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => { + const spawned: string[][] = []; + const deps: Partial = { + runAppCommand: (_appPath, appArgs) => { + spawned.push(appArgs); + }, + }; + + await runSyncCommand( + makeContext({ + syncCliTokens: [ + 'media-box', + '--pull', + '--remote-cmd', + '/opt/SubMiner.AppImage', + '--db', + '/tmp/db.sqlite', + '--force', + '--json', + ], + logLevel: 'debug', + }), + deps, + ); + + assert.deepEqual(spawned, [ [ '--sync-cli', 'sync', @@ -103,32 +88,7 @@ test('buildSyncCliArgv forwards every sync option', () => { '--log-level', 'debug', ], - ); - - assert.deepEqual( - buildSyncCliArgv(makeArgs({ syncSnapshotPath: '/tmp/out.sqlite' })), - ['--sync-cli', 'sync', '--snapshot', '/tmp/out.sqlite', '--log-level', 'warn'], - ); - - assert.deepEqual( - buildSyncCliArgv(makeArgs({ syncHost: 'media-box', syncCheck: true })), - ['--sync-cli', 'sync', 'media-box', '--check', '--log-level', 'warn'], - ); - - assert.deepEqual( - buildSyncCliArgv(makeArgs({ syncMakeTemp: true })), - ['--sync-cli', 'sync', '--make-temp', '--log-level', 'warn'], - ); - - assert.deepEqual( - buildSyncCliArgv(makeArgs({ syncRemoveTempPath: '/tmp/subminer-sync-x' })), - ['--sync-cli', 'sync', '--remove-temp', '/tmp/subminer-sync-x', '--log-level', 'warn'], - ); - - assert.deepEqual( - buildSyncCliArgv(makeArgs({ syncMergePath: '/tmp/in.sqlite', syncForce: true })), - ['--sync-cli', 'sync', '--merge', '/tmp/in.sqlite', '--force', '--log-level', 'warn'], - ); + ]); }); test('runSyncCommand fails with a clear message when the app binary is missing', async () => { @@ -142,7 +102,7 @@ test('runSyncCommand fails with a clear message when the app binary is missing', }; await assert.rejects( - () => runSyncCommand(makeContext({ syncHost: 'media-box' }, null), deps), + () => runSyncCommand(makeContext({ syncCliTokens: ['media-box'] }, null), deps), /SubMiner app binary not found \(sync runs inside the app\)/, ); }); diff --git a/launcher/commands/sync-command.ts b/launcher/commands/sync-command.ts index 9f80ff4b..27deac77 100644 --- a/launcher/commands/sync-command.ts +++ b/launcher/commands/sync-command.ts @@ -1,6 +1,6 @@ +import { SYNC_CLI_FLAG } from '../../src/core/services/stats-sync/cli-args.js'; import { fail } from '../log.js'; import { runAppCommandInteractive } from '../mpv.js'; -import type { Args } from '../types.js'; import type { LauncherCommandContext } from './context.js'; export interface SyncCommandDeps { @@ -13,46 +13,12 @@ const defaultSyncCommandDeps: SyncCommandDeps = { fail, }; -type SyncArgs = Pick< - Args, - | 'syncHost' - | 'syncSnapshotPath' - | 'syncMergePath' - | 'syncDirection' - | 'syncRemoteCmd' - | 'syncDbPath' - | 'syncForce' - | 'syncJson' - | 'syncCheck' - | 'syncMakeTemp' - | 'syncRemoveTempPath' - | 'logLevel' ->; - -/** Rebuild the app's --sync-cli argv from the launcher's parsed sync args. */ -export function buildSyncCliArgv(args: SyncArgs): string[] { - const argv = ['--sync-cli', 'sync']; - if (args.syncHost) argv.push(args.syncHost); - if (args.syncSnapshotPath) argv.push('--snapshot', args.syncSnapshotPath); - if (args.syncMergePath) argv.push('--merge', args.syncMergePath); - if (args.syncMakeTemp) argv.push('--make-temp'); - if (args.syncRemoveTempPath) argv.push('--remove-temp', args.syncRemoveTempPath); - if (args.syncDirection === 'push') argv.push('--push'); - if (args.syncDirection === 'pull') argv.push('--pull'); - if (args.syncCheck) argv.push('--check'); - if (args.syncRemoteCmd) argv.push('--remote-cmd', args.syncRemoteCmd); - if (args.syncDbPath) argv.push('--db', args.syncDbPath); - if (args.syncForce) argv.push('--force'); - if (args.syncJson) argv.push('--json'); - argv.push('--log-level', args.logLevel); - return argv; -} - /** * `subminer sync` is a thin proxy: the sync engine only executes inside the - * SubMiner app (--sync-cli mode, libsql), so the launcher and the app cannot - * drift apart. The launcher contributes its parser/help and app discovery; - * the child owns the terminal and its exit code becomes the launcher's. + * SubMiner app (--sync-cli mode, libsql). The launcher contributes its + * parser/help and app discovery; the app's parseSyncCliTokens owns validation, + * so its errors reach the terminal through the child's inherited stdio. The + * child owns the terminal and its exit code becomes the launcher's. */ export async function runSyncCommand( context: LauncherCommandContext, @@ -68,6 +34,12 @@ export async function runSyncCommand( ); return true; // fail() never returns; this only satisfies control-flow analysis } - deps.runAppCommand(context.appPath, buildSyncCliArgv(context.args)); + deps.runAppCommand(context.appPath, [ + SYNC_CLI_FLAG, + 'sync', + ...context.args.syncCliTokens, + '--log-level', + context.args.logLevel, + ]); return true; } diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index cfaa9b3a..000cf218 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -136,17 +136,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { statsCleanupLifetime: false, statsLogLevel: null, syncTriggered: false, - syncHost: null, - syncSnapshotPath: null, - syncMergePath: null, - syncDirection: 'both', - syncRemoteCmd: null, - syncDbPath: null, - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], syncLogLevel: null, syncUiTriggered: false, syncUiLogLevel: null, @@ -197,17 +187,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () => statsCleanupLifetime: false, statsLogLevel: null, syncTriggered: false, - syncHost: null, - syncSnapshotPath: null, - syncMergePath: null, - syncDirection: 'both', - syncRemoteCmd: null, - syncDbPath: null, - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], syncLogLevel: null, syncUiTriggered: false, syncUiLogLevel: null, @@ -251,17 +231,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () => statsCleanupLifetime: false, statsLogLevel: null, syncTriggered: false, - syncHost: null, - syncSnapshotPath: null, - syncMergePath: null, - syncDirection: 'both', - syncRemoteCmd: null, - syncDbPath: null, - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], syncLogLevel: null, syncUiTriggered: false, syncUiLogLevel: null, @@ -303,17 +273,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => { statsCleanupLifetime: false, statsLogLevel: null, syncTriggered: false, - syncHost: null, - syncSnapshotPath: null, - syncMergePath: null, - syncDirection: 'both', - syncRemoteCmd: null, - syncDbPath: null, - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], syncLogLevel: null, syncUiTriggered: false, syncUiLogLevel: null, diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index ed66dcc1..03b21e39 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -200,17 +200,7 @@ export function createDefaultArgs( useRofi: false, history: false, sync: false, - syncHost: '', - syncSnapshotPath: '', - syncMergePath: '', - syncDirection: 'both', - syncRemoteCmd: '', - syncDbPath: '', - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], syncUi: false, logLevel: loggingConfig.level ?? 'warn', logRotation: loggingConfig.rotation ?? 7, @@ -279,17 +269,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations } if (invocations.syncTriggered) { parsed.sync = true; - parsed.syncHost = invocations.syncHost ?? ''; - parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? ''; - parsed.syncMergePath = invocations.syncMergePath ?? ''; - parsed.syncDirection = invocations.syncDirection; - parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? ''; - parsed.syncDbPath = invocations.syncDbPath ?? ''; - parsed.syncForce = invocations.syncForce; - parsed.syncJson = invocations.syncJson; - parsed.syncCheck = invocations.syncCheck; - parsed.syncMakeTemp = invocations.syncMakeTemp; - parsed.syncRemoveTempPath = invocations.syncRemoveTempPath ?? ''; + parsed.syncCliTokens = invocations.syncCliTokens; if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel); } if (invocations.syncUiTriggered) { diff --git a/launcher/config/cli-parser-builder.test.ts b/launcher/config/cli-parser-builder.test.ts index a6184248..6aff92c5 100644 --- a/launcher/config/cli-parser-builder.test.ts +++ b/launcher/config/cli-parser-builder.test.ts @@ -43,42 +43,63 @@ test('parseCliPrograms captures texthooker browser-open flag', () => { assert.equal(result.invocations.texthookerOpenBrowser, true); }); -test('parseCliPrograms captures one-way sync directions', () => { +test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => { const push = parseCliPrograms(['sync', 'media-box', '--push'], 'subminer'); + assert.equal(push.invocations.syncTriggered, true); + assert.deepEqual(push.invocations.syncCliTokens, ['media-box', '--push']); + const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer'); - - assert.equal(push.invocations.syncDirection, 'push'); - assert.equal(pull.invocations.syncDirection, 'pull'); -}); - -test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => { - assert.throws( - () => parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'), - /--push and --pull cannot be combined/, - ); - assert.throws( - () => parseCliPrograms(['sync', '--snapshot', '/tmp/stats.sqlite', '--push'], 'subminer'), - /--push and --pull require a host/, - ); -}); - -test('parseCliPrograms captures sync --json and --check flags', () => { - const json = parseCliPrograms(['sync', 'media-box', '--json'], 'subminer'); - assert.equal(json.invocations.syncJson, true); - assert.equal(json.invocations.syncCheck, false); + assert.deepEqual(pull.invocations.syncCliTokens, ['media-box', '--pull']); const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer'); - assert.equal(check.invocations.syncCheck, true); - assert.equal(check.invocations.syncJson, true); - assert.equal(check.invocations.syncHost, 'media-box'); + assert.deepEqual(check.invocations.syncCliTokens, ['media-box', '--check', '--json']); + + const full = parseCliPrograms( + [ + 'sync', + 'media-box', + '--remote-cmd', + '/opt/SubMiner.AppImage', + '--db', + '/tmp/db.sqlite', + '--force', + '--log-level', + 'debug', + ], + 'subminer', + ); + assert.deepEqual(full.invocations.syncCliTokens, [ + 'media-box', + '--remote-cmd', + '/opt/SubMiner.AppImage', + '--db', + '/tmp/db.sqlite', + '--force', + ]); + assert.equal(full.invocations.syncLogLevel, 'debug'); + + const snapshot = parseCliPrograms(['sync', '--snapshot', '/tmp/out.sqlite'], 'subminer'); + assert.deepEqual(snapshot.invocations.syncCliTokens, ['--snapshot', '/tmp/out.sqlite']); + + const merge = parseCliPrograms(['sync', '--merge', '/tmp/in.sqlite'], 'subminer'); + assert.deepEqual(merge.invocations.syncCliTokens, ['--merge', '/tmp/in.sqlite']); + + 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'); + assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']); }); -test('parseCliPrograms rejects invalid sync --check combinations', () => { - assert.throws(() => parseCliPrograms(['sync', '--check'], 'subminer'), /--check requires a host/); - assert.throws( - () => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'), - /--check cannot be combined/, - ); +test('parseCliPrograms leaves sync validation to the app parser', () => { + // Invalid combinations are forwarded; the app's parseSyncCliTokens rejects them. + const invalid = parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'); + assert.equal(invalid.invocations.syncTriggered, true); + assert.deepEqual(invalid.invocations.syncCliTokens, ['media-box', '--push', '--pull']); + + const empty = parseCliPrograms(['sync'], 'subminer'); + assert.equal(empty.invocations.syncTriggered, true); + assert.deepEqual(empty.invocations.syncCliTokens, []); }); test('parseCliPrograms captures sync --ui', () => { diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 166afa22..df95b311 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -39,17 +39,7 @@ export interface CliInvocations { statsCleanupLifetime: boolean; statsLogLevel: string | null; syncTriggered: boolean; - syncHost: string | null; - syncSnapshotPath: string | null; - syncMergePath: string | null; - syncDirection: 'both' | 'push' | 'pull'; - syncRemoteCmd: string | null; - syncDbPath: string | null; - syncForce: boolean; - syncJson: boolean; - syncCheck: boolean; - syncMakeTemp: boolean; - syncRemoveTempPath: string | null; + syncCliTokens: string[]; syncLogLevel: string | null; syncUiTriggered: boolean; syncUiLogLevel: string | null; @@ -181,17 +171,7 @@ export function parseCliPrograms( let statsCleanupLifetime = false; let statsLogLevel: string | null = null; let syncTriggered = false; - let syncHost: string | null = null; - let syncSnapshotPath: string | null = null; - let syncMergePath: string | null = null; - let syncDirection: 'both' | 'push' | 'pull' = 'both'; - let syncRemoteCmd: string | null = null; - let syncDbPath: string | null = null; - let syncForce = false; - let syncJson = false; - let syncCheck = false; - let syncMakeTemp = false; - let syncRemoveTempPath: string | null = null; + let syncCliTokens: string[] = []; let syncLogLevel: string | null = null; let syncUiTriggered = false; let syncUiLogLevel: string | null = null; @@ -369,49 +349,25 @@ export function parseCliPrograms( syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; return; } - if (push && pull) { - throw new Error('Sync --push and --pull cannot be combined.'); - } - if ((push || pull) && !host) { - throw new Error('Sync --push and --pull require a host.'); - } - if (check && !host) { - throw new Error('Sync --check requires a host.'); - } - if (check && (push || pull || snapshot || merge)) { - throw new Error( - 'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.', - ); - } - if ((makeTemp || removeTemp) && (push || pull || check)) { - throw new Error('Sync --make-temp/--remove-temp cannot be combined with other sync options.'); - } - const modes = [ - Boolean(host), - Boolean(snapshot), - Boolean(merge), - makeTemp, - Boolean(removeTemp), - ].filter(Boolean).length; - if (modes === 0) { - throw new Error('Sync requires a host, --snapshot , or --merge .'); - } - if (modes > 1) { - throw new Error('Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.'); - } + // No validation here: the app's parseSyncCliTokens owns the sync rules + // and its error text reaches the terminal through the child's stdio. + const remoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() : ''; + const dbPath = typeof options.db === 'string' ? options.db.trim() : ''; + const tokens: string[] = []; + if (host) tokens.push(host); + if (snapshot) tokens.push('--snapshot', snapshot); + if (merge) tokens.push('--merge', merge); + if (makeTemp) tokens.push('--make-temp'); + if (removeTemp) tokens.push('--remove-temp', removeTemp); + if (push) tokens.push('--push'); + if (pull) tokens.push('--pull'); + if (check) tokens.push('--check'); + if (remoteCmd) tokens.push('--remote-cmd', remoteCmd); + if (dbPath) tokens.push('--db', dbPath); + if (options.force === true) tokens.push('--force'); + if (options.json === true) tokens.push('--json'); syncTriggered = true; - syncHost = host || null; - syncSnapshotPath = snapshot || null; - syncMergePath = merge || null; - syncDirection = push ? 'push' : pull ? 'pull' : 'both'; - syncRemoteCmd = - typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null; - syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null; - syncForce = options.force === true; - syncJson = options.json === true; - syncCheck = check; - syncMakeTemp = makeTemp; - syncRemoveTempPath = removeTemp || null; + syncCliTokens = tokens; syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; }); @@ -527,17 +483,7 @@ export function parseCliPrograms( statsCleanupLifetime, statsLogLevel, syncTriggered, - syncHost, - syncSnapshotPath, - syncMergePath, - syncDirection, - syncRemoteCmd, - syncDbPath, - syncForce, - syncJson, - syncCheck, - syncMakeTemp, - syncRemoveTempPath, + syncCliTokens, syncLogLevel, syncUiTriggered, syncUiLogLevel, diff --git a/launcher/history-db.ts b/launcher/history-db.ts index e9199043..e299a76b 100644 --- a/launcher/history-db.ts +++ b/launcher/history-db.ts @@ -1,38 +1,12 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { Database } from 'bun:sqlite'; -import { resolveConfigDir } from '../src/config/path-resolution.js'; -import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; import type { HistoryVideoRow } from './history-types.js'; -import { resolvePathMaybe } from './util.js'; +import { resolveImmersionDbPath } from '../src/core/services/stats-sync/db-path.js'; import { isReadonlyWalRetryError, withReadonlyWalRetry, } from '../src/core/services/stats-sync/wal-retry.js'; -export { isReadonlyWalRetryError, withReadonlyWalRetry }; - -export function resolveImmersionDbPath(): string { - const root = readLauncherMainConfigObject(); - const tracking = - root?.immersionTracking && - typeof root.immersionTracking === 'object' && - !Array.isArray(root.immersionTracking) - ? (root.immersionTracking as Record) - : null; - const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : ''; - if (configured) return resolvePathMaybe(configured); - - const configDir = resolveConfigDir({ - platform: process.platform, - appDataDir: process.env.APPDATA, - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir: os.homedir(), - existsSync: fs.existsSync, - }); - return path.join(configDir, 'immersion.sqlite'); -} +export { isReadonlyWalRetryError, resolveImmersionDbPath, withReadonlyWalRetry }; interface RawHistoryRow { video_id: number; diff --git a/launcher/jellyfin.test.ts b/launcher/jellyfin.test.ts index 8ba4ef5f..9054e767 100644 --- a/launcher/jellyfin.test.ts +++ b/launcher/jellyfin.test.ts @@ -31,17 +31,7 @@ function createArgs(): Args { useRofi: false, history: false, sync: false, - syncHost: '', - syncSnapshotPath: '', - syncMergePath: '', - syncDirection: 'both', - syncRemoteCmd: '', - syncDbPath: '', - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], syncUi: false, logLevel: 'info', logRotation: 7, diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 90e81c4b..dbf6512d 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -603,17 +603,7 @@ function makeArgs(overrides: Partial = {}): Args { useRofi: false, history: false, sync: false, - syncHost: '', - syncSnapshotPath: '', - syncMergePath: '', - syncDirection: 'both', - syncRemoteCmd: '', - syncDbPath: '', - syncForce: false, - syncJson: false, - syncCheck: false, - syncMakeTemp: false, - syncRemoveTempPath: '', + syncCliTokens: [], syncUi: false, logLevel: 'error', logRotation: 7, diff --git a/launcher/sync/engine-merge.test.ts b/launcher/sync/engine-merge.test.ts index 25402040..906f96a6 100644 --- a/launcher/sync/engine-merge.test.ts +++ b/launcher/sync/engine-merge.test.ts @@ -4,17 +4,11 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Database } from 'bun:sqlite'; -import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js'; -import { createDbSnapshot as createDbSnapshotWith } from '../../src/core/services/stats-sync/shared.js'; -import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js'; - -// The engine only executes inside the app (libsql driver) in production, so -// these merge tests run through that same binding; bun:sqlite is used only to -// build fixtures and inspect results. -const createDbSnapshot = (dbPath: string, outPath: string) => - createDbSnapshotWith(openLibsqlSyncDb, dbPath, outPath); -const mergeSnapshotIntoDb = (localDbPath: string, snapshotPath: string) => - mergeSnapshotIntoDbWith(openLibsqlSyncDb, localDbPath, snapshotPath); +// The engine executes on libsql in production; these merge tests run through +// that same driver. bun:sqlite is used only to build fixtures and inspect +// results. +import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js'; +import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js'; import { createImmersionDbFixture, insertFixtureSession, diff --git a/launcher/types.ts b/launcher/types.ts index 09a743af..fe9cfc17 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -114,17 +114,8 @@ export interface Args { useRofi: boolean; history: boolean; sync: boolean; - syncHost: string; - syncSnapshotPath: string; - syncMergePath: string; - syncDirection: 'both' | 'push' | 'pull'; - syncRemoteCmd: string; - syncDbPath: string; - syncForce: boolean; - syncJson: boolean; - syncCheck: boolean; - syncMakeTemp: boolean; - syncRemoveTempPath: string; + /** App-owned sync argv tokens forwarded verbatim to `--sync-cli sync`. */ + syncCliTokens: string[]; syncUi: boolean; logLevel: LogLevel; logRotation: LogRotation; diff --git a/src/core/services/stats-sync/cli-args.test.ts b/src/core/services/stats-sync/cli-args.test.ts index 1ad5c5fb..0ca729de 100644 --- a/src/core/services/stats-sync/cli-args.test.ts +++ b/src/core/services/stats-sync/cli-args.test.ts @@ -51,10 +51,11 @@ test('parseSyncCliTokens handles the temp-dir protocol modes', () => { assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error'); }); -test('parseSyncCliTokens mirrors launcher sync validation', () => { +test('parseSyncCliTokens owns the sync CLI validation rules', () => { assert.equal(parseSyncCliTokens([]).kind, 'error'); assert.equal(parseSyncCliTokens(['sync']).kind, 'error'); assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', '--snapshot', '/tmp/x', '--push']).kind, 'error'); assert.equal(parseSyncCliTokens(['sync', '--check']).kind, 'error'); assert.equal(parseSyncCliTokens(['sync', '--check', '--snapshot', '/tmp/x', 'h']).kind, 'error'); assert.equal(parseSyncCliTokens(['sync', 'h', '--snapshot', '/tmp/x']).kind, 'error'); diff --git a/src/core/services/stats-sync/cli-args.ts b/src/core/services/stats-sync/cli-args.ts index 0bf67600..f4cdecb4 100644 --- a/src/core/services/stats-sync/cli-args.ts +++ b/src/core/services/stats-sync/cli-args.ts @@ -16,8 +16,9 @@ export function extractSyncCliTokens(argv: readonly string[]): string[] | null { /** * Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the - * app's --sync-cli mode. Mirrors the launcher's `subminer sync` validation so - * both entry points accept the same command lines and fail the same way. + * app's --sync-cli mode. This is the single owner of sync CLI validation: + * the launcher forwards `subminer sync` tokens verbatim, so both entry + * points accept the same command lines and fail the same way. */ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' }; @@ -120,7 +121,6 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { return { kind: 'run', args: { - sync: true, syncHost: host, syncSnapshotPath: snapshot, syncMergePath: merge, diff --git a/src/core/services/stats-sync/db-path.ts b/src/core/services/stats-sync/db-path.ts new file mode 100644 index 00000000..bd194ec1 --- /dev/null +++ b/src/core/services/stats-sync/db-path.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveConfigFilePath } from '../../../config/path-resolution'; +import { parseConfigContent } from '../../../config/parse'; +import { getDefaultConfigDir } from '../../../shared/setup-state'; + +/** + * Default immersion stats database location, shared by the launcher's history + * command and the app's --sync-cli mode: honor a configured + * immersionTracking.dbPath (raw main-config read, tolerant of comments), else + * /immersion.sqlite. Electron- and libsql-free on purpose so the + * bun launcher can import it. + */ +export function resolveImmersionDbPath(): string { + const configPath = resolveConfigFilePath({ + appDataDir: process.env.APPDATA, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + existsSync: fs.existsSync, + }); + let configured = ''; + try { + const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf8')); + const tracking = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record).immersionTracking + : null; + if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) { + const dbPath = (tracking as Record).dbPath; + if (typeof dbPath === 'string') configured = dbPath.trim(); + } + } catch { + // no config or unreadable config → default location + } + if (configured) { + return configured.startsWith('~') + ? path.join(os.homedir(), configured.slice(1)) + : configured; + } + return path.join(getDefaultConfigDir(), 'immersion.sqlite'); +} diff --git a/src/core/services/stats-sync/driver.ts b/src/core/services/stats-sync/driver.ts deleted file mode 100644 index e5fe3d1b..00000000 --- a/src/core/services/stats-sync/driver.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Minimal SQLite driver surface the stats-sync engine runs against. The -// launcher binds it to bun:sqlite; the Electron app binds it to libsql. Both -// implementations must cache prepared statements per SQL string in query(): -// the merge runs one insert per copied row, so re-preparing would dominate -// merge time. - -export interface SyncDbRunResult { - changes: number; - lastInsertRowid: number | bigint; -} - -export interface SyncDbStatement { - run(...params: unknown[]): SyncDbRunResult; - get(...params: unknown[]): unknown; - all(...params: unknown[]): unknown[]; -} - -export interface SyncDbOpenOptions { - readonly?: boolean; - readwrite?: boolean; - create?: boolean; -} - -export interface SyncDb { - /** Prepare (or reuse a cached prepared statement for) the given SQL. */ - query(sql: string): SyncDbStatement; - /** Execute SQL that returns no rows (pragmas, transaction control). */ - exec(sql: string): void; - close(): void; -} - -export type OpenSyncDb = (dbPath: string, options: SyncDbOpenOptions) => SyncDb; - -export type SqlRow = Record; - -export function selectAll(db: SyncDb, sql: string, params: unknown[] = []): SqlRow[] { - return db.query(sql).all(...params) as SqlRow[]; -} - -export function selectOne(db: SyncDb, sql: string, params: unknown[] = []): SqlRow | undefined { - return (db.query(sql).get(...params) ?? undefined) as SqlRow | undefined; -} diff --git a/src/core/services/stats-sync/libsql-driver.ts b/src/core/services/stats-sync/libsql-driver.ts index 1938d557..6b49c7a1 100644 --- a/src/core/services/stats-sync/libsql-driver.ts +++ b/src/core/services/stats-sync/libsql-driver.ts @@ -1,25 +1,48 @@ import { Database } from '../immersion-tracker/sqlite'; -import type { OpenSyncDb, SyncDb, SyncDbRunResult, SyncDbStatement } from './driver'; +import type { SyncDbOpenOptions } from './wal-retry'; -interface LibsqlStatement { - run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint }; +export interface SyncDbRunResult { + changes: number; + lastInsertRowid: number | bigint; +} + +export interface SyncDbStatement { + run(...params: unknown[]): SyncDbRunResult; get(...params: unknown[]): unknown; all(...params: unknown[]): unknown[]; } +export interface SyncDb { + /** Prepare (or reuse a cached prepared statement for) the given SQL. */ + query(sql: string): SyncDbStatement; + /** Execute SQL that returns no rows (pragmas, transaction control). */ + exec(sql: string): void; + close(): void; +} + +export type SqlRow = Record; + +export function selectAll(db: SyncDb, sql: string, params: unknown[] = []): SqlRow[] { + return db.query(sql).all(...params) as SqlRow[]; +} + +export function selectOne(db: SyncDb, sql: string, params: unknown[] = []): SqlRow | undefined { + return (db.query(sql).get(...params) ?? undefined) as SqlRow | undefined; +} + interface LibsqlDatabase { - prepare(sql: string): LibsqlStatement; + prepare(sql: string): SyncDbStatement; exec(sql: string): unknown; close(): unknown; } /** - * libsql (better-sqlite3 API) binding of the SyncDb driver for the Electron - * app. prepare() is not cached by libsql, so query() keeps a per-connection + * libsql (better-sqlite3 API) SQLite connection for the stats-sync engine. + * prepare() is not cached by libsql, so query() keeps a per-connection * statement cache — the merge prepares a handful of statements and runs them - * once per copied row. + * once per copied row, so re-preparing would dominate merge time. */ -export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => { +export function openLibsqlSyncDb(dbPath: string, options: SyncDbOpenOptions): SyncDb { const db = new Database(dbPath, { readonly: options.readonly === true, fileMustExist: options.create !== true, @@ -27,22 +50,12 @@ export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => { const statements = new Map(); return { query(sql: string): SyncDbStatement { - const cached = statements.get(sql); - if (cached) return cached; - const statement = db.prepare(sql); - const wrapped: SyncDbStatement = { - run(...params: unknown[]): SyncDbRunResult { - return statement.run(...params); - }, - get(...params: unknown[]): unknown { - return statement.get(...params); - }, - all(...params: unknown[]): unknown[] { - return statement.all(...params); - }, - }; - statements.set(sql, wrapped); - return wrapped; + let statement = statements.get(sql); + if (!statement) { + statement = db.prepare(sql); + statements.set(sql, statement); + } + return statement; }, exec(sql: string): void { db.exec(sql); @@ -52,4 +65,4 @@ export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => { db.close(); }, }; -}; +} diff --git a/src/core/services/stats-sync/merge-catalog.ts b/src/core/services/stats-sync/merge-catalog.ts index 2b4fc56f..2c3d08cf 100644 --- a/src/core/services/stats-sync/merge-catalog.ts +++ b/src/core/services/stats-sync/merge-catalog.ts @@ -1,4 +1,4 @@ -import { selectAll, selectOne, type SqlRow, type SyncDb } from './driver'; +import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver'; import { insertRow, tableExists, type SyncMergeSummary } from './shared'; const ANIME_COPY_COLUMNS = [ diff --git a/src/core/services/stats-sync/merge-rollups.ts b/src/core/services/stats-sync/merge-rollups.ts index 3d007898..9204660a 100644 --- a/src/core/services/stats-sync/merge-rollups.ts +++ b/src/core/services/stats-sync/merge-rollups.ts @@ -1,4 +1,4 @@ -import { selectAll, type SqlRow, type SyncDb } from './driver'; +import { selectAll, type SqlRow, type SyncDb } from './libsql-driver'; import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './shared'; const LOCAL_DAY_EXPR = `CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)`; diff --git a/src/core/services/stats-sync/merge-sessions.ts b/src/core/services/stats-sync/merge-sessions.ts index a5324d44..ea862b07 100644 --- a/src/core/services/stats-sync/merge-sessions.ts +++ b/src/core/services/stats-sync/merge-sessions.ts @@ -1,5 +1,5 @@ import type { LexiconResolver } from './merge-catalog'; -import { selectAll, selectOne, type SqlRow, type SyncDb } from './driver'; +import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver'; import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './shared'; const SESSION_COPY_COLUMNS = [ diff --git a/src/core/services/stats-sync/merge.ts b/src/core/services/stats-sync/merge.ts index b7b0c1c6..0876625f 100644 --- a/src/core/services/stats-sync/merge.ts +++ b/src/core/services/stats-sync/merge.ts @@ -13,7 +13,7 @@ import { createEmptyMergeSummary, type SyncMergeSummary, } from './shared'; -import type { OpenSyncDb, SyncDb } from './driver'; +import { openLibsqlSyncDb, type SyncDb } from './libsql-driver'; export type { SyncMergeSummary } from './shared'; @@ -25,11 +25,7 @@ export type { SyncMergeSummary } from './shared'; * window is preserved on both sides. Idempotent: re-merging the same * snapshot is a no-op. */ -export function mergeSnapshotIntoDb( - open: OpenSyncDb, - localDbPath: string, - snapshotPath: string, -): SyncMergeSummary { +export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string): SyncMergeSummary { if (!fs.existsSync(localDbPath)) { throw new Error(`Local stats database not found: ${localDbPath}`); } @@ -37,10 +33,10 @@ export function mergeSnapshotIntoDb( throw new Error(`Snapshot database not found: ${snapshotPath}`); } - const remote = open(snapshotPath, { readonly: true }); + const remote = openLibsqlSyncDb(snapshotPath, { readonly: true }); let local: SyncDb; try { - local = open(localDbPath, { readwrite: true, create: false }); + local = openLibsqlSyncDb(localDbPath, { create: false }); } catch (error) { remote.close(); throw error; diff --git a/src/core/services/stats-sync/shared.ts b/src/core/services/stats-sync/shared.ts index c929b1c8..4adb1e06 100644 --- a/src/core/services/stats-sync/shared.ts +++ b/src/core/services/stats-sync/shared.ts @@ -2,9 +2,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { SCHEMA_VERSION } from '../immersion-tracker/types'; -import { resolveConfigDir } from '../../../config/path-resolution'; +import { getDefaultConfigDir } from '../../../shared/setup-state'; import { withReadonlyWalRetry } from './wal-retry'; -import { selectOne, type OpenSyncDb, type SyncDb } from './driver'; +import { openLibsqlSyncDb, selectOne, type SyncDb } from './libsql-driver'; export { SCHEMA_VERSION }; @@ -40,7 +40,7 @@ export function tableExists(db: SyncDb, tableName: string): boolean { ); } -export function readSchemaVersion(db: SyncDb): number | null { +function readSchemaVersion(db: SyncDb): number | null { if (!tableExists(db, 'imm_schema_version')) return null; const row = selectOne(db, 'SELECT MAX(schema_version) AS schema_version FROM imm_schema_version'); return typeof row?.schema_version === 'number' ? row.schema_version : null; @@ -78,14 +78,14 @@ export function insertRow( return Number(result.lastInsertRowid); } -export function createDbSnapshot(open: OpenSyncDb, dbPath: string, outPath: string): void { +export function createDbSnapshot(dbPath: string, outPath: string): void { if (!fs.existsSync(dbPath)) { throw new Error(`Stats database not found: ${dbPath}`); } fs.rmSync(outPath, { force: true }); fs.mkdirSync(path.dirname(outPath), { recursive: true }); withReadonlyWalRetry(dbPath, (options) => { - const db = open(dbPath, options); + const db = openLibsqlSyncDb(dbPath, options); try { assertMergeableSchema(db, 'Local'); db.query('VACUUM INTO ?').run(outPath); @@ -113,14 +113,7 @@ function isProcessAlive(pid: number): boolean { function statsDaemonStateCandidates(dbPath: string): string[] { const homeDir = os.homedir(); const candidates = new Set([path.join(path.dirname(dbPath), 'stats-daemon.json')]); - const configDir = resolveConfigDir({ - platform: process.platform, - appDataDir: process.env.APPDATA, - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir, - existsSync: fs.existsSync, - }); - candidates.add(path.join(configDir, 'stats-daemon.json')); + candidates.add(path.join(getDefaultConfigDir(), 'stats-daemon.json')); if (process.platform === 'darwin') { candidates.add( path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'), diff --git a/src/core/services/stats-sync/ssh.ts b/src/core/services/stats-sync/ssh.ts index ffb649ea..6c1ad5ca 100644 --- a/src/core/services/stats-sync/ssh.ts +++ b/src/core/services/stats-sync/ssh.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process'; +import { SYNC_CLI_FLAG } from './cli-args'; export interface RemoteRunResult { status: number; @@ -104,7 +105,7 @@ export type RemoteShellFlavor = 'posix' | 'windows-cmd' | 'windows-powershell'; */ export function detectRemoteShellFlavor( host: string, - runRemote: typeof runSsh = runSsh, + runRemote: (host: string, remoteCommand: string) => RemoteRunResult, ): RemoteShellFlavor { const posixProbe = runRemote(host, 'uname -s'); if (posixProbe.status === 0 && posixProbe.stdout.trim().length > 0) return 'posix'; @@ -144,15 +145,10 @@ const REMOTE_RUNTIME_PATH = 'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"'; // The Electron app answers the same launcher-style `sync ...` argv when -// invoked with --sync-cli, so a remote machine only needs the app installed; -// the command-line launcher is one candidate, not a requirement. -const APP_SYNC_CLI_FLAG = '--sync-cli'; - -interface RemoteCandidate { - invocation: string; -} - -function defaultRemoteCandidates(flavor: RemoteShellFlavor): RemoteCandidate[] { +// invoked with --sync-cli (SYNC_CLI_FLAG), so a remote machine only needs the +// app installed; the command-line launcher is one candidate, not a +// requirement. Each candidate is the remote invocation to probe, in order. +function defaultRemoteCandidates(flavor: RemoteShellFlavor): string[] { if (flavor === 'windows-cmd' || flavor === 'windows-powershell') { // cmd.exe expands %VAR% inside double quotes; PowerShell needs $env: and // the & call operator to run a quoted path. @@ -166,30 +162,30 @@ function defaultRemoteCandidates(flavor: RemoteShellFlavor): RemoteCandidate[] { : `& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe"`; return [ // Command-line launcher shim on PATH or in its default install dir. - { invocation: 'subminer' }, - { invocation: launcherShim }, + 'subminer', + launcherShim, // The app binary itself in sync-CLI mode (default NSIS install dir). - { invocation: `${appInstall} ${APP_SYNC_CLI_FLAG}` }, - { invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` }, + `${appInstall} ${SYNC_CLI_FLAG}`, + `SubMiner ${SYNC_CLI_FLAG}`, ]; } return [ // Command-line launcher (bun script) on PATH or in its default install dir. - { invocation: 'subminer' }, - { invocation: '~/.local/bin/subminer' }, + 'subminer', + '~/.local/bin/subminer', // The app binary itself in sync-CLI mode. - { invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` }, - { invocation: `/Applications/SubMiner.app/Contents/MacOS/SubMiner ${APP_SYNC_CLI_FLAG}` }, - { invocation: `~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${APP_SYNC_CLI_FLAG}` }, + `SubMiner ${SYNC_CLI_FLAG}`, + `/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`, + `~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`, ]; } -function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): RemoteCandidate[] { +function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): string[] { const quoted = quoteForRemoteShell(flavor, preferred); const invocation = flavor === 'windows-powershell' ? `& ${quoted}` : quoted; // App binaries also answer plain --help by opening the GUI-oriented help // path, so probe the sync-CLI shape first. - return [{ invocation: `${invocation} ${APP_SYNC_CLI_FLAG}` }, { invocation }]; + return [`${invocation} ${SYNC_CLI_FLAG}`, invocation]; } /** @@ -210,8 +206,7 @@ export function resolveRemoteSubminerCommand( ? preferredCandidates(flavor, preferred) : defaultRemoteCandidates(flavor); for (const candidate of candidates) { - const command = - flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate.invocation}` : candidate.invocation; + const command = flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate}` : candidate; const probe = runRemote( host, flavor === 'posix' ? `${command} --help >/dev/null 2>&1` : `${command} --help`, diff --git a/src/core/services/stats-sync/sync-flow.test.ts b/src/core/services/stats-sync/sync-flow.test.ts index 106b5017..548a559b 100644 --- a/src/core/services/stats-sync/sync-flow.test.ts +++ b/src/core/services/stats-sync/sync-flow.test.ts @@ -14,7 +14,6 @@ import { function makeContext(overrides: Partial = {}): SyncFlowContext { return { args: { - sync: true, syncHost: '', syncSnapshotPath: '', syncMergePath: '', @@ -43,17 +42,12 @@ function makeDeps(overrides: Partial = {}): SyncFlowDeps { return { createDbSnapshot: () => {}, mergeSnapshotIntoDb: () => createEmptyMergeSummary(), - formatMergeSummary: () => 'summary', findLiveStatsDaemonPid: () => null, assertSafeSshHost: () => {}, detectRemoteShellFlavor: () => 'posix', resolveRemoteSubminerCommand: () => 'subminer', runScp: () => {}, runSsh: () => ok(), - fail: (message: string): never => { - throw new Error(message); - }, - log: () => {}, canConnectUnixSocket: async () => false, realpathSync: (candidate) => candidate, mkdtempSync: (prefix) => fs.mkdtempSync(prefix), @@ -64,7 +58,6 @@ function makeDeps(overrides: Partial = {}): SyncFlowDeps { emitEvent: () => {}, recordHostSyncResult: () => {}, resolveDefaultDbPath: () => '/tracker.sqlite', - resolvePath: (value) => value, ...overrides, }; } @@ -128,12 +121,9 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a }, }); - assert.equal( - await runSyncFlow( - makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }), - deps, - ), - true, + await runSyncFlow( + makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }), + deps, ); assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite')); assert.ok( @@ -154,8 +144,6 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a () => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps), /sync requires a host, --snapshot , or --merge /, ); - - assert.equal(await runSyncFlow(makeContext({ sync: false }), deps), false); }); function makeHostDeps(calls: string[], overrides: Partial = {}): SyncFlowDeps { diff --git a/src/core/services/stats-sync/sync-flow.ts b/src/core/services/stats-sync/sync-flow.ts index 95483305..e21e40c4 100644 --- a/src/core/services/stats-sync/sync-flow.ts +++ b/src/core/services/stats-sync/sync-flow.ts @@ -1,12 +1,12 @@ import os from 'node:os'; import path from 'node:path'; +import { formatMergeSummary } from './merge'; import { quoteForRemoteShell } from './ssh'; import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh'; import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events'; import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store'; export interface SyncFlowArgs { - sync: boolean; syncHost: string; syncSnapshotPath: string; syncMergePath: string; @@ -27,14 +27,14 @@ export interface SyncFlowContext { } /** - * Everything the sync flow touches outside plain computation. The launcher - * binds these to bun:sqlite-backed engine functions and its own logger; the - * app's --sync-cli mode binds libsql and console output. Tests stub freely. + * Process/IO seams the sync flow needs stubbed in tests: SSH/scp, the DB + * snapshot/merge engine, filesystem, and progress/bookkeeping output. The + * app's --sync-cli mode (src/main/sync-cli.ts) provides the only production + * binding; pure helpers are imported directly. */ export interface SyncFlowDeps { createDbSnapshot: (dbPath: string, outPath: string) => void; mergeSnapshotIntoDb: (localDbPath: string, snapshotPath: string) => SyncMergeSummary; - formatMergeSummary: (summary: SyncMergeSummary) => string; findLiveStatsDaemonPid: (dbPath: string) => number | null; assertSafeSshHost: (host: string) => void; detectRemoteShellFlavor: ( @@ -49,8 +49,6 @@ export interface SyncFlowDeps { ) => string; runScp: (from: string, to: string) => void; runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult; - fail: (message: string) => never; - log: (level: string, configuredLevel: string, message: string) => void; canConnectUnixSocket: (socketPath: string) => Promise; realpathSync: (candidate: string) => string; mkdtempSync: (prefix: string) => string; @@ -61,7 +59,11 @@ export interface SyncFlowDeps { emitEvent: (event: SyncProgressEvent) => void; recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void; resolveDefaultDbPath: () => string; - resolvePath: (value: string) => string; +} + +/** Expand a leading `~` (as ssh users write paths) and make the path absolute. */ +function resolveCliPath(input: string): string { + return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input); } /** @@ -70,14 +72,14 @@ export interface SyncFlowDeps { * --remove-temp) so the flow never depends on mktemp/rm existing in the * remote shell — that is what makes Windows remotes work. */ -export const SYNC_TEMP_PREFIX = 'subminer-sync-'; +const SYNC_TEMP_PREFIX = 'subminer-sync-'; -export function makeSyncTempDir(mkdtempSync: SyncFlowDeps['mkdtempSync']): string { +function makeSyncTempDir(mkdtempSync: SyncFlowDeps['mkdtempSync']): string { return mkdtempSync(path.join(os.tmpdir(), SYNC_TEMP_PREFIX)); } /** Only dirs directly under os.tmpdir() with the sync prefix may be removed. */ -export function assertRemovableSyncTempDir(target: string): string { +function assertRemovableSyncTempDir(target: string): string { const resolved = path.resolve(target.trim()); const normalizeCase = (value: string) => process.platform === 'win32' ? value.toLowerCase() : value; @@ -89,18 +91,18 @@ export function assertRemovableSyncTempDir(target: string): string { return resolved; } -export function runMakeTempMode(deps: SyncFlowDeps): void { +function runMakeTempMode(deps: SyncFlowDeps): void { deps.consoleLog(makeSyncTempDir(deps.mkdtempSync)); } -export function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void { +function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void { const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath); deps.rmSync(target, { recursive: true, force: true }); } -export function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string { +function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string { const override = context.args.syncDbPath.trim(); - return override ? deps.resolvePath(override) : deps.resolveDefaultDbPath(); + return override ? resolveCliPath(override) : deps.resolveDefaultDbPath(); } function isTrackerDb(dbPath: string, deps: SyncFlowDeps): boolean { @@ -123,12 +125,12 @@ export async function ensureTrackerQuiescentFlow( if (!isTrackerDb(dbPath, deps)) return; const daemonPid = deps.findLiveStatsDaemonPid(dbPath); if (daemonPid !== null) { - deps.fail( + throw new Error( `The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`, ); } if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) { - deps.fail( + throw new Error( `An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`, ); } @@ -136,7 +138,7 @@ export async function ensureTrackerQuiescentFlow( // In --json mode every line on stdout is an NDJSON event: human console output // is silenced and events are written through the original console logger. -export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps { +function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps { const writeLine = deps.consoleLog; return { ...deps, @@ -146,13 +148,13 @@ export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps { }; } -export async function runSnapshotMode( +async function runSnapshotMode( context: SyncFlowContext, dbPath: string, deps: SyncFlowDeps, ): Promise { await deps.ensureTrackerQuiescent(context, dbPath); - const outPath = deps.resolvePath(context.args.syncSnapshotPath); + const outPath = resolveCliPath(context.args.syncSnapshotPath); deps.emitEvent({ type: 'stage', stage: 'snapshot-local', @@ -163,13 +165,13 @@ export async function runSnapshotMode( deps.consoleLog(outPath); } -export async function runMergeMode( +async function runMergeMode( context: SyncFlowContext, dbPath: string, deps: SyncFlowDeps, ): Promise { await deps.ensureTrackerQuiescent(context, dbPath); - const snapshotPath = deps.resolvePath(context.args.syncMergePath); + const snapshotPath = resolveCliPath(context.args.syncMergePath); deps.emitEvent({ type: 'stage', stage: 'merge-local', @@ -177,7 +179,7 @@ export async function runMergeMode( }); const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath); deps.emitEvent({ type: 'merge-summary', target: 'local', summary }); - deps.consoleLog(deps.formatMergeSummary(summary)); + deps.consoleLog(formatMergeSummary(summary)); } function formatHostSyncDetail( @@ -296,7 +298,9 @@ export async function runHostSync( const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh); const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor); const quote = (value: string) => quoteForRemoteShell(flavor, value); - deps.log('debug', args.logLevel, `Remote subminer command (${flavor}): ${remoteCmd}`); + if (args.logLevel === 'debug') { + console.error(`Remote subminer command (${flavor}): ${remoteCmd}`); + } const localTmpDir = makeSyncTempDir(deps.mkdtempSync); let remoteTmpDir = ''; @@ -365,7 +369,7 @@ export async function runHostSync( const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot); pulledSummary = summary; deps.emitEvent({ type: 'merge-summary', target: 'local', summary }); - deps.consoleLog(deps.formatMergeSummary(summary)); + deps.consoleLog(formatMergeSummary(summary)); } if (shouldPush) { @@ -425,34 +429,29 @@ export async function runHostSync( export async function runSyncFlow( context: SyncFlowContext, inputDeps: SyncFlowDeps, -): Promise { +): Promise { let deps = inputDeps; const { args } = context; - if (!args.sync) return false; if (args.syncJson) deps = withJsonEvents(deps); try { if (args.syncMakeTemp) { runMakeTempMode(deps); - if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null }); - return true; - } - if (args.syncRemoveTempPath) { + } else if (args.syncRemoveTempPath) { runRemoveTempMode(context, deps); - if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null }); - return true; - } - const dbPath = resolveSyncDbPath(context, deps); - if (args.syncCheck) { - await runCheckMode(context, deps); - } else if (args.syncSnapshotPath) { - await runSnapshotMode(context, dbPath, deps); - } else if (args.syncMergePath) { - await runMergeMode(context, dbPath, deps); - } else if (args.syncHost) { - await runHostSync(context, dbPath, deps); } else { - deps.fail('sync requires a host, --snapshot , or --merge .'); + const dbPath = resolveSyncDbPath(context, deps); + if (args.syncCheck) { + await runCheckMode(context, deps); + } else if (args.syncSnapshotPath) { + await runSnapshotMode(context, dbPath, deps); + } else if (args.syncMergePath) { + await runMergeMode(context, dbPath, deps); + } else if (args.syncHost) { + await runHostSync(context, dbPath, deps); + } else { + throw new Error('sync requires a host, --snapshot , or --merge .'); + } } } catch (error) { if (args.syncJson) { @@ -465,5 +464,4 @@ export async function runSyncFlow( throw error; } if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null }); - return true; } diff --git a/src/core/services/stats-sync/wal-retry.ts b/src/core/services/stats-sync/wal-retry.ts index b534a278..21cb9c4c 100644 --- a/src/core/services/stats-sync/wal-retry.ts +++ b/src/core/services/stats-sync/wal-retry.ts @@ -1,5 +1,16 @@ import fs from 'node:fs'; -import type { SyncDbOpenOptions } from './driver'; + +export interface SyncDbOpenOptions { + readonly?: boolean; + /** + * Read-write open. The libsql opener treats "not readonly" as read-write, + * but the launcher's bun:sqlite history reader passes these options straight + * to `new Database(...)`, where an explicit readwrite flag is what prevents + * bun from defaulting to readwrite+create. Keep it. + */ + readwrite?: boolean; + create?: boolean; +} /** * Opening a WAL-mode SQLite database strictly read-only fails when the -shm diff --git a/src/main.ts b/src/main.ts index 1235e171..c8226f6a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -536,6 +536,7 @@ import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-set import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime'; import { createSyncAutoScheduler } from './main/runtime/sync-auto-scheduler'; import { resolveSyncLauncherCommand, runSyncLauncher } from './main/runtime/sync-launcher-client'; +import { getSyncHostsPath } from './shared/sync/sync-hosts-store'; import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order'; import { isSameYoutubeMediaPath, @@ -2254,7 +2255,7 @@ const openSyncUiWindow = () => openSyncUiWindowHandler(); const syncUiRuntime = createSyncUiRuntime({ ipcMain, - hostsFilePath: path.join(USER_DATA_PATH, 'sync-hosts.json'), + hostsFilePath: getSyncHostsPath(USER_DATA_PATH), snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'), getDbPath: () => { const configured = getResolvedConfig().immersionTracking?.dbPath?.trim(); diff --git a/src/main/runtime/sync-launcher-client.test.ts b/src/main/runtime/sync-launcher-client.test.ts index 450d5d4e..0b6bee45 100644 --- a/src/main/runtime/sync-launcher-client.test.ts +++ b/src/main/runtime/sync-launcher-client.test.ts @@ -143,10 +143,20 @@ test('runSyncLauncher cancel kills the child and resolves as cancelled', async ( onEvent: () => {}, spawn, }); + const child = children[0]!; + child.kill = () => { + child.killed = true; + child.emit('exit', null, 'SIGTERM'); + return true; + }; handle.cancel(); - assert.equal(children[0]!.killed, true); + assert.equal(child.killed, true); - const result = await handle.done; + const result = await Promise.race([ + handle.done, + new Promise((resolve) => setTimeout(() => resolve(null), 25)), + ]); + assert.ok(result); assert.equal(result.ok, false); assert.match(result.error ?? '', /cancel/i); }); @@ -160,12 +170,18 @@ test('runSyncLauncher times out a child that never completes', async () => { spawn, timeoutMs: 1, }); + const child = children[0]!; + child.kill = () => { + child.killed = true; + child.emit('exit', null, 'SIGTERM'); + return true; + }; const result = await Promise.race([ handle.done, new Promise((resolve) => setTimeout(() => resolve(null), 25)), ]); - assert.equal(children[0]!.killed, true); + assert.equal(child.killed, true); assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' }); }); @@ -190,12 +206,11 @@ test('resolveSyncLauncherCommand self-spawns the app in --sync-cli mode', async execPath: '/opt/SubMiner/subminer-app', appPath: null, }); - assert.deepEqual(packaged.command, ['/opt/SubMiner/subminer-app', '--sync-cli']); - assert.equal(packaged.error, null); + assert.deepEqual(packaged, ['/opt/SubMiner/subminer-app', '--sync-cli']); const dev = resolveSyncLauncherCommand({ execPath: '/usr/bin/electron', appPath: '/home/u/SubMiner', }); - assert.deepEqual(dev.command, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']); + assert.deepEqual(dev, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']); }); diff --git a/src/main/runtime/sync-launcher-client.ts b/src/main/runtime/sync-launcher-client.ts index 0224c3ab..e1d424ed 100644 --- a/src/main/runtime/sync-launcher-client.ts +++ b/src/main/runtime/sync-launcher-client.ts @@ -26,11 +26,6 @@ export interface SyncLauncherRunHandle { done: Promise; } -export interface SyncLauncherResolution { - command: string[] | null; - error: string | null; -} - // Sync runs in a child copy of this app in headless --sync-cli mode: same // engine and NDJSON protocol as `subminer sync --json`, with no dependency on // bun or an installed command-line launcher. In dev runs process.execPath is @@ -40,13 +35,10 @@ export function resolveSyncLauncherCommand( execPath?: string; appPath?: string | null; } = {}, -): SyncLauncherResolution { +): string[] { const execPath = deps.execPath ?? process.execPath; const appPath = deps.appPath ?? null; - return { - command: appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG], - error: null, - }; + return appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG]; } export function runSyncLauncher(options: { @@ -142,7 +134,7 @@ export function runSyncLauncher(options: { child.on('exit', (code) => { exitObserved = true; exitCode = code; - if (resultEvent) settleFromExit(code); + if (resultEvent || terminationError) settleFromExit(code); }); child.on('close', settleFromExit); }); diff --git a/src/main/runtime/sync-ui-runtime.test.ts b/src/main/runtime/sync-ui-runtime.test.ts index f526d77e..94842052 100644 --- a/src/main/runtime/sync-ui-runtime.test.ts +++ b/src/main/runtime/sync-ui-runtime.test.ts @@ -35,7 +35,7 @@ function makeTestRig(root: string, overrides: Partial = {}) { hostsFilePath: path.join(root, 'sync-hosts.json'), snapshotsDir: path.join(root, 'snapshots'), getDbPath: () => path.join(root, 'immersion.sqlite'), - resolveLauncherCommand: () => ({ command: ['subminer'], error: null }), + resolveLauncherCommand: () => ['subminer'], runLauncher: (options): SyncLauncherRunHandle => { let finish: (result: { ok: boolean; error: string | null }) => void = () => {}; const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => { @@ -106,21 +106,24 @@ test('registerHandlers registers every sync-ui request channel', () => test('save/remove host round-trips through the hosts file', () => withTempDir(async (root) => { - const { invoke } = makeTestRig(root); - const saved = (await invoke('sync-ui:save-host', { + const { invoke, sent } = makeTestRig(root); + await invoke('sync-ui:save-host', { host: 'user@laptop', label: 'Laptop', direction: 'pull', autoSync: true, - })) as { hosts: Array<{ host: string; direction: string; autoSync: boolean }> }; - assert.equal(saved.hosts.length, 1); - assert.equal(saved.hosts[0]!.direction, 'pull'); - assert.equal(saved.hosts[0]!.autoSync, true); - - const removed = (await invoke('sync-ui:remove-host', 'user@laptop')) as { - hosts: unknown[]; + }); + assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed')); + const saved = (await invoke('sync-ui:get-snapshot')) as { + hosts: { hosts: Array<{ host: string; direction: string; autoSync: boolean }> }; }; - assert.equal(removed.hosts.length, 0); + assert.equal(saved.hosts.hosts.length, 1); + assert.equal(saved.hosts.hosts[0]!.direction, 'pull'); + assert.equal(saved.hosts.hosts[0]!.autoSync, true); + + await invoke('sync-ui:remove-host', 'user@laptop'); + const removed = (await invoke('sync-ui:get-snapshot')) as { hosts: { hosts: unknown[] } }; + assert.equal(removed.hosts.hosts.length, 0); })); test('save-host rejects invalid hosts', () => @@ -195,7 +198,7 @@ test('create-snapshot runs the launcher with a timestamped path under the snapsh test('delete-snapshot refuses paths outside the snapshots dir', () => withTempDir(async (root) => { - const { invoke } = makeTestRig(root); + const { invoke, sent } = makeTestRig(root); fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true }); const inside = path.join(root, 'snapshots', 'immersion-1.sqlite'); fs.writeFileSync(inside, 'x'); @@ -203,10 +206,13 @@ test('delete-snapshot refuses paths outside the snapshots dir', () => fs.writeFileSync(outside, 'x'); await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside)); - const remaining = (await invoke('sync-ui:delete-snapshot', inside)) as unknown[]; - assert.equal(remaining.length, 0); + await invoke('sync-ui:delete-snapshot', inside); assert.equal(fs.existsSync(inside), false); assert.equal(fs.existsSync(outside), true); + // The renderer relies on the state-changed broadcast to refresh its list. + assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed')); + const snapshot = (await invoke('sync-ui:get-snapshot')) as { snapshots: unknown[] }; + assert.equal(snapshot.snapshots.length, 0); })); test('check-host resolves with the check-result event', () => diff --git a/src/main/runtime/sync-ui-runtime.ts b/src/main/runtime/sync-ui-runtime.ts index 1fecbf71..c5784708 100644 --- a/src/main/runtime/sync-ui-runtime.ts +++ b/src/main/runtime/sync-ui-runtime.ts @@ -21,14 +21,10 @@ import type { SyncUiSnapshotFile, SyncUiStartResult, } from '../../types/sync-ui'; -import type { - SyncLauncherResolution, - SyncLauncherRunHandle, - SyncLauncherRunResult, -} from './sync-launcher-client'; +import type { SyncLauncherRunHandle, SyncLauncherRunResult } from './sync-launcher-client'; import { runSyncLauncher } from './sync-launcher-client'; -export interface SyncUiWindowLike { +interface SyncUiWindowLike { isDestroyed(): boolean; webContents: { send(channel: string, payload?: unknown): void }; } @@ -40,7 +36,7 @@ export interface SyncUiRuntimeDeps { hostsFilePath: string; snapshotsDir: string; getDbPath: () => string; - resolveLauncherCommand: () => SyncLauncherResolution; + resolveLauncherCommand: () => string[]; runLauncher: typeof runSyncLauncher; getWindow: () => SyncUiWindowLike | null; pickSnapshotFile: () => Promise; @@ -61,7 +57,7 @@ interface ActiveRun { // 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. -export function formatSnapshotName(nowMs: number): string { +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`; @@ -85,10 +81,9 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { return readSyncHostsState(deps.hostsFilePath); } - function writeState(state: SyncHostsState): SyncHostsState { + function writeState(state: SyncHostsState): void { writeSyncHostsState(deps.hostsFilePath, state); broadcastStateChanged(); - return state; } function listSnapshots(): SyncUiSnapshotFile[] { @@ -135,23 +130,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { }; } - function startRun( + // 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 } = {}, - ): SyncUiStartResult { + options: { + notify?: boolean; + timeoutMs?: number; + emitProgress?: boolean; + onEvent?: (event: SyncProgressEvent) => void; + } = {}, + ): { start: SyncUiStartResult; done: Promise | null } { if (currentRun) { return { - started: false, - runId: null, - reason: 'A sync operation is already running.', + start: { started: false, runId: null, reason: 'A sync operation is already running.' }, + done: null, }; } - const resolution = deps.resolveLauncherCommand(); - if (!resolution.command) { - return { started: false, runId: null, reason: resolution.error }; - } + const emitProgress = options.emitProgress ?? true; runCounter += 1; const runId = runCounter; const run: ActiveRun = { @@ -160,11 +159,15 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { host, resultSeen: false, handle: deps.runLauncher({ - command: resolution.command, + command: deps.resolveLauncherCommand(), args, + timeoutMs: options.timeoutMs, onEvent: (event: SyncProgressEvent) => { if (event.type === 'result') run.resultSeen = true; - sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event }); + options.onEvent?.(event); + if (emitProgress) { + sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event }); + } }, onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`), }), @@ -175,7 +178,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { 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 (!run.resultSeen) { + if (emitProgress && !run.resultSeen) { sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, @@ -202,7 +205,16 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { `[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`, ); }); - return { started: true, runId, reason: null }; + 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 } = {}) { @@ -238,57 +250,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { if (!isValidSyncHost(trimmed)) { return Promise.resolve(failed(`Invalid sync host: ${host}`)); } - if (currentRun) { - return Promise.resolve(failed('A sync operation is already running.')); - } - const resolution = deps.resolveLauncherCommand(); - if (!resolution.command) { - return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.')); - } - return new Promise((resolve) => { - let checkResult: SyncUiCheckResult | null = null; - runCounter += 1; - const runId = runCounter; - const handle = deps.runLauncher({ - command: resolution.command!, - args: ['sync', trimmed, '--check', '--json'], - timeoutMs: 30_000, - 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, - }; - } - }, - onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`), - }); - const run: ActiveRun = { - id: runId, - kind: 'check', - host: trimmed, - handle, - resultSeen: false, - }; - currentRun = run; - run.completion = handle.done - .then((result) => { - if (currentRun?.id === runId) currentRun = null; - resolve(checkResult ?? failed(result.error ?? 'Connection check failed.')); - broadcastStateChanged(); - }) - .catch((error) => { - if (currentRun?.id === runId) currentRun = null; - resolve(failed(error instanceof Error ? error.message : String(error))); - deps.log?.( - `[sync-ui check] Cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - }); + 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 { @@ -306,7 +288,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { return startRun('merge', null, args); } - function deleteSnapshot(filePath: string): SyncUiSnapshotFile[] { + 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')) { @@ -314,7 +296,6 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { } fs.rmSync(resolved, { force: true }); broadcastStateChanged(); - return listSnapshots(); } function cancelRun(): boolean { @@ -333,18 +314,18 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { 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())), - ); - deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => - writeState(removeSyncHost(readState(), String(host))), - ); + deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => { + writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs())); + }); + 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.'); } - return writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) }); + writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) }); }); deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) => runHostSync(request as SyncUiRunRequest), @@ -355,9 +336,9 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { 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.syncUiDeleteSnapshot, (_event, filePath) => { + deleteSnapshot(String(filePath)); + }); deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => { deps.revealPath(String(filePath)); return true; diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index e2ef1fbb..04d39c0a 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; -import net from 'node:net'; import { spawn, spawnSync } from 'node:child_process'; import { isLogFileEnabled } from '../../shared/log-files'; +import { canConnectSocket } from '../../shared/socket-probe'; import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; import { buildMpvMsgLevel } from '../../shared/mpv-logging-args'; import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts'; @@ -93,28 +93,6 @@ async function sleepMs(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } -async function canConnectSocket(socketPath: string): Promise { - return await new Promise((resolve) => { - const socket = net.createConnection(socketPath); - let settled = false; - - const finish = (value: boolean): void => { - if (settled) return; - settled = true; - try { - socket.destroy(); - } catch { - // ignore - } - resolve(value); - }; - - socket.once('connect', () => finish(true)); - socket.once('error', () => finish(false)); - socket.setTimeout(400, () => finish(false)); - }); -} - async function waitForSocketReady(socketPath: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/src/main/sync-cli.ts b/src/main/sync-cli.ts index 73bdc11e..2623ad26 100644 --- a/src/main/sync-cli.ts +++ b/src/main/sync-cli.ts @@ -1,17 +1,15 @@ import fs from 'node:fs'; -import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { parse as parseJsonc } from 'jsonc-parser'; -import { resolveConfigDir, resolveConfigFilePath } from '../config/path-resolution'; import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path'; +import { canConnectSocket } from '../shared/socket-probe'; +import { getDefaultConfigDir } from '../shared/setup-state'; import { extractSyncCliTokens, parseSyncCliTokens, syncCliUsage, } from '../core/services/stats-sync/cli-args'; +import { resolveImmersionDbPath } from '../core/services/stats-sync/db-path'; import { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared'; -import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge'; +import { mergeSnapshotIntoDb } from '../core/services/stats-sync/merge'; import { assertSafeSshHost, detectRemoteShellFlavor, @@ -19,7 +17,6 @@ import { runScp, runSsh, } from '../core/services/stats-sync/ssh'; -import { openLibsqlSyncDb } from '../core/services/stats-sync/libsql-driver'; import { ensureTrackerQuiescentFlow, runSyncFlow, @@ -40,75 +37,13 @@ export function shouldHandleSyncCliAtEntry( return extractSyncCliTokens(argv) !== null; } -function resolveAppConfigDir(): string { - return resolveConfigDir({ - platform: process.platform, - appDataDir: process.env.APPDATA, - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir: os.homedir(), - existsSync: fs.existsSync, - }); -} - -function resolveTildePath(input: string): string { - return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input); -} - -// Same resolution as the launcher: honor a configured immersionTracking.dbPath -// (raw config read, tolerant of comments), else /immersion.sqlite. -function resolveDefaultDbPath(): string { - const configPath = resolveConfigFilePath({ - appDataDir: process.env.APPDATA, - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir: os.homedir(), - existsSync: fs.existsSync, - }); - let configured = ''; - try { - const data = fs.readFileSync(configPath, 'utf8'); - const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data); - const tracking = - parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record).immersionTracking - : null; - if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) { - const dbPath = (tracking as Record).dbPath; - if (typeof dbPath === 'string') configured = dbPath.trim(); - } - } catch { - // no config or unreadable config → default location - } - if (configured) return resolveTildePath(configured); - return path.join(resolveAppConfigDir(), 'immersion.sqlite'); -} - -async function canConnectUnixSocket(socketPath: string): Promise { - return await new Promise((resolve) => { - const socket = net.createConnection(socketPath); - let settled = false; - const finish = (value: boolean) => { - if (settled) return; - settled = true; - try { - socket.destroy(); - } catch { - // ignore - } - resolve(value); - }; - socket.once('connect', () => finish(true)); - socket.once('error', () => finish(false)); - socket.setTimeout(400, () => finish(false)); - }); -} - function recordHostSyncResultToDisk( host: string, status: 'success' | 'error', detail: string | null, ): void { try { - const filePath = getSyncHostsPath(resolveAppConfigDir()); + const filePath = getSyncHostsPath(getDefaultConfigDir()); const state = recordSyncResult(readSyncHostsState(filePath), host, { atMs: Date.now(), status, @@ -122,24 +57,15 @@ function recordHostSyncResultToDisk( function buildSyncCliDeps(): SyncFlowDeps { const deps: SyncFlowDeps = { - createDbSnapshot: (dbPath, outPath) => createDbSnapshot(openLibsqlSyncDb, dbPath, outPath), - mergeSnapshotIntoDb: (localDbPath, snapshotPath) => - mergeSnapshotIntoDb(openLibsqlSyncDb, localDbPath, snapshotPath), - formatMergeSummary, + createDbSnapshot, + mergeSnapshotIntoDb, findLiveStatsDaemonPid, assertSafeSshHost, detectRemoteShellFlavor, resolveRemoteSubminerCommand, runScp, runSsh, - fail: (message: string): never => { - throw new Error(message); - }, - log: (level, configured, message) => { - if (level === 'debug' && configured !== 'debug') return; - console.error(message); - }, - canConnectUnixSocket, + canConnectUnixSocket: canConnectSocket, realpathSync: (candidate) => fs.realpathSync(candidate), mkdtempSync: (prefix) => fs.mkdtempSync(prefix), rmSync: (target, options) => fs.rmSync(target, options), @@ -149,8 +75,7 @@ function buildSyncCliDeps(): SyncFlowDeps { ensureTrackerQuiescentFlow(context, dbPath, deps), emitEvent: () => {}, recordHostSyncResult: recordHostSyncResultToDisk, - resolveDefaultDbPath, - resolvePath: resolveTildePath, + resolveDefaultDbPath: resolveImmersionDbPath, }; return deps; } diff --git a/src/preload-syncui.ts b/src/preload-syncui.ts index 308e02eb..6c389d09 100644 --- a/src/preload-syncui.ts +++ b/src/preload-syncui.ts @@ -1,25 +1,23 @@ import { contextBridge, ipcRenderer } from 'electron'; import { IPC_CHANNELS } from './shared/ipc/contracts'; import type { - SyncHostsState, SyncUiAPI, SyncUiCheckResult, SyncUiHostUpdateRequest, SyncUiProgressPayload, SyncUiRunRequest, SyncUiSnapshot, - SyncUiSnapshotFile, SyncUiStartResult, } from './types/sync-ui'; const syncUiAPI: SyncUiAPI = { getSnapshot: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot), - saveHost: (update: SyncUiHostUpdateRequest): Promise => + saveHost: (update: SyncUiHostUpdateRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update), - removeHost: (host: string): Promise => + removeHost: (host: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host), - setAutoSyncInterval: (minutes: number): Promise => + setAutoSyncInterval: (minutes: number): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSetAutoSyncInterval, minutes), runSync: (request: SyncUiRunRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request), @@ -30,7 +28,7 @@ const syncUiAPI: SyncUiAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot), mergeSnapshotFile: (path: string, force?: boolean): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiMergeSnapshotFile, path, force === true), - deleteSnapshot: (path: string): Promise => + deleteSnapshot: (path: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiDeleteSnapshot, path), revealSnapshot: (path: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRevealSnapshot, path), diff --git a/src/shared/socket-probe.ts b/src/shared/socket-probe.ts new file mode 100644 index 00000000..165fdb93 --- /dev/null +++ b/src/shared/socket-probe.ts @@ -0,0 +1,28 @@ +import net from 'node:net'; + +/** + * True when something is accepting connections on the unix socket / Windows + * named pipe (400 ms probe). Electron-free; shared by the sync CLI's + * running-app guard and the Windows mpv launch attach wait. + */ +export async function canConnectSocket(socketPath: string): Promise { + return await new Promise((resolve) => { + const socket = net.createConnection(socketPath); + let settled = false; + + const finish = (value: boolean): void => { + if (settled) return; + settled = true; + try { + socket.destroy(); + } catch { + // ignore + } + resolve(value); + }; + + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.setTimeout(400, () => finish(false)); + }); +} diff --git a/src/syncui/style.css b/src/syncui/style.css index 934d53e7..aa060e5f 100644 --- a/src/syncui/style.css +++ b/src/syncui/style.css @@ -278,18 +278,6 @@ input { color: var(--muted); } -.banner { - border-radius: 10px; - padding: 10px 14px; - font-size: 13px; -} - -.banner-warn { - border: 1px solid rgba(245, 169, 127, 0.4); - background: rgba(245, 169, 127, 0.08); - color: var(--warn); -} - .empty-note { padding: 14px 4px 4px; font-size: 13px; diff --git a/src/syncui/syncui.ts b/src/syncui/syncui.ts index a28a5946..b2d941fd 100644 --- a/src/syncui/syncui.ts +++ b/src/syncui/syncui.ts @@ -167,8 +167,8 @@ function handleProgress(payload: SyncUiProgressPayload): void { } else if (event.type === 'merge-summary') { appendMergeSummary(event.target, summarizeMergeCounts(event.summary)); } else if (event.type === 'result') { + // The run's completion handler broadcasts state-changed, which refreshes. showResult(event.ok, event.error); - void refresh(); } } @@ -240,7 +240,8 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement { button.title = direction.title; button.classList.toggle('active', entry.direction === direction.id); button.addEventListener('click', () => { - void api.saveHost({ host: entry.host, direction: direction.id }).then(refreshFromState); + // Every mutation triggers a state-changed broadcast, which refreshes. + void api.saveHost({ host: entry.host, direction: direction.id }); }); toggle.appendChild(button); } @@ -251,7 +252,7 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement { autoBox.type = 'checkbox'; autoBox.checked = entry.autoSync; autoBox.addEventListener('change', () => { - void api.saveHost({ host: entry.host, autoSync: autoBox.checked }).then(refreshFromState); + void api.saveHost({ host: entry.host, autoSync: autoBox.checked }); }); const autoText = document.createElement('span'); autoText.textContent = 'Auto-sync'; @@ -288,7 +289,7 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement { removeButton.textContent = 'Remove'; removeButton.addEventListener('click', () => { if (!window.confirm(`Remove ${entry.host} from saved devices?`)) return; - void api.removeHost(entry.host).then(refreshFromState); + void api.removeHost(entry.host); }); actions.append(syncButton, testButton, removeButton); @@ -367,7 +368,7 @@ function renderSnapshots(): void { remove.textContent = 'Delete'; remove.addEventListener('click', () => { if (!window.confirm(`Delete snapshot ${file.name}?`)) return; - void api.deleteSnapshot(file.path).then(refreshFromState); + void api.deleteSnapshot(file.path); }); actions.append(merge, reveal, remove); row.append(name, meta, actions); @@ -408,7 +409,6 @@ addHostButton.addEventListener('click', () => { newHostInput.value = ''; newLabelInput.value = ''; checkResultEl.classList.add('hidden'); - return refresh(); }) .catch((error: unknown) => { renderCheckResult({ @@ -446,7 +446,7 @@ testHostButton.addEventListener('click', () => { autoIntervalInput.addEventListener('change', () => { const minutes = Number(autoIntervalInput.value); if (!Number.isFinite(minutes) || minutes < 1) return; - void api.setAutoSyncInterval(Math.floor(minutes)).then(refreshFromState); + void api.setAutoSyncInterval(Math.floor(minutes)); }); cancelButton.addEventListener('click', () => { diff --git a/src/types/sync-ui.ts b/src/types/sync-ui.ts index 24e33528..a717dfd8 100644 --- a/src/types/sync-ui.ts +++ b/src/types/sync-ui.ts @@ -65,15 +65,15 @@ export interface SyncUiHostUpdateRequest { export interface SyncUiAPI { getSnapshot: () => Promise; - saveHost: (update: SyncUiHostUpdateRequest) => Promise; - removeHost: (host: string) => Promise; - setAutoSyncInterval: (minutes: number) => Promise; + saveHost: (update: SyncUiHostUpdateRequest) => Promise; + removeHost: (host: string) => Promise; + setAutoSyncInterval: (minutes: number) => Promise; runSync: (request: SyncUiRunRequest) => Promise; cancelRun: () => Promise; checkHost: (host: string) => Promise; createSnapshot: () => Promise; mergeSnapshotFile: (path: string, force?: boolean) => Promise; - deleteSnapshot: (path: string) => Promise; + deleteSnapshot: (path: string) => Promise; revealSnapshot: (path: string) => Promise; pickSnapshotFile: () => Promise; onProgress: (listener: (payload: SyncUiProgressPayload) => void) => () => void;