feat(sync-ui): main-process runtime, window, tray entry, CLI wiring for sync window

This commit is contained in:
2026-07-11 18:07:48 -07:00
parent 0a3f76c0a8
commit 97aaf44b3c
37 changed files with 1351 additions and 7 deletions
+4
View File
@@ -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;
}
+7
View File
@@ -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 &&
+1
View File
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
+5
View File
@@ -21,6 +21,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -137,6 +138,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
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: () => {},
+5
View File
@@ -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) {
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
+73
View File
@@ -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();
},
+2
View File
@@ -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,
+2
View File
@@ -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,
@@ -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'),
@@ -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,
@@ -76,6 +76,7 @@ test('cli command context factory composes main deps and context handlers', () =
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -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'),
@@ -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(),
@@ -58,6 +58,7 @@ function createDeps() {
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+2
View File
@@ -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,
+1 -1
View File
@@ -153,7 +153,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
};
}
function resolveLauncherResourcePath(options: CommonOptions): string {
export function resolveLauncherResourcePath(options: CommonOptions): string {
const platformPath = pathModuleFor(platformOf(options));
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath =
@@ -52,6 +52,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -31,6 +31,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
+15
View File
@@ -84,3 +84,18 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
backgroundColor: '#24273a',
});
}
export function createCreateSyncUiWindowHandler<TWindow>(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',
});
}
@@ -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);
});
+50
View File
@@ -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 };
}
@@ -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/);
});
+129
View File
@@ -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<SyncLauncherRunResult>;
}
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<SyncProgressEvent, { type: 'result' }> | 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<SyncLauncherRunResult>((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,
};
}
+269
View File
@@ -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<string, (event: unknown, ...args: unknown[]) => 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> | void): Promise<void> | 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);
}));
+343
View File
@@ -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<string | null>;
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<SyncUiCheckResult> {
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,
};
}
@@ -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,
+5
View File
@@ -47,6 +47,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -66,6 +67,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -103,6 +105,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openConfigSettings: () => {
deps.openConfigSettingsWindow();
},
openSyncUi: () => {
deps.openSyncUiWindow();
},
exportLogs: () => {
deps.exportLogs();
},
+2
View File
@@ -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,
+3
View File
@@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -56,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -79,6 +81,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
exportLogs: deps.exportLogs,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
@@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
+14 -6
View File
@@ -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,
+5
View File
@@ -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,
+2
View File
@@ -152,6 +152,7 @@ export interface AppState {
jellyfinSetupWindow: BrowserWindow | null;
firstRunSetupWindow: BrowserWindow | null;
configSettingsWindow: BrowserWindow | null;
syncUiWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | 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,
+67
View File
@@ -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<SyncUiSnapshot> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.getSnapshot),
saveHost: (update: SyncUiHostUpdateRequest): Promise<SyncHostsState> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.saveHost, update),
removeHost: (host: string): Promise<SyncHostsState> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.removeHost, host),
setAutoSyncInterval: (minutes: number): Promise<SyncHostsState> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.setAutoSyncInterval, minutes),
runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.runSync, request),
cancelRun: (): Promise<boolean> => ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.cancelRun),
checkHost: (host: string): Promise<SyncUiCheckResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.checkHost, host),
createSnapshot: (): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.createSnapshot),
mergeSnapshotFile: (path: string): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.mergeSnapshotFile, path),
deleteSnapshot: (path: string): Promise<SyncUiSnapshotFile[]> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.deleteSnapshot, path),
revealSnapshot: (path: string): Promise<boolean> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.revealSnapshot, path),
pickSnapshotFile: (): Promise<string | null> =>
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);
+14
View File
@@ -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;
+82
View File
@@ -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<SyncUiSnapshot>;
saveHost: (update: SyncUiHostUpdateRequest) => Promise<SyncHostsState>;
removeHost: (host: string) => Promise<SyncHostsState>;
setAutoSyncInterval: (minutes: number) => Promise<SyncHostsState>;
runSync: (request: SyncUiRunRequest) => Promise<SyncUiStartResult>;
cancelRun: () => Promise<boolean>;
checkHost: (host: string) => Promise<SyncUiCheckResult>;
createSnapshot: () => Promise<SyncUiStartResult>;
mergeSnapshotFile: (path: string) => Promise<SyncUiStartResult>;
deleteSnapshot: (path: string) => Promise<SyncUiSnapshotFile[]>;
revealSnapshot: (path: string) => Promise<boolean>;
pickSnapshotFile: () => Promise<string | null>;
onProgress: (listener: (payload: SyncUiProgressPayload) => void) => () => void;
onStateChanged: (listener: () => void) => () => void;
}