diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts index adc58a5e..bb2d31af 100644 --- a/launcher/commands/app-command.ts +++ b/launcher/commands/app-command.ts @@ -30,6 +30,10 @@ export function runAppPassthroughCommand( deps.runAppCommandWithInherit(appPath, ['--settings']); return true; } + if (args.syncUi) { + deps.runAppCommandWithInherit(appPath, ['--sync-window']); + return true; + } if (!args.appPassthrough) { return false; } diff --git a/src/cli/args.ts b/src/cli/args.ts index 49023985..f9f4ebd8 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -14,6 +14,7 @@ export interface CliArgs { togglePrimarySubtitleBar: boolean; yomitan: boolean; settings: boolean; + syncWindow: boolean; setup: boolean; show: boolean; hide: boolean; @@ -122,6 +123,7 @@ export function parseArgs(argv: string[]): CliArgs { togglePrimarySubtitleBar: false, yomitan: false, settings: false, + syncWindow: false, setup: false, show: false, hide: false, @@ -269,6 +271,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true; else if (arg === '--yomitan') args.yomitan = true; else if (arg === '--settings') args.settings = true; + else if (arg === '--sync-window') args.syncWindow = true; else if (arg === '--setup') args.setup = true; else if (arg === '--show') args.show = true; else if (arg === '--hide') args.hide = true; @@ -541,6 +544,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.togglePrimarySubtitleBar || args.yomitan || args.settings || + args.syncWindow || args.setup || args.show || args.hide || @@ -618,6 +622,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.togglePrimarySubtitleBar && !args.yomitan && !args.settings && + !args.syncWindow && !args.setup && !args.show && !args.hide && @@ -688,6 +693,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.togglePrimarySubtitleBar || args.yomitan || args.settings || + args.syncWindow || args.setup || args.copySubtitle || args.copySubtitleMultiple || @@ -744,6 +750,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean { !args.toggleVisibleOverlay && !args.togglePrimarySubtitleBar && !args.settings && + !args.syncWindow && !args.show && !args.hide && !args.setup && diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index eef31db4..fc88263f 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -16,6 +16,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { togglePrimarySubtitleBar: false, yomitan: false, settings: false, + syncWindow: false, setup: false, show: false, hide: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 495d1430..5c1185d9 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -21,6 +21,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, yomitan: false, settings: false, + syncWindow: false, setup: false, show: false, hide: false, @@ -137,6 +138,9 @@ function createDeps(overrides: Partial = {}) { openConfigSettingsWindow: () => { calls.push('openConfigSettingsWindow'); }, + openSyncUiWindow: () => { + calls.push('openSyncUiWindow'); + }, openFirstRunSetup: (force?: boolean) => { calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`); }, @@ -659,6 +663,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis openFirstRunSetup: () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, + openSyncUiWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 79d0edf2..8f5e257f 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -44,6 +44,7 @@ export interface CliCommandServiceDeps { openFirstRunSetup: (force?: boolean) => void; openYomitanSettingsDelayed: (delayMs: number) => void; openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; setVisibleOverlayVisible: (visible: boolean) => void; copyCurrentSubtitle: () => void; startPendingMultiCopy: (timeoutMs: number) => void; @@ -170,6 +171,7 @@ interface UiCliRuntime { openFirstRunSetup: (force?: boolean) => void; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -274,6 +276,7 @@ export function createCliCommandDepsRuntime( }, delayMs); }, openConfigSettingsWindow: options.ui.openConfigSettingsWindow, + openSyncUiWindow: options.ui.openSyncUiWindow, setVisibleOverlayVisible: options.overlay.setVisible, copyCurrentSubtitle: options.mining.copyCurrentSubtitle, startPendingMultiCopy: options.mining.startPendingMultiCopy, @@ -417,6 +420,8 @@ export function handleCliCommand( deps.openYomitanSettingsDelayed(1000); } else if (args.settings) { deps.openConfigSettingsWindow(); + } else if (args.syncWindow) { + deps.openSyncUiWindow(); } else if (args.show || args.showVisibleOverlay) { deps.setVisibleOverlayVisible(true); } else if (args.hide || args.hideVisibleOverlay) { diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index dff3cd8c..a238a9e3 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -16,6 +16,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { togglePrimarySubtitleBar: false, yomitan: false, settings: false, + syncWindow: false, setup: false, show: false, hide: false, diff --git a/src/main.ts b/src/main.ts index f32e0db0..df7fa74f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -529,8 +529,16 @@ import { createCreateConfigSettingsWindowHandler, createCreateFirstRunSetupWindowHandler, createCreateJellyfinSetupWindowHandler, + createCreateSyncUiWindowHandler, } from './main/runtime/setup-window-factory'; import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime'; +import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window'; +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 { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order'; import { isSameYoutubeMediaPath, @@ -902,6 +910,7 @@ const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG); function getOverlayForegroundSeparateWindows(): BrowserWindow[] { return [ appState.configSettingsWindow, + appState.syncUiWindow, appState.yomitanSettingsWindow, appState.anilistSetupWindow, appState.jellyfinSetupWindow, @@ -2227,6 +2236,68 @@ const configSettingsRuntime = createConfigSettingsRuntime({ configSettingsRuntime.registerHandlers(); const openConfigSettingsWindow = () => configSettingsRuntime.openWindow(); +const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({ + getSettingsWindow: () => appState.syncUiWindow, + setSettingsWindow: (window) => { + appState.syncUiWindow = window as BrowserWindow | null; + }, + createSettingsWindow: createCreateSyncUiWindowHandler({ + createBrowserWindow: (options) => new BrowserWindow(options), + preloadPath: path.join(__dirname, 'preload-syncui.js'), + }), + settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'), + promoteSettingsWindowAboveOverlay: (window) => + promoteSettingsWindowAboveOverlay(window as BrowserWindow), + log: (message) => logger.error(message), +}); +const openSyncUiWindow = () => openSyncUiWindowHandler(); + +const syncUiRuntime = createSyncUiRuntime({ + ipcMain, + hostsFilePath: path.join(USER_DATA_PATH, 'sync-hosts.json'), + snapshotsDir: path.join(USER_DATA_PATH, 'sync-snapshots'), + getDbPath: () => { + const configured = getResolvedConfig().immersionTracking?.dbPath?.trim(); + return configured || DEFAULT_IMMERSION_DB_PATH; + }, + resolveLauncherCommand: () => resolveSyncLauncherCommand(), + runLauncher: runSyncLauncher, + getWindow: () => appState.syncUiWindow, + pickSnapshotFile: async () => { + const result = await dialog.showOpenDialog({ + title: 'Choose a SubMiner stats snapshot', + filters: [{ name: 'SQLite snapshots', extensions: ['sqlite'] }], + properties: ['openFile'], + }); + return result.canceled ? null : (result.filePaths[0] ?? null); + }, + revealPath: (targetPath) => shell.showItemInFolder(targetPath), + nowMs: () => Date.now(), + log: (message) => logger.info(message), + notify: (payload) => + showOverlayNotification({ + title: payload.title, + body: payload.body, + variant: payload.variant, + }), +}); +syncUiRuntime.registerHandlers(); + +// Auto-sync only runs while no mpv session or app-owned stats server could be +// writing the immersion database; the launcher's quiescence guard covers +// writers this process does not know about. +const syncAutoScheduler = createSyncAutoScheduler({ + readState: () => syncUiRuntime.readState(), + isRunning: () => syncUiRuntime.isRunning(), + canAutoSync: () => appState.mpvClient === null && appState.statsServer === null, + triggerHostSync: (host, direction) => { + void syncUiRuntime.runHostSync({ host, direction }, { notify: true }); + }, + nowMs: () => Date.now(), + log: (message) => logger.info(message), +}); +syncAutoScheduler.start(); + const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({ platform: process.platform, dirname: __dirname, @@ -5983,6 +6054,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(), openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => openConfigSettingsWindow(), + openSyncUiWindow: () => openSyncUiWindow(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), @@ -6231,6 +6303,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = showWindowsMpvLauncherSetup: () => process.platform === 'win32', openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => openConfigSettingsWindow(), + openSyncUiWindow: () => openSyncUiWindow(), exportLogs: () => { void exportLogsFromTray(); }, diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 700ca6aa..2da17bb8 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -50,6 +50,7 @@ export interface CliCommandRuntimeServiceContext { ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer']; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -135,6 +136,7 @@ function createCliCommandDepsFromContext( openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, openConfigSettingsWindow: context.openConfigSettingsWindow, + openSyncUiWindow: context.openSyncUiWindow, cycleSecondarySubMode: context.cycleSecondarySubMode, openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, printHelp: context.printHelp, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index cc4f8029..293f0291 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -210,6 +210,7 @@ export interface CliCommandRuntimeServiceDepsParams { openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow']; + openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow']; cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode']; openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette']; printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp']; @@ -413,6 +414,7 @@ export function createCliCommandRuntimeServiceDeps( openFirstRunSetup: params.ui.openFirstRunSetup, openYomitanSettings: params.ui.openYomitanSettings, openConfigSettingsWindow: params.ui.openConfigSettingsWindow, + openSyncUiWindow: params.ui.openSyncUiWindow, cycleSecondarySubMode: params.ui.cycleSecondarySubMode, openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette, printHelp: params.ui.printHelp, diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index 3c58b62c..f1b34c95 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -74,6 +74,7 @@ test('build cli command context deps maps handlers and values', () => { }, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('config-settings'), + openSyncUiWindow: () => {}, cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), printHelp: () => calls.push('help'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 75c4afb6..0295bce7 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -48,6 +48,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, + openSyncUiWindow: deps.openSyncUiWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, printHelp: deps.printHelp, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index 0fbfaa3b..ea79a223 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -76,6 +76,7 @@ test('cli command context factory composes main deps and context handlers', () = runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, + openSyncUiWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 823370f7..ab7c0b66 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -105,6 +105,7 @@ test('cli command context main deps builder maps state and callbacks', async () }, openYomitanSettings: () => calls.push('open-yomitan'), openConfigSettingsWindow: () => calls.push('open-config-settings'), + openSyncUiWindow: () => {}, cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), printHelp: () => calls.push('help'), diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index c1354da6..8918873a 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: () => deps.openYomitanSettings(), openConfigSettingsWindow: () => deps.openConfigSettingsWindow(), + openSyncUiWindow: () => deps.openSyncUiWindow(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), printHelp: () => deps.printHelp(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index b946a8b5..c81f9934 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -58,6 +58,7 @@ function createDeps() { runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, + openSyncUiWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index 81159318..b625849f 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -53,6 +53,7 @@ export type CliCommandContextFactoryDeps = { ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer']; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -134,6 +135,7 @@ export function createCliCommandContext( ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, + openSyncUiWindow: deps.openSyncUiWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, printHelp: deps.printHelp, diff --git a/src/main/runtime/command-line-launcher.ts b/src/main/runtime/command-line-launcher.ts index 6dd770fd..0194b4b5 100644 --- a/src/main/runtime/command-line-launcher.ts +++ b/src/main/runtime/command-line-launcher.ts @@ -153,7 +153,7 @@ export async function detectBun(options: CommonOptions = {}): Promise { runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, + openSyncUiWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 9970e2e7..dd7bcf46 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -31,6 +31,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { togglePrimarySubtitleBar: false, yomitan: false, settings: false, + syncWindow: false, setup: false, show: false, hide: false, diff --git a/src/main/runtime/setup-window-factory.ts b/src/main/runtime/setup-window-factory.ts index 08e5227a..c656ae67 100644 --- a/src/main/runtime/setup-window-factory.ts +++ b/src/main/runtime/setup-window-factory.ts @@ -84,3 +84,18 @@ export function createCreateConfigSettingsWindowHandler(deps: { backgroundColor: '#24273a', }); } + +export function createCreateSyncUiWindowHandler(deps: { + createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; + preloadPath: string; +}) { + return createSetupWindowHandler(deps, { + width: 880, + height: 720, + title: 'SubMiner Sync', + show: false, + resizable: true, + preloadPath: deps.preloadPath, + backgroundColor: '#24273a', + }); +} diff --git a/src/main/runtime/sync-auto-scheduler.test.ts b/src/main/runtime/sync-auto-scheduler.test.ts new file mode 100644 index 00000000..09f0ad42 --- /dev/null +++ b/src/main/runtime/sync-auto-scheduler.test.ts @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createDefaultSyncHostsState, + upsertSyncHost, + recordSyncResult, + type SyncHostsState, +} from '../../shared/sync/sync-hosts-store'; +import { createSyncAutoScheduler } from './sync-auto-scheduler'; + +function makeState(): SyncHostsState { + let state = createDefaultSyncHostsState(); + state = upsertSyncHost(state, { host: 'auto-box', autoSync: true, direction: 'both' }, 0); + state = upsertSyncHost(state, { host: 'manual-box', autoSync: false }, 0); + return state; +} + +test('tick triggers a due auto-sync host with its saved direction', () => { + let state = makeState(); + state = upsertSyncHost(state, { host: 'auto-box', autoSync: true, direction: 'pull' }, 0); + const triggered: Array<{ host: string; direction: string }> = []; + const scheduler = createSyncAutoScheduler({ + readState: () => state, + isRunning: () => false, + canAutoSync: () => true, + triggerHostSync: (host, direction) => triggered.push({ host, direction }), + nowMs: () => 100 * 60_000, + }); + + scheduler.tick(); + assert.deepEqual(triggered, [{ host: 'auto-box', direction: 'pull' }]); +}); + +test('tick skips hosts synced more recently than the interval', () => { + let state = makeState(); + state = recordSyncResult(state, 'auto-box', { atMs: 90 * 60_000, status: 'success', detail: null }); + const triggered: string[] = []; + const scheduler = createSyncAutoScheduler({ + readState: () => state, + isRunning: () => false, + canAutoSync: () => true, + triggerHostSync: (host) => triggered.push(host), + nowMs: () => 100 * 60_000, // 10 minutes later, default interval 30 + }); + + scheduler.tick(); + assert.deepEqual(triggered, []); +}); + +test('tick does nothing while a run is active or auto sync is blocked', () => { + const state = makeState(); + const triggered: string[] = []; + const running = createSyncAutoScheduler({ + readState: () => state, + isRunning: () => true, + canAutoSync: () => true, + triggerHostSync: (host) => triggered.push(host), + nowMs: () => 100 * 60_000, + }); + running.tick(); + + const blocked = createSyncAutoScheduler({ + readState: () => state, + isRunning: () => false, + canAutoSync: () => false, + triggerHostSync: (host) => triggered.push(host), + nowMs: () => 100 * 60_000, + }); + blocked.tick(); + + assert.deepEqual(triggered, []); +}); + +test('tick triggers at most one host per tick', () => { + let state = makeState(); + state = upsertSyncHost(state, { host: 'second-box', autoSync: true }, 0); + const triggered: string[] = []; + const scheduler = createSyncAutoScheduler({ + readState: () => state, + isRunning: () => false, + canAutoSync: () => true, + triggerHostSync: (host) => triggered.push(host), + nowMs: () => 100 * 60_000, + }); + + scheduler.tick(); + assert.equal(triggered.length, 1); +}); + +test('start/stop manage the interval timer', () => { + const state = makeState(); + let setCalls = 0; + let clearCalls = 0; + const scheduler = createSyncAutoScheduler({ + readState: () => state, + isRunning: () => false, + canAutoSync: () => true, + triggerHostSync: () => {}, + nowMs: () => 0, + setIntervalFn: ((): NodeJS.Timeout => { + setCalls += 1; + return 42 as unknown as NodeJS.Timeout; + }) as unknown as typeof setInterval, + clearIntervalFn: (() => { + clearCalls += 1; + }) as unknown as typeof clearInterval, + }); + + scheduler.start(); + scheduler.start(); // idempotent + assert.equal(setCalls, 1); + scheduler.stop(); + assert.equal(clearCalls, 1); +}); diff --git a/src/main/runtime/sync-auto-scheduler.ts b/src/main/runtime/sync-auto-scheduler.ts new file mode 100644 index 00000000..844af953 --- /dev/null +++ b/src/main/runtime/sync-auto-scheduler.ts @@ -0,0 +1,50 @@ +import type { SyncDirection, SyncHostsState } from '../../shared/sync/sync-hosts-store'; + +export interface SyncAutoSchedulerDeps { + readState: () => SyncHostsState; + isRunning: () => boolean; + canAutoSync: () => boolean; + triggerHostSync: (host: string, direction: SyncDirection) => void; + nowMs: () => number; + log?: (message: string) => void; + tickIntervalMs?: number; + setIntervalFn?: typeof setInterval; + clearIntervalFn?: typeof clearInterval; +} + +const DEFAULT_TICK_INTERVAL_MS = 60_000; + +export function createSyncAutoScheduler(deps: SyncAutoSchedulerDeps) { + let timer: NodeJS.Timeout | null = null; + + function tick(): void { + if (deps.isRunning() || !deps.canAutoSync()) return; + const state = deps.readState(); + const intervalMs = state.autoSyncIntervalMinutes * 60_000; + const now = deps.nowMs(); + // A failed sync still updates lastSyncAtMs, so errors retry on the next + // interval instead of every tick. + const due = state.hosts.find( + (entry) => + entry.autoSync && (entry.lastSyncAtMs === null || now - entry.lastSyncAtMs >= intervalMs), + ); + if (!due) return; + deps.log?.(`Auto-sync starting for ${due.host} (${due.direction})`); + deps.triggerHostSync(due.host, due.direction); + } + + function start(): void { + if (timer !== null) return; + const setIntervalFn = deps.setIntervalFn ?? setInterval; + timer = setIntervalFn(tick, deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS); + } + + function stop(): void { + if (timer === null) return; + const clearIntervalFn = deps.clearIntervalFn ?? clearInterval; + clearIntervalFn(timer); + timer = null; + } + + return { tick, start, stop }; +} diff --git a/src/main/runtime/sync-launcher-client.test.ts b/src/main/runtime/sync-launcher-client.test.ts new file mode 100644 index 00000000..a4d0b4de --- /dev/null +++ b/src/main/runtime/sync-launcher-client.test.ts @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import type { SyncProgressEvent } from '../../shared/sync/sync-events'; +import { runSyncLauncher, type SyncLauncherSpawn } from './sync-launcher-client'; + +class FakeChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + kill(): boolean { + this.killed = true; + this.emit('close', null, 'SIGTERM'); + return true; + } +} + +function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; commands: string[][] } { + const children: FakeChild[] = []; + const commands: string[][] = []; + const spawn: SyncLauncherSpawn = (command, args) => { + commands.push([command, ...args]); + const child = new FakeChild(); + children.push(child); + return child as never; + }; + return { spawn, children, commands }; +} + +test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => { + const { spawn, children, commands } = makeSpawn(); + const events: SyncProgressEvent[] = []; + const handle = runSyncLauncher({ + command: ['subminer'], + args: ['sync', 'media-box', '--json'], + onEvent: (event) => events.push(event), + spawn, + }); + + const child = children[0]!; + child.stdout.emit('data', Buffer.from('{"type":"stage","stage":"snapshot-')); + child.stdout.emit('data', Buffer.from('local","message":"Snapshotting"}\n{"type":"result",')); + child.stdout.emit('data', Buffer.from('"ok":true,"error":null}\n')); + child.emit('close', 0, null); + + const result = await handle.done; + assert.deepEqual(commands[0], ['subminer', 'sync', 'media-box', '--json']); + assert.equal(events.length, 2); + assert.equal(events[0]!.type, 'stage'); + assert.deepEqual(events[1], { type: 'result', ok: true, error: null }); + assert.deepEqual(result, { ok: true, error: null }); +}); + +test('runSyncLauncher reports failures with the result event error or stderr tail', async () => { + const { spawn, children } = makeSpawn(); + const handle = runSyncLauncher({ + command: ['subminer'], + args: ['sync', 'media-box', '--json'], + onEvent: () => {}, + spawn, + }); + const child = children[0]!; + child.stdout.emit( + 'data', + Buffer.from('{"type":"result","ok":false,"error":"Remote merge failed"}\n'), + ); + child.stderr.emit('data', Buffer.from('[ERROR] Remote merge failed\n')); + child.emit('close', 1, null); + + const result = await handle.done; + assert.equal(result.ok, false); + assert.match(result.error ?? '', /Remote merge failed/); +}); + +test('runSyncLauncher falls back to stderr when no result event arrives', async () => { + const { spawn, children } = makeSpawn(); + const handle = runSyncLauncher({ + command: ['subminer'], + args: ['sync', '--snapshot', '/tmp/x.sqlite', '--json'], + onEvent: () => {}, + spawn, + }); + const child = children[0]!; + child.stderr.emit('data', Buffer.from('[ERROR] boom\n')); + child.emit('close', 1, null); + + const result = await handle.done; + assert.equal(result.ok, false); + assert.match(result.error ?? '', /boom/); +}); + +test('runSyncLauncher cancel kills the child and resolves as cancelled', async () => { + const { spawn, children } = makeSpawn(); + const handle = runSyncLauncher({ + command: ['subminer'], + args: ['sync', 'media-box', '--json'], + onEvent: () => {}, + spawn, + }); + handle.cancel(); + assert.equal(children[0]!.killed, true); + + const result = await handle.done; + assert.equal(result.ok, false); + assert.match(result.error ?? '', /cancel/i); +}); + +test('runSyncLauncher surfaces spawn errors', async () => { + const { spawn, children } = makeSpawn(); + const handle = runSyncLauncher({ + command: ['subminer'], + args: ['sync', 'media-box', '--json'], + onEvent: () => {}, + spawn, + }); + children[0]!.emit('error', new Error('ENOENT: subminer not found')); + + const result = await handle.done; + assert.equal(result.ok, false); + assert.match(result.error ?? '', /ENOENT/); +}); diff --git a/src/main/runtime/sync-launcher-client.ts b/src/main/runtime/sync-launcher-client.ts new file mode 100644 index 00000000..7013ed82 --- /dev/null +++ b/src/main/runtime/sync-launcher-client.ts @@ -0,0 +1,129 @@ +import { spawn as nodeSpawn } from 'node:child_process'; +import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events'; +import { findCommand } from './command-line-launcher-deps'; +import { resolveLauncherResourcePath } from './command-line-launcher'; + +export interface SyncLauncherChildLike { + stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null; + stderr: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null; + on(event: 'close', listener: (code: number | null, signal: string | null) => void): unknown; + on(event: 'error', listener: (error: Error) => void): unknown; + kill(signal?: NodeJS.Signals): boolean; +} + +export type SyncLauncherSpawn = (command: string, args: string[]) => SyncLauncherChildLike; + +export interface SyncLauncherRunResult { + ok: boolean; + error: string | null; +} + +export interface SyncLauncherRunHandle { + cancel: () => void; + done: Promise; +} + +export interface SyncLauncherResolution { + command: string[] | null; + error: string | null; +} + +// The launcher is a bun script: prefer the PATH-installed `subminer`, fall +// back to running the bundled resource through bun directly. +export function resolveSyncLauncherCommand( + deps: { + findCommand?: typeof findCommand; + resolveResourcePath?: () => string; + existsSync?: (candidate: string) => boolean; + } = {}, +): SyncLauncherResolution { + const find = deps.findCommand ?? findCommand; + const installed = find('subminer', {}); + if (installed) return { command: [installed], error: null }; + + const resourcePath = deps.resolveResourcePath + ? deps.resolveResourcePath() + : resolveLauncherResourcePath({}); + const bunPath = find('bun', {}); + if (bunPath && resourcePath) return { command: [bunPath, resourcePath], error: null }; + + return { + command: null, + error: + 'Could not find the subminer launcher. Install the command-line launcher from SubMiner setup (or install bun).', + }; +} + +export function runSyncLauncher(options: { + command: string[]; + args: string[]; + onEvent: (event: SyncProgressEvent) => void; + onStderr?: (text: string) => void; + spawn?: SyncLauncherSpawn; +}): SyncLauncherRunHandle { + const spawn = options.spawn ?? ((command, args) => nodeSpawn(command, args, { stdio: 'pipe' })); + const [executable, ...prefixArgs] = options.command; + const child = spawn(executable!, [...prefixArgs, ...options.args]); + + let stdoutBuffer = ''; + let stderrTail = ''; + let resultEvent: Extract | null = null; + let cancelled = false; + + child.stdout?.on('data', (chunk) => { + stdoutBuffer += chunk.toString(); + let newlineIndex = stdoutBuffer.indexOf('\n'); + while (newlineIndex !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + const event = parseSyncProgressLine(line); + if (event) { + if (event.type === 'result') resultEvent = event; + options.onEvent(event); + } + newlineIndex = stdoutBuffer.indexOf('\n'); + } + }); + child.stderr?.on('data', (chunk) => { + const text = chunk.toString(); + stderrTail = `${stderrTail}${text}`.slice(-4000); + options.onStderr?.(text); + }); + + const done = new Promise((resolve) => { + let settled = false; + const settle = (result: SyncLauncherRunResult): void => { + if (settled) return; + settled = true; + resolve(result); + }; + child.on('error', (error) => { + settle({ ok: false, error: error.message }); + }); + child.on('close', (code) => { + if (cancelled) { + settle({ ok: false, error: 'Sync cancelled.' }); + return; + } + if (code === 0) { + settle({ ok: true, error: null }); + return; + } + const error = + resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`); + settle({ ok: false, error }); + }); + }); + + return { + cancel: () => { + cancelled = true; + try { + child.kill('SIGTERM'); + } catch { + // process may already be gone + } + }, + done, + }; +} diff --git a/src/main/runtime/sync-ui-runtime.test.ts b/src/main/runtime/sync-ui-runtime.test.ts new file mode 100644 index 00000000..524be69f --- /dev/null +++ b/src/main/runtime/sync-ui-runtime.test.ts @@ -0,0 +1,269 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { SyncProgressEvent } from '../../shared/sync/sync-events'; +import type { SyncLauncherRunHandle } from './sync-launcher-client'; +import { createSyncUiRuntime, type SyncUiRuntimeDeps } from './sync-ui-runtime'; + +interface LauncherCall { + args: string[]; + onEvent: (event: SyncProgressEvent) => void; + finish: (result: { ok: boolean; error: string | null }) => void; + cancelled: boolean; +} + +function makeTestRig(root: string) { + const launcherCalls: LauncherCall[] = []; + const sent: Array<{ channel: string; payload: unknown }> = []; + const handlers = new Map unknown>(); + const windowStub = { + isDestroyed: () => false, + webContents: { + send: (channel: string, payload: unknown) => sent.push({ channel, payload }), + }, + }; + + const deps: SyncUiRuntimeDeps = { + ipcMain: { + handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => { + handlers.set(channel, listener); + }, + }, + hostsFilePath: path.join(root, 'sync-hosts.json'), + snapshotsDir: path.join(root, 'snapshots'), + getDbPath: () => path.join(root, 'immersion.sqlite'), + resolveLauncherCommand: () => ({ command: ['subminer'], error: null }), + runLauncher: (options): SyncLauncherRunHandle => { + let finish: (result: { ok: boolean; error: string | null }) => void = () => {}; + const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => { + finish = resolve; + }); + const call: LauncherCall = { + args: options.args, + onEvent: options.onEvent, + finish, + cancelled: false, + }; + launcherCalls.push(call); + return { + cancel: () => { + call.cancelled = true; + finish({ ok: false, error: 'Sync cancelled.' }); + }, + done, + }; + }, + getWindow: () => windowStub, + pickSnapshotFile: async () => null, + revealPath: () => {}, + nowMs: () => 1700000000000, + }; + + const runtime = createSyncUiRuntime(deps); + runtime.registerHandlers(); + const invoke = (channel: string, ...args: unknown[]): unknown => { + const handler = handlers.get(channel); + assert.ok(handler, `missing handler for ${channel}`); + return handler({}, ...args); + }; + return { runtime, invoke, launcherCalls, sent, handlers }; +} + +function withTempDir(fn: (dir: string) => Promise | void): Promise | void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-ui-test-')); + const cleanup = () => fs.rmSync(dir, { recursive: true, force: true }); + const result = fn(dir); + if (result instanceof Promise) return result.finally(cleanup); + cleanup(); + return result; +} + +test('registerHandlers registers every sync-ui request channel', () => + withTempDir((root) => { + const { handlers } = makeTestRig(root); + for (const channel of [ + 'sync-ui:get-snapshot', + 'sync-ui:save-host', + 'sync-ui:remove-host', + 'sync-ui:set-auto-sync-interval', + 'sync-ui:run-sync', + 'sync-ui:cancel-run', + 'sync-ui:check-host', + 'sync-ui:create-snapshot', + 'sync-ui:merge-snapshot-file', + 'sync-ui:delete-snapshot', + 'sync-ui:reveal-snapshot', + 'sync-ui:pick-snapshot-file', + ]) { + assert.ok(handlers.has(channel), `missing ${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', { + 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.equal(removed.hosts.length, 0); + })); + +test('save-host rejects invalid hosts', () => + withTempDir(async (root) => { + const { invoke } = makeTestRig(root); + await assert.rejects( + async () => invoke('sync-ui:save-host', { host: '-oProxyCommand=evil' }), + /Invalid sync host/, + ); + })); + +test('run-sync spawns the launcher, forwards progress, and rejects concurrent runs', () => + withTempDir(async (root) => { + const { invoke, launcherCalls, sent } = makeTestRig(root); + const start = (await invoke('sync-ui:run-sync', { + host: 'media-box', + direction: 'push', + force: true, + })) as { started: boolean; runId: number }; + assert.equal(start.started, true); + assert.deepEqual(launcherCalls[0]!.args, [ + 'sync', + 'media-box', + '--push', + '--force', + '--json', + ]); + + const second = (await invoke('sync-ui:run-sync', { host: 'other' })) as { + started: boolean; + reason: string | null; + }; + assert.equal(second.started, false); + assert.match(second.reason ?? '', /already running/i); + + launcherCalls[0]!.onEvent({ type: 'stage', stage: 'snapshot-local', message: 'Snap' }); + const progress = sent.filter((entry) => entry.channel === 'sync-ui:progress'); + assert.equal(progress.length, 1); + const payload = progress[0]!.payload as { runId: number; host: string; event: unknown }; + assert.equal(payload.runId, start.runId); + assert.equal(payload.host, 'media-box'); + + launcherCalls[0]!.finish({ ok: true, error: null }); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed')); + + const third = (await invoke('sync-ui:run-sync', { host: 'media-box' })) as { + started: boolean; + }; + assert.equal(third.started, true); + })); + +test('run-sync saves the host so previously used hosts persist', () => + withTempDir(async (root) => { + const { invoke, launcherCalls } = makeTestRig(root); + await invoke('sync-ui:run-sync', { host: 'media-box', direction: 'pull' }); + const snapshot = (await invoke('sync-ui:get-snapshot')) as { + hosts: { hosts: Array<{ host: string; direction: string }> }; + }; + assert.equal(snapshot.hosts.hosts[0]!.host, 'media-box'); + assert.equal(snapshot.hosts.hosts[0]!.direction, 'pull'); + launcherCalls[0]!.finish({ ok: true, error: null }); + })); + +test('create-snapshot runs the launcher with a timestamped path under the snapshots dir', () => + withTempDir(async (root) => { + const { invoke, launcherCalls } = makeTestRig(root); + const start = (await invoke('sync-ui:create-snapshot')) as { started: boolean }; + assert.equal(start.started, true); + const args = launcherCalls[0]!.args; + assert.equal(args[0], 'sync'); + assert.equal(args[1], '--snapshot'); + assert.ok(args[2]!.startsWith(path.join(root, 'snapshots'))); + assert.match(args[2]!, /immersion-.*\.sqlite$/); + assert.equal(args[3], '--json'); + launcherCalls[0]!.finish({ ok: true, error: null }); + })); + +test('delete-snapshot refuses paths outside the snapshots dir', () => + withTempDir(async (root) => { + const { invoke } = makeTestRig(root); + fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true }); + const inside = path.join(root, 'snapshots', 'immersion-1.sqlite'); + fs.writeFileSync(inside, 'x'); + const outside = path.join(root, 'immersion.sqlite'); + 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); + assert.equal(fs.existsSync(inside), false); + assert.equal(fs.existsSync(outside), true); + })); + +test('check-host resolves with the check-result event', () => + withTempDir(async (root) => { + const { invoke, launcherCalls } = makeTestRig(root); + const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{ + ok: boolean; + sshOk: boolean; + }>; + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--check', '--json']); + launcherCalls[0]!.onEvent({ + type: 'check-result', + host: 'media-box', + sshOk: true, + remoteCommand: 'subminer', + remoteVersion: '0.18.0', + ok: true, + error: null, + }); + launcherCalls[0]!.finish({ ok: true, error: null }); + const result = await pending; + assert.equal(result.ok, true); + assert.equal(result.sshOk, true); + })); + +test('cancel-run cancels the active run', () => + withTempDir(async (root) => { + const { invoke, launcherCalls } = makeTestRig(root); + await invoke('sync-ui:run-sync', { host: 'media-box' }); + const cancelled = (await invoke('sync-ui:cancel-run')) as boolean; + assert.equal(cancelled, true); + assert.equal(launcherCalls[0]!.cancelled, true); + })); + +test('get-snapshot lists snapshot files newest first', () => + withTempDir(async (root) => { + const { invoke } = makeTestRig(root); + const dir = path.join(root, 'snapshots'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'immersion-old.sqlite'), 'old'); + fs.writeFileSync(path.join(dir, 'ignore.txt'), 'x'); + fs.writeFileSync(path.join(dir, 'immersion-new.sqlite'), 'new'); + fs.utimesSync(path.join(dir, 'immersion-old.sqlite'), new Date(1000), new Date(1000)); + fs.utimesSync(path.join(dir, 'immersion-new.sqlite'), new Date(2000), new Date(2000)); + + const snapshot = (await invoke('sync-ui:get-snapshot')) as { + snapshots: Array<{ name: string }>; + dbPath: string; + run: { running: boolean }; + }; + assert.deepEqual( + snapshot.snapshots.map((file) => file.name), + ['immersion-new.sqlite', 'immersion-old.sqlite'], + ); + assert.equal(snapshot.run.running, false); + })); diff --git a/src/main/runtime/sync-ui-runtime.ts b/src/main/runtime/sync-ui-runtime.ts new file mode 100644 index 00000000..3b8129ca --- /dev/null +++ b/src/main/runtime/sync-ui-runtime.ts @@ -0,0 +1,343 @@ +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 { + SyncLauncherResolution, + SyncLauncherRunHandle, + SyncLauncherRunResult, +} from './sync-launcher-client'; +import { runSyncLauncher } from './sync-launcher-client'; + +export interface SyncUiWindowLike { + isDestroyed(): boolean; + webContents: { send(channel: string, payload?: unknown): void }; +} + +export interface SyncUiRuntimeDeps { + ipcMain: { + handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown; + }; + hostsFilePath: string; + snapshotsDir: string; + getDbPath: () => string; + resolveLauncherCommand: () => SyncLauncherResolution; + runLauncher: typeof runSyncLauncher; + getWindow: () => SyncUiWindowLike | null; + pickSnapshotFile: () => Promise; + revealPath: (targetPath: string) => void; + nowMs: () => number; + log?: (message: string) => void; + notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void; +} + +interface ActiveRun { + id: number; + kind: SyncUiRunKind; + host: string | null; + handle: SyncLauncherRunHandle; + resultSeen: boolean; +} + +function formatSnapshotName(nowMs: number): string { + const iso = new Date(nowMs).toISOString(); + const stamp = iso.slice(0, 19).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; + window.webContents.send(channel, payload); + } + + function broadcastStateChanged(): void { + sendToWindow(IPC_CHANNELS.event.syncUiStateChanged); + } + + function readState(): SyncHostsState { + return readSyncHostsState(deps.hostsFilePath); + } + + function writeState(state: SyncHostsState): SyncHostsState { + writeSyncHostsState(deps.hostsFilePath, state); + broadcastStateChanged(); + return state; + } + + 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 }; + } + + function getSnapshot(): SyncUiSnapshot { + const resolution = deps.resolveLauncherCommand(); + return { + dbPath: deps.getDbPath(), + hosts: readState(), + snapshotsDir: deps.snapshotsDir, + snapshots: listSnapshots(), + run: runState(), + launcherPath: resolution.command ? resolution.command.join(' ') : null, + }; + } + + function startRun( + kind: SyncUiRunKind, + host: string | null, + args: string[], + options: { notify?: boolean } = {}, + ): SyncUiStartResult { + if (currentRun) { + return { + started: false, + runId: null, + reason: 'A sync operation is already running.', + }; + } + const resolution = deps.resolveLauncherCommand(); + if (!resolution.command) { + return { started: false, runId: null, reason: resolution.error }; + } + runCounter += 1; + const runId = runCounter; + const run: ActiveRun = { + id: runId, + kind, + host, + resultSeen: false, + handle: deps.runLauncher({ + command: resolution.command, + args, + onEvent: (event: SyncProgressEvent) => { + if (event.type === 'result') run.resultSeen = true; + sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event }); + }, + onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`), + }), + }; + currentRun = run; + void 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 (!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', + }, + ); + } + }); + return { started: true, runId, reason: null }; + } + + function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) { + const host = request.host.trim(); + if (!isValidSyncHost(host)) { + return { started: false, runId: null, reason: `Invalid sync host: ${request.host}` }; + } + const direction: SyncDirection = request.direction ?? 'both'; + const args = ['sync', host]; + if (direction === 'push') args.push('--push'); + if (direction === 'pull') args.push('--pull'); + if (request.force) args.push('--force'); + args.push('--json'); + const result = 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()), + ); + } + 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}`)); + } + const resolution = deps.resolveLauncherCommand(); + if (!resolution.command) { + return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.')); + } + return new Promise((resolve) => { + let checkResult: SyncUiCheckResult | null = null; + const handle = deps.runLauncher({ + command: resolution.command!, + args: ['sync', trimmed, '--check', '--json'], + 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()}`), + }); + void handle.done.then((result) => { + resolve(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): SyncUiSnapshotFile[] { + 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(); + return listSnapshots(); + } + + function cancelRun(): boolean { + if (!currentRun) return false; + currentRun.handle.cancel(); + return true; + } + + 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.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) }); + }); + 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, + runHostSync, + checkHost, + cancelRun, + isRunning: () => currentRun !== null, + }; +} diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 083a0de1..840cacaf 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -71,6 +71,7 @@ test('build tray template handler wires actions and init guards', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), + openSyncUiWindow: () => calls.push('configuration'), exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, @@ -124,6 +125,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), + openSyncUiWindow: () => calls.push('configuration'), exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => false, diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 413a9de8..65f4f013 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -47,6 +47,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openConfigSettings: () => void; + openSyncUi: () => void; exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; @@ -66,6 +67,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; exportLogs: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; @@ -103,6 +105,9 @@ export function createBuildTrayMenuTemplateHandler(deps: { openConfigSettings: () => { deps.openConfigSettingsWindow(); }, + openSyncUi: () => { + deps.openSyncUiWindow(); + }, exportLogs: () => { deps.exportLogs(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index 5632313f..e4dcfd44 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), + openSyncUiWindow: () => calls.push('sync-ui'), exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, @@ -57,6 +58,7 @@ test('tray main deps builders return mapped handlers', () => { showWindowsMpvLauncherSetup: true, openYomitanSettings: () => calls.push('open-yomitan'), openConfigSettings: () => calls.push('open-configuration'), + openSyncUi: () => {}, exportLogs: () => calls.push('open-export-logs'), openJellyfinSetup: () => calls.push('open-jellyfin'), showJellyfinDiscovery: true, diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 2d21afb9..4cef541f 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openConfigSettings: () => void; + openSyncUi: () => void; exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; @@ -56,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; exportLogs: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; @@ -79,6 +81,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, + openSyncUiWindow: deps.openSyncUiWindow, exportLogs: deps.exportLogs, openJellyfinSetupWindow: deps.openJellyfinSetupWindow, isJellyfinConfigured: deps.isJellyfinConfigured, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index f1de38ba..53eebdb1 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, + openSyncUiWindow: () => {}, exportLogs: () => {}, openJellyfinSetupWindow: () => {}, isJellyfinConfigured: () => false, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index c104d065..3f2aecf3 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => { showWindowsMpvLauncherSetup: true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettings: () => calls.push('configuration'), + openSyncUi: () => calls.push('sync-ui'), exportLogs: () => calls.push('export-logs'), openJellyfinSetup: () => calls.push('jellyfin'), showJellyfinDiscovery: true, @@ -48,7 +49,7 @@ test('tray menu template contains expected entries and handlers', () => { quitApp: () => calls.push('quit'), }); - assert.equal(template.length, 13); + assert.equal(template.length, 14); assert.equal( template.some((entry) => entry.label === 'Open Runtime Options'), false, @@ -67,16 +68,19 @@ test('tray menu template contains expected entries and handlers', () => { assert.equal(template[1]!.label, 'Open Texthooker'); template[1]!.click?.(); assert.equal(template[5]!.label, 'Open SubMiner Settings'); - assert.equal(template[6]!.label, 'Export Logs'); + assert.equal(template[6]!.label, 'Sync Stats && History'); template[6]!.click?.(); - assert.equal(template[10]!.label, 'Check for Updates'); - template[10]!.click?.(); - template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); - template[12]!.click?.(); + assert.equal(template[7]!.label, 'Export Logs'); + template[7]!.click?.(); + assert.equal(template[11]!.label, 'Check for Updates'); + template[11]!.click?.(); + template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); + template[13]!.click?.(); assert.deepEqual(calls, [ 'jellyfin-discovery:true', 'help', 'texthooker', + 'sync-ui', 'export-logs', 'updates', 'separator', @@ -95,6 +99,7 @@ test('tray menu template omits first-run setup entry when setup is complete', () showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, + openSyncUi: () => undefined, exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: false, @@ -123,6 +128,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, + openSyncUi: () => undefined, exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: false, @@ -149,6 +155,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => { showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, + openSyncUi: () => undefined, exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: true, @@ -176,6 +183,7 @@ test('tray menu template renders a visible linux discovery check mark when activ showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, + openSyncUi: () => undefined, exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: true, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 7308cbb2..636c8ef8 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -40,6 +40,7 @@ export type TrayMenuActionHandlers = { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openConfigSettings: () => void; + openSyncUi: () => void; exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; @@ -103,6 +104,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): label: 'Open SubMiner Settings', click: handlers.openConfigSettings, }, + { + label: 'Sync Stats && History', + click: handlers.openSyncUi, + }, { label: 'Export Logs', click: handlers.exportLogs, diff --git a/src/main/state.ts b/src/main/state.ts index 34dcc707..728a1603 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -152,6 +152,7 @@ export interface AppState { jellyfinSetupWindow: BrowserWindow | null; firstRunSetupWindow: BrowserWindow | null; configSettingsWindow: BrowserWindow | null; + syncUiWindow: BrowserWindow | null; yomitanParserReadyPromise: Promise | null; yomitanParserInitPromise: Promise | null; mpvClient: MpvIpcClient | null; @@ -238,6 +239,7 @@ export function createAppState(values: AppStateInitialValues): AppState { jellyfinSetupWindow: null, firstRunSetupWindow: null, configSettingsWindow: null, + syncUiWindow: null, yomitanParserReadyPromise: null, yomitanParserInitPromise: null, mpvClient: null, diff --git a/src/preload-syncui.ts b/src/preload-syncui.ts new file mode 100644 index 00000000..74f73214 --- /dev/null +++ b/src/preload-syncui.ts @@ -0,0 +1,67 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import type { + SyncHostsState, + SyncUiAPI, + SyncUiCheckResult, + SyncUiHostUpdateRequest, + SyncUiProgressPayload, + SyncUiRunRequest, + SyncUiSnapshot, + SyncUiSnapshotFile, + SyncUiStartResult, +} from './types/sync-ui'; + +const SYNC_UI_IPC_CHANNELS = { + getSnapshot: 'sync-ui:get-snapshot', + saveHost: 'sync-ui:save-host', + removeHost: 'sync-ui:remove-host', + setAutoSyncInterval: 'sync-ui:set-auto-sync-interval', + runSync: 'sync-ui:run-sync', + cancelRun: 'sync-ui:cancel-run', + checkHost: 'sync-ui:check-host', + createSnapshot: 'sync-ui:create-snapshot', + mergeSnapshotFile: 'sync-ui:merge-snapshot-file', + deleteSnapshot: 'sync-ui:delete-snapshot', + revealSnapshot: 'sync-ui:reveal-snapshot', + pickSnapshotFile: 'sync-ui:pick-snapshot-file', + progress: 'sync-ui:progress', + stateChanged: 'sync-ui:state-changed', +} as const; + +const syncUiAPI: SyncUiAPI = { + getSnapshot: (): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.getSnapshot), + saveHost: (update: SyncUiHostUpdateRequest): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.saveHost, update), + removeHost: (host: string): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.removeHost, host), + setAutoSyncInterval: (minutes: number): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.setAutoSyncInterval, minutes), + runSync: (request: SyncUiRunRequest): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.runSync, request), + cancelRun: (): Promise => ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.cancelRun), + checkHost: (host: string): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.checkHost, host), + createSnapshot: (): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.createSnapshot), + mergeSnapshotFile: (path: string): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.mergeSnapshotFile, path), + deleteSnapshot: (path: string): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.deleteSnapshot, path), + revealSnapshot: (path: string): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.revealSnapshot, path), + pickSnapshotFile: (): Promise => + ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.pickSnapshotFile), + onProgress: (listener: (payload: SyncUiProgressPayload) => void): (() => void) => { + const handler = (_event: unknown, payload: SyncUiProgressPayload): void => listener(payload); + ipcRenderer.on(SYNC_UI_IPC_CHANNELS.progress, handler); + return () => ipcRenderer.removeListener(SYNC_UI_IPC_CHANNELS.progress, handler); + }, + onStateChanged: (listener: () => void): (() => void) => { + const handler = (): void => listener(); + ipcRenderer.on(SYNC_UI_IPC_CHANNELS.stateChanged, handler); + return () => ipcRenderer.removeListener(SYNC_UI_IPC_CHANNELS.stateChanged, handler); + }, +}; + +contextBridge.exposeInMainWorld('syncUiAPI', syncUiAPI); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index fc730fc3..2503fd35 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -119,6 +119,18 @@ export const IPC_CHANNELS = { getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names', getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names', getConfigSettingsYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name', + syncUiGetSnapshot: 'sync-ui:get-snapshot', + syncUiSaveHost: 'sync-ui:save-host', + syncUiRemoveHost: 'sync-ui:remove-host', + syncUiSetAutoSyncInterval: 'sync-ui:set-auto-sync-interval', + syncUiRunSync: 'sync-ui:run-sync', + syncUiCancelRun: 'sync-ui:cancel-run', + syncUiCheckHost: 'sync-ui:check-host', + syncUiCreateSnapshot: 'sync-ui:create-snapshot', + syncUiMergeSnapshotFile: 'sync-ui:merge-snapshot-file', + syncUiDeleteSnapshot: 'sync-ui:delete-snapshot', + syncUiRevealSnapshot: 'sync-ui:reveal-snapshot', + syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file', }, event: { subtitleSet: 'subtitle:set', @@ -149,6 +161,8 @@ export const IPC_CHANNELS = { configHotReload: 'config:hot-reload', overlayNotification: 'overlay:notification', notificationHistoryToggle: 'notification-history:toggle', + syncUiProgress: 'sync-ui:progress', + syncUiStateChanged: 'sync-ui:state-changed', }, } as const; diff --git a/src/types/sync-ui.ts b/src/types/sync-ui.ts new file mode 100644 index 00000000..c672e25a --- /dev/null +++ b/src/types/sync-ui.ts @@ -0,0 +1,82 @@ +import type { SyncProgressEvent } from '../shared/sync/sync-events'; +import type { SyncDirection, SyncHostsState } from '../shared/sync/sync-hosts-store'; + +export type { SyncProgressEvent } from '../shared/sync/sync-events'; +export type { SyncDirection, SyncHostEntry, SyncHostsState } from '../shared/sync/sync-hosts-store'; + +export interface SyncUiSnapshotFile { + path: string; + name: string; + sizeBytes: number; + modifiedAtMs: number; +} + +export type SyncUiRunKind = 'host-sync' | 'merge' | 'check' | 'snapshot'; + +export interface SyncUiRunState { + running: boolean; + runId: number | null; + kind: SyncUiRunKind | null; + host: string | null; +} + +export interface SyncUiSnapshot { + dbPath: string; + hosts: SyncHostsState; + snapshotsDir: string; + snapshots: SyncUiSnapshotFile[]; + run: SyncUiRunState; + launcherPath: string | null; +} + +export interface SyncUiRunRequest { + host: string; + direction?: SyncDirection; + force?: boolean; +} + +export interface SyncUiStartResult { + started: boolean; + runId: number | null; + reason: string | null; +} + +export interface SyncUiCheckResult { + host: string; + sshOk: boolean; + remoteCommand: string | null; + remoteVersion: string | null; + ok: boolean; + error: string | null; +} + +export interface SyncUiProgressPayload { + runId: number; + kind: SyncUiRunKind; + host: string | null; + event: SyncProgressEvent; +} + +export interface SyncUiHostUpdateRequest { + host: string; + label?: string | null; + direction?: SyncDirection; + autoSync?: boolean; +} + +export interface SyncUiAPI { + getSnapshot: () => 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) => Promise; + deleteSnapshot: (path: string) => Promise; + revealSnapshot: (path: string) => Promise; + pickSnapshotFile: () => Promise; + onProgress: (listener: (payload: SyncUiProgressPayload) => void) => () => void; + onStateChanged: (listener: () => void) => () => void; +}