Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)

This commit is contained in:
2026-07-13 18:56:51 -07:00
committed by GitHub
parent 66f8ca4f80
commit 49b926e08c
111 changed files with 6983 additions and 1130 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
+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'];
@@ -414,6 +415,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,
@@ -16,6 +16,9 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -47,7 +50,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 33);
assert.equal(calls.length, 34);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -68,6 +71,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
+4 -1
View File
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void;
@@ -33,7 +34,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
return (): Promise<void> => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -70,6 +72,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService();
return Promise.resolve(stopSyncAutoScheduler);
};
}
@@ -19,6 +19,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -113,6 +116,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => ({
@@ -173,6 +177,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
@@ -73,6 +74,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
stopSyncAutoScheduler: () => deps.stopSyncAutoScheduler(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -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: () => {},
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
@@ -45,13 +45,14 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
createSettingsWindow: () => created,
settingsHtmlPath: '/tmp/settings.html',
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
onClosed: () => calls.push('on-closed'),
});
assert.equal(open(), true);
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']);
assert.ok(handlers.closed);
handlers.closed();
assert.equal(calls.at(-1), 'set:null');
assert.deepEqual(calls.slice(-2), ['set:null', 'on-closed']);
});
test('createOpenConfigSettingsWindowHandler clears failed load window state', async () => {
@@ -13,6 +13,7 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
onClosed?: () => void;
log?: (message: string) => void;
}
@@ -42,6 +43,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
deps.setSettingsWindow(window);
window.on('closed', () => {
deps.setSettingsWindow(null);
deps.onClosed?.();
});
showAndFocus(window);
return true;
@@ -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',
});
}
@@ -15,7 +15,9 @@ test('app lifecycle runtime runner main deps builder maps lifecycle callbacks',
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
@@ -23,7 +23,9 @@ test('startup runtime handlers compose lifecycle runner and startup bootstrap ap
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
@@ -0,0 +1,139 @@
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,
triggerHostSync: (host, direction) => triggered.push({ host, direction }),
nowMs: () => 100 * 60_000,
});
scheduler.tick();
assert.deepEqual(triggered, [{ host: 'auto-box', direction: 'pull' }]);
});
test('tick does not require the resident app and stats writer to stop', () => {
const state = makeState();
const triggered: string[] = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
assert.doesNotThrow(() => scheduler.tick());
assert.deepEqual(triggered, ['auto-box']);
});
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,
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', () => {
const state = makeState();
const triggered: string[] = [];
const running = createSyncAutoScheduler({
readState: () => state,
isRunning: () => true,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
running.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,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
scheduler.tick();
assert.equal(triggered.length, 1);
});
test('tick logs a synchronous trigger failure and remains usable', () => {
const state = makeState();
const logs: string[] = [];
let attempts = 0;
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: () => {
attempts += 1;
throw new Error('spawn exploded');
},
nowMs: () => 100 * 60_000,
log: (message) => logs.push(message),
});
assert.doesNotThrow(() => scheduler.tick());
assert.doesNotThrow(() => scheduler.tick());
assert.equal(attempts, 2);
assert.ok(logs.some((message) => message.includes('spawn exploded')));
});
test('start/stop manage the interval timer', () => {
const state = makeState();
let setCalls = 0;
let clearCalls = 0;
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
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);
});
+55
View File
@@ -0,0 +1,55 @@
import type { SyncDirection, SyncHostsState } from '../../shared/sync/sync-hosts-store';
export interface SyncAutoSchedulerDeps {
readState: () => SyncHostsState;
isRunning: () => 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()) 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})`);
try {
deps.triggerHostSync(due.host, due.direction);
} catch (error) {
deps.log?.(
`Auto-sync failed to start: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
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,330 @@
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,
sanitizeSyncLauncherEnv,
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 preserves multibyte characters split across chunks', async () => {
const { spawn, children } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
const line = Buffer.from('{"type":"result","ok":false,"error":"進捗の同期に失敗"}\n');
// Split mid-character: the boundary lands inside a multibyte code point.
const boundary = line.indexOf(Buffer.from('進')) + 1;
child.stdout.emit('data', line.subarray(0, boundary));
child.stdout.emit('data', line.subarray(boundary));
child.emit('close', 1, null);
const result = await handle.done;
assert.deepEqual(events.at(-1), {
type: 'result',
ok: false,
error: '進捗の同期に失敗',
});
assert.equal(result.error, '進捗の同期に失敗');
});
test('runSyncLauncher settles after exit when close never arrives', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.stderr.emit('data', Buffer.from('[ERROR] remote refused\n'));
// The child is gone, but a descendant still holds the inherited stdio pipes,
// so `close` never fires.
child.emit('exit', 1, null);
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /remote refused/);
});
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 settles on exit when the terminal event is already parsed', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
});
children[0]!.stdout.emit(
'data',
Buffer.from('{"type":"result","ok":true,"error":null}\n'),
);
children[0]!.emit('exit', 0, null);
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: true, error: null });
});
test('runSyncLauncher waits for terminal NDJSON when exit precedes final stdout data', async () => {
const { spawn, children } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
let settled = false;
void handle.done.then(() => {
settled = true;
});
child.emit('exit', 0, null);
await Promise.resolve();
assert.equal(settled, false);
child.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n'));
assert.deepEqual(await handle.done, { ok: true, error: null });
assert.deepEqual(events.at(-1), { type: 'result', ok: true, error: null });
});
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,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
handle.cancel();
assert.equal(child.killed, true);
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.ok(result);
assert.equal(result.ok, false);
assert.match(result.error ?? '', /cancel/i);
});
test('runSyncLauncher cancel settles a child that already exited without a result', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
// The child is gone, but a descendant still holds the inherited stdio pipes,
// so `close` never arrives.
child.kill = () => {
child.killed = true;
return true;
};
child.emit('exit', null, 'SIGTERM');
handle.cancel();
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync cancelled.' });
assert.equal(child.killed, false);
});
test('runSyncLauncher times out a child that already exited without a result', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
timeoutMs: 1,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
return true;
};
child.emit('exit', null, 'SIGTERM');
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
assert.equal(child.killed, false);
});
test('runSyncLauncher times out a child that never completes', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
timeoutMs: 1,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.equal(child.killed, true);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
});
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/);
});
test('resolveSyncLauncherCommand self-spawns the app in --sync-cli mode', async () => {
const { resolveSyncLauncherCommand } = await import('./sync-launcher-client');
const packaged = resolveSyncLauncherCommand({
execPath: '/opt/SubMiner/subminer-app',
appPath: null,
});
assert.deepEqual(packaged, ['/opt/SubMiner/subminer-app', '--sync-cli']);
const dev = resolveSyncLauncherCommand({
execPath: '/usr/bin/electron',
appPath: '/home/u/SubMiner',
});
assert.deepEqual(dev, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']);
});
test('sanitizeSyncLauncherEnv removes transported parent startup arguments', () => {
const parentEnv = {
PATH: '/usr/bin',
ELECTRON_RUN_AS_NODE: '1',
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--start',
SUBMINER_APP_ARG_1: '--background',
};
assert.deepEqual(sanitizeSyncLauncherEnv(parentEnv), { PATH: '/usr/bin' });
assert.equal(parentEnv.SUBMINER_APP_ARGC, '2');
});
+221
View File
@@ -0,0 +1,221 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args';
/** How long a cancelled sync child gets to exit on SIGTERM before SIGKILL. */
const CANCEL_GRACE_MS = 5000;
/**
* How long a child that exited without terminal NDJSON gets to flush its
* remaining stdout before the exit is treated as authoritative.
*/
const EXIT_DRAIN_MS = 2000;
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: 'exit', 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 function sanitizeSyncLauncherEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
delete env.ELECTRON_RUN_AS_NODE;
delete env.SUBMINER_APP_ARGC;
for (const name of Object.keys(env)) {
if (name.startsWith('SUBMINER_APP_ARG_')) delete env[name];
}
return env;
}
// Sync runs in a child copy of this app in headless --sync-cli mode: same
// engine and NDJSON protocol as `subminer sync --json`, with no dependency on
// bun or an installed command-line launcher. In dev runs process.execPath is
// a bare electron binary, so the app path is passed as its entry argument.
export function resolveSyncLauncherCommand(
deps: {
execPath?: string;
appPath?: string | null;
} = {},
): string[] {
const execPath = deps.execPath ?? process.execPath;
const appPath = deps.appPath ?? null;
return appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG];
}
export function runSyncLauncher(options: {
command: string[];
args: string[];
onEvent: (event: SyncProgressEvent) => void;
onStderr?: (text: string) => void;
spawn?: SyncLauncherSpawn;
timeoutMs?: number;
}): SyncLauncherRunHandle {
const spawn =
options.spawn ??
((command, args) => {
// The child must boot as a full Electron app (its entry handles
// --sync-cli); a leaked ELECTRON_RUN_AS_NODE would turn it into node.
const env = sanitizeSyncLauncherEnv(process.env);
return nodeSpawn(command, args, { stdio: 'pipe', env });
});
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 terminationError: string | null = null;
let settleAfterTerminalEvent: (() => void) | null = null;
let settleAfterTermination: (() => boolean) | null = null;
// Decode incrementally: a multibyte character can straddle a chunk boundary,
// and a per-chunk toString() would corrupt it (sync payloads carry Japanese
// media titles and error detail).
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');
const decodeChunk = (decoder: StringDecoder, chunk: Buffer | string): string =>
typeof chunk === 'string' ? chunk : decoder.write(chunk);
child.stdout?.on('data', (chunk) => {
stdoutBuffer += decodeChunk(stdoutDecoder, chunk);
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;
settleAfterTerminalEvent?.();
}
options.onEvent(event);
}
newlineIndex = stdoutBuffer.indexOf('\n');
}
});
child.stderr?.on('data', (chunk) => {
const text = decodeChunk(stderrDecoder, chunk);
stderrTail = `${stderrTail}${text}`.slice(-4000);
options.onStderr?.(text);
});
let killTimer: ReturnType<typeof setTimeout> | null = null;
let operationTimer: ReturnType<typeof setTimeout> | null = null;
let drainTimer: ReturnType<typeof setTimeout> | null = null;
const clearTimers = (): void => {
if (killTimer !== null) clearTimeout(killTimer);
if (operationTimer !== null) clearTimeout(operationTimer);
if (drainTimer !== null) clearTimeout(drainTimer);
killTimer = null;
operationTimer = null;
drainTimer = null;
};
const done = new Promise<SyncLauncherRunResult>((resolve) => {
let settled = false;
let exitObserved = false;
let exitCode: number | null = null;
const settle = (result: SyncLauncherRunResult): void => {
if (settled) return;
settled = true;
clearTimers();
resolve(result);
};
child.on('error', (error) => {
settle({ ok: false, error: error.message });
});
const settleFromExit = (code: number | null) => {
if (terminationError) {
settle({ ok: false, error: terminationError });
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 });
};
settleAfterTerminalEvent = () => {
if (exitObserved) settleFromExit(exitCode);
};
settleAfterTermination = () => {
if (!exitObserved) return false;
settleFromExit(exitCode);
return true;
};
// Electron descendants can retain inherited stdio pipes after the main
// child exits, delaying `close` indefinitely. Once terminal NDJSON has
// arrived, `exit` is authoritative; keep `close` as the fallback.
child.on('exit', (code) => {
exitObserved = true;
exitCode = code;
if (resultEvent || terminationError) {
settleFromExit(code);
return;
}
// No terminal event yet: stdout may still be flushing, so give it a
// bounded window rather than waiting on a `close` that retained pipes
// can withhold forever.
drainTimer = setTimeout(() => {
drainTimer = null;
settleFromExit(code);
}, EXIT_DRAIN_MS);
drainTimer.unref?.();
});
child.on('close', settleFromExit);
});
// A sync child blocked on an ssh password prompt may ignore SIGTERM, so
// escalate to SIGKILL after a grace period.
const terminate = (error: string): void => {
if (terminationError) return;
terminationError = error;
// A child that already exited without terminal NDJSON is still waiting on
// `close`, which descendants holding inherited pipes can delay
// indefinitely. There is nothing left to signal, so settle now instead of
// leaving `done` (and the quit-time shutdown that awaits it) pending.
if (settleAfterTermination?.()) return;
killTimer = setTimeout(() => {
killTimer = null;
try {
child.kill('SIGKILL');
} catch {
// process may already be gone
}
}, CANCEL_GRACE_MS);
killTimer.unref?.();
try {
child.kill('SIGTERM');
} catch {
// process may already be gone
}
};
if (options.timeoutMs !== undefined) {
operationTimer = setTimeout(() => terminate('Sync operation timed out.'), options.timeoutMs);
operationTimer.unref?.();
}
return {
cancel: () => terminate('Sync cancelled.'),
done,
};
}
+23
View File
@@ -0,0 +1,23 @@
import {
readSyncHostsState,
writeSyncHostsState,
type SyncHostsState,
} from '../../shared/sync/sync-hosts-store';
interface SyncUiHostsStateDeps {
hostsFilePath: string;
broadcastStateChanged: () => void;
}
export function createSyncUiHostsState(deps: SyncUiHostsStateDeps) {
function readState(): SyncHostsState {
return readSyncHostsState(deps.hostsFilePath);
}
function writeState(state: SyncHostsState): void {
writeSyncHostsState(deps.hostsFilePath, state);
deps.broadcastStateChanged();
}
return { readState, writeState };
}
+63
View File
@@ -0,0 +1,63 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { removeSyncHost, upsertSyncHost } from '../../shared/sync/sync-hosts-store';
import type {
SyncUiHostUpdateRequest,
SyncUiRunRequest,
SyncUiSnapshot,
SyncUiStartResult,
} from '../../types/sync-ui';
interface SyncUiIpcHandlersDeps {
ipcMain: {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
nowMs: () => number;
revealPath: (targetPath: string) => void;
pickSnapshotFile: () => Promise<string | null>;
getSnapshot: () => SyncUiSnapshot;
readState: () => SyncUiSnapshot['hosts'];
writeState: (state: SyncUiSnapshot['hosts']) => void;
runHostSync: (request: SyncUiRunRequest) => SyncUiStartResult;
cancelRun: () => boolean;
checkHost: (host: string) => unknown;
createSnapshot: () => SyncUiStartResult;
mergeSnapshotFile: (filePath: string, force?: boolean) => SyncUiStartResult;
deleteSnapshot: (filePath: string) => void;
}
export function registerSyncUiIpcHandlers(deps: SyncUiIpcHandlersDeps): void {
const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => deps.getSnapshot());
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
deps.writeState(
upsertSyncHost(deps.readState(), update as SyncUiHostUpdateRequest, deps.nowMs()),
);
});
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
deps.writeState(removeSyncHost(deps.readState(), String(host)));
});
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
const value = Number(minutes);
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
}
deps.writeState({ ...deps.readState(), autoSyncIntervalMinutes: Math.floor(value) });
});
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
deps.runHostSync(request as SyncUiRunRequest),
);
deps.ipcMain.handle(channels.syncUiCancelRun, () => deps.cancelRun());
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => deps.checkHost(String(host)));
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => deps.createSnapshot());
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
deps.mergeSnapshotFile(String(filePath), force === true),
);
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
deps.deleteSnapshot(String(filePath));
});
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
deps.revealPath(String(filePath));
return true;
});
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
}
+183
View File
@@ -0,0 +1,183 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { isValidSyncHost } from '../../shared/sync/sync-hosts-store';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import type {
SyncUiCheckResult,
SyncUiRunKind,
SyncUiRunState,
SyncUiStartResult,
} from '../../types/sync-ui';
import {
runSyncLauncher,
type SyncLauncherRunHandle,
type SyncLauncherRunResult,
} from './sync-launcher-client';
interface ActiveRun {
id: number;
kind: SyncUiRunKind;
host: string | null;
handle: SyncLauncherRunHandle;
resultSeen: boolean;
completion?: Promise<void>;
}
interface SyncUiRunLifecycleDeps {
runLauncher: typeof runSyncLauncher;
resolveLauncherCommand: () => string[];
log?: (message: string) => void;
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
sendToWindow: (channel: string, payload?: unknown) => void;
broadcastStateChanged: () => void;
}
export function createSyncUiRunLifecycle(deps: SyncUiRunLifecycleDeps) {
let runCounter = 0;
let currentRun: ActiveRun | null = null;
function launchRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: {
notify?: boolean;
timeoutMs?: number;
emitProgress?: boolean;
onEvent?: (event: SyncProgressEvent) => void;
} = {},
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
if (currentRun) {
return {
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
done: null,
};
}
const emitProgress = options.emitProgress ?? true;
runCounter += 1;
const runId = runCounter;
const run: ActiveRun = {
id: runId,
kind,
host,
resultSeen: false,
handle: deps.runLauncher({
command: deps.resolveLauncherCommand(),
args,
timeoutMs: options.timeoutMs,
onEvent: (event: SyncProgressEvent) => {
if (event.type === 'result') run.resultSeen = true;
options.onEvent?.(event);
if (emitProgress) {
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
}
},
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
}),
};
currentRun = run;
run.completion = run.handle.done
.then((result: SyncLauncherRunResult) => {
if (currentRun?.id === runId) currentRun = null;
if (emitProgress && !run.resultSeen) {
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
runId,
kind,
host,
event: { type: 'result', ok: result.ok, error: result.error },
});
}
deps.broadcastStateChanged();
if (options.notify && deps.notify) {
const target = host ?? 'local database';
deps.notify(
result.ok
? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' }
: {
title: 'Sync failed',
body: `${target}: ${result.error ?? 'unknown error'}`,
variant: 'error',
},
);
}
})
.catch((error) => {
deps.log?.(
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
);
});
return { start: { started: true, runId, reason: null }, done: run.handle.done };
}
function startRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: { notify?: boolean } = {},
): SyncUiStartResult {
return launchRun(kind, host, args, options).start;
}
function checkHost(host: string): Promise<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}`));
}
let checkResult: SyncUiCheckResult | null = null;
const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], {
timeoutMs: 30_000,
emitProgress: false,
onEvent: (event) => {
if (event.type === 'check-result') {
checkResult = {
host: event.host,
sshOk: event.sshOk,
remoteCommand: event.remoteCommand,
remoteVersion: event.remoteVersion,
ok: event.ok,
error: event.error,
};
}
},
});
if (!start.started || !done) {
return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.'));
}
return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.'));
}
function cancelRun(): boolean {
if (!currentRun) return false;
currentRun.handle.cancel();
return true;
}
async function shutdown(): Promise<void> {
const run = currentRun;
if (!run) return;
run.handle.cancel();
await (run.completion ?? run.handle.done.then(() => undefined));
}
function runState(): SyncUiRunState {
return currentRun
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
: { running: false, runId: null, kind: null, host: null };
}
return {
startRun,
checkHost,
cancelRun,
shutdown,
runState,
isRunning: () => currentRun !== null,
};
}
+319
View File
@@ -0,0 +1,319 @@
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[];
timeoutMs: number | undefined;
onEvent: (event: SyncProgressEvent) => void;
finish: (result: { ok: boolean; error: string | null }) => void;
cancelled: boolean;
}
function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
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: () => ['subminer'],
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,
timeoutMs: options.timeoutMs,
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,
...overrides,
};
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, sent } = makeTestRig(root);
await invoke('sync-ui:save-host', {
host: 'user@laptop',
label: 'Laptop',
direction: 'pull',
autoSync: true,
});
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const saved = (await invoke('sync-ui:get-snapshot')) as {
hosts: { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
};
assert.equal(saved.hosts.hosts.length, 1);
assert.equal(saved.hosts.hosts[0]!.direction, 'pull');
assert.equal(saved.hosts.hosts[0]!.autoSync, true);
await invoke('sync-ui:remove-host', 'user@laptop');
const removed = (await invoke('sync-ui:get-snapshot')) as { hosts: { hosts: unknown[] } };
assert.equal(removed.hosts.hosts.length, 0);
}));
test('save-host rejects invalid hosts', () =>
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 uses live mode, 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',
})) 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, sent } = 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));
await invoke('sync-ui:delete-snapshot', inside);
assert.equal(fs.existsSync(inside), false);
assert.equal(fs.existsSync(outside), true);
// The renderer relies on the state-changed broadcast to refresh its list.
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const snapshot = (await invoke('sync-ui:get-snapshot')) as { snapshots: unknown[] };
assert.equal(snapshot.snapshots.length, 0);
}));
test('check-host resolves with the check-result event', () =>
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']);
assert.equal(launcherCalls[0]!.timeoutMs, 30_000);
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('check-host participates in active-run coordination and can be cancelled', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{ ok: boolean }>;
await new Promise((resolve) => setImmediate(resolve));
const blocked = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
started: boolean;
reason: string | null;
};
assert.equal(blocked.started, false);
assert.match(blocked.reason ?? '', /already running/i);
assert.equal(await invoke('sync-ui:cancel-run'), true);
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal((await pending).ok, false);
}));
test('shutdown cancels and awaits the active launcher run', () =>
withTempDir(async (root) => {
const { runtime, invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:create-snapshot');
await runtime.shutdown();
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal(runtime.isRunning(), false);
}));
test('post-run side-effect failures are logged without rejecting cleanup', () =>
withTempDir(async (root) => {
const logs: string[] = [];
const { invoke, launcherCalls } = makeTestRig(root, {
getWindow: () => ({
isDestroyed: () => false,
webContents: {
send: () => {
throw new Error('window gone');
},
},
}),
log: (message) => logs.push(message),
});
await invoke('sync-ui:create-snapshot');
launcherCalls[0]!.finish({ ok: true, error: null });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(logs.some((message) => message.includes('window gone')));
}));
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);
}));
+132
View File
@@ -0,0 +1,132 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
isValidSyncHost,
upsertSyncHost,
type SyncDirection,
} from '../../shared/sync/sync-hosts-store';
import type { SyncUiRunRequest, SyncUiSnapshot } from '../../types/sync-ui';
import { createSyncUiHostsState } from './sync-ui-hosts-state';
import { registerSyncUiIpcHandlers } from './sync-ui-ipc-handlers';
import { createSyncUiRunLifecycle } from './sync-ui-run-lifecycle';
import { runSyncLauncher } from './sync-launcher-client';
import { createSyncUiSnapshots } from './sync-ui-snapshots';
interface SyncUiWindowLike {
isDestroyed(): boolean;
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: () => string[];
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;
}
export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
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);
}
const hostsState = createSyncUiHostsState({
hostsFilePath: deps.hostsFilePath,
broadcastStateChanged,
});
const runLifecycle = createSyncUiRunLifecycle({
runLauncher: deps.runLauncher,
resolveLauncherCommand: deps.resolveLauncherCommand,
log: deps.log,
notify: deps.notify,
sendToWindow,
broadcastStateChanged,
});
const snapshots = createSyncUiSnapshots({
snapshotsDir: deps.snapshotsDir,
nowMs: deps.nowMs,
startRun: runLifecycle.startRun,
broadcastStateChanged,
});
function getSnapshot(): SyncUiSnapshot {
return {
dbPath: deps.getDbPath(),
hosts: hostsState.readState(),
snapshotsDir: deps.snapshotsDir,
snapshots: snapshots.listSnapshots(),
run: runLifecycle.runState(),
};
}
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');
// The sync window runs inside the resident app, so waiting for all app
// writers to disappear would make manual and automatic sync impossible.
// WAL keeps snapshots consistent, transactional merges serialize with live
// writes, and unfinished sessions wait for a later sync after finalization.
args.push('--force', '--json');
const result = runLifecycle.startRun('host-sync', host, args, options);
if (result.started) {
// Persist before launcher bookkeeping lands so the host appears immediately.
hostsState.writeState(
upsertSyncHost(
hostsState.readState(),
{ host, direction: request.direction },
deps.nowMs(),
),
);
}
return result;
}
function registerHandlers(): void {
registerSyncUiIpcHandlers({
ipcMain: deps.ipcMain,
nowMs: deps.nowMs,
revealPath: deps.revealPath,
pickSnapshotFile: deps.pickSnapshotFile,
getSnapshot,
readState: hostsState.readState,
writeState: hostsState.writeState,
runHostSync,
cancelRun: runLifecycle.cancelRun,
checkHost: runLifecycle.checkHost,
createSnapshot: snapshots.createSnapshot,
mergeSnapshotFile: snapshots.mergeSnapshotFile,
deleteSnapshot: snapshots.deleteSnapshot,
});
}
return {
registerHandlers,
getSnapshot,
readState: hostsState.readState,
runHostSync,
checkHost: runLifecycle.checkHost,
cancelRun: runLifecycle.cancelRun,
shutdown: runLifecycle.shutdown,
isRunning: runLifecycle.isRunning,
};
}
+74
View File
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import type { SyncUiSnapshotFile, SyncUiStartResult } from '../../types/sync-ui';
interface SyncUiSnapshotsDeps {
snapshotsDir: string;
nowMs: () => number;
startRun: (kind: 'snapshot' | 'merge', host: null, args: string[]) => SyncUiStartResult;
broadcastStateChanged: () => void;
}
function formatSnapshotName(nowMs: number): string {
// Milliseconds prevent snapshots taken in the same second from overwriting each other.
const iso = new Date(nowMs).toISOString();
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`;
}
export function createSyncUiSnapshots(deps: SyncUiSnapshotsDeps) {
function listSnapshots(): SyncUiSnapshotFile[] {
let names: string[];
try {
names = fs.readdirSync(deps.snapshotsDir);
} catch {
return [];
}
const files: SyncUiSnapshotFile[] = [];
for (const name of names) {
if (!name.endsWith('.sqlite')) continue;
const filePath = path.join(deps.snapshotsDir, name);
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) continue;
files.push({
path: filePath,
name,
sizeBytes: stat.size,
modifiedAtMs: stat.mtimeMs,
});
} catch {
// file disappeared between readdir and stat
}
}
files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
return files;
}
function createSnapshot(): SyncUiStartResult {
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
return deps.startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']);
}
function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult {
if (!fs.existsSync(filePath)) {
return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` };
}
const args = ['sync', '--merge', filePath];
if (force) args.push('--force');
args.push('--json');
return deps.startRun('merge', null, args);
}
function deleteSnapshot(filePath: string): void {
const resolved = path.resolve(filePath);
const dir = path.resolve(deps.snapshotsDir);
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
throw new Error('Refusing to delete a file outside the snapshots directory.');
}
fs.rmSync(resolved, { force: true });
deps.broadcastStateChanged();
}
return { listSnapshots, createSnapshot, mergeSnapshotFile, deleteSnapshot };
}
@@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.openSyncUi();
handlers.exportLogs();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true);
@@ -71,6 +72,7 @@ test('build tray template handler wires actions and init guards', () => {
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,
@@ -96,6 +98,7 @@ test('build tray template handler wires actions and init guards', () => {
'setup-forced',
'yomitan',
'configuration',
'sync-ui',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
@@ -124,6 +127,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,
+1 -23
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files';
import { canConnectSocket } from '../../shared/socket-probe';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import { buildMpvMsgLevel } from '../../shared/mpv-logging-args';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
@@ -93,28 +93,6 @@ async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}
async function waitForSocketReady(socketPath: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
+1 -1
View File
@@ -14,7 +14,7 @@ export interface AppLifecycleRuntimeRunnerParams {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
+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,
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { handleSyncCliAtEntry } from './sync-cli';
test('handleSyncCliAtEntry exits through the process exit dependency', async () => {
const exits: number[] = [];
const handled = await handleSyncCliAtEntry(
['SubMiner', '--sync-cli', 'sync', '--make-temp'],
{},
'0.18.0',
{
run: async () => 7,
exit: (code) => {
exits.push(code);
},
},
);
assert.equal(handled, true);
assert.deepEqual(exits, [7]);
});
test('handleSyncCliAtEntry ignores normal GUI startup', async () => {
const handled = await handleSyncCliAtEntry(['SubMiner', '--start'], {}, '0.18.0', {
run: async () => {
throw new Error('should not run');
},
exit: () => {
throw new Error('should not exit');
},
});
assert.equal(handled, false);
});
+140
View File
@@ -0,0 +1,140 @@
import fs from 'node:fs';
import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path';
import { canConnectSocket } from '../shared/socket-probe';
import { getDefaultConfigDir } from '../shared/setup-state';
import {
extractSyncCliTokens,
parseSyncCliTokens,
syncCliUsage,
} from '../core/services/stats-sync/cli-args';
import { resolveImmersionDbPath } from '../core/services/stats-sync/db-path';
import { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared';
import { mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
import {
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
} from '../core/services/stats-sync/ssh';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
type SyncFlowDeps,
} from '../core/services/stats-sync/sync-flow';
import {
recordSyncResult,
readSyncHostsState,
writeSyncHostsState,
getSyncHostsPath,
} from '../shared/sync/sync-hosts-store';
export function shouldHandleSyncCliAtEntry(
argv: readonly string[],
env: NodeJS.ProcessEnv,
): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
return extractSyncCliTokens(argv) !== null;
}
function recordHostSyncResultToDisk(
host: string,
status: 'success' | 'error',
detail: string | null,
): void {
try {
const filePath = getSyncHostsPath(getDefaultConfigDir());
const state = recordSyncResult(readSyncHostsState(filePath), host, {
atMs: Date.now(),
status,
detail,
});
writeSyncHostsState(filePath, state);
} catch {
// best effort: bookkeeping must never fail the sync itself
}
}
function buildSyncCliDeps(): SyncFlowDeps {
const deps: SyncFlowDeps = {
createDbSnapshot,
mergeSnapshotIntoDb,
findLiveStatsDaemonPid,
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
canConnectUnixSocket: canConnectSocket,
realpathSync: (candidate) => fs.realpathSync(candidate),
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options),
consoleLog: (message) => console.log(message),
writeStdout: (text) => process.stdout.write(text),
ensureTrackerQuiescent: async (context, dbPath) =>
ensureTrackerQuiescentFlow(context, dbPath, deps),
emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk,
resolveDefaultDbPath: resolveImmersionDbPath,
};
return deps;
}
/**
* Headless sync entry for the Electron app: answers the same launcher-style
* `sync ...` argv as `subminer sync`, so a machine only needs the app
* installed to participate in stats sync (locally or as an SSH remote). Runs
* before any window/display initialization and never touches Electron APIs.
*/
export async function runSyncCliFromProcess(
argv: readonly string[],
appVersion: string,
): Promise<number> {
const tokens = extractSyncCliTokens(argv);
const parsed = parseSyncCliTokens(tokens ?? []);
if (parsed.kind === 'help') {
console.log(syncCliUsage());
return 0;
}
if (parsed.kind === 'version') {
console.log(`SubMiner ${appVersion}`);
return 0;
}
if (parsed.kind === 'error') {
console.error(parsed.message);
console.error(`\n${syncCliUsage()}`);
return 2;
}
const context = {
args: parsed.args,
mpvSocketPath: getDefaultMpvSocketPath(process.platform),
};
try {
await runSyncFlow(context, buildSyncCliDeps());
return 0;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function handleSyncCliAtEntry(
argv: readonly string[],
env: NodeJS.ProcessEnv,
appVersion: string,
deps: {
run: typeof runSyncCliFromProcess;
exit: (code: number) => void;
} = {
run: runSyncCliFromProcess,
exit: (code) => process.exit(code),
},
): Promise<boolean> {
if (!shouldHandleSyncCliAtEntry(argv, env)) return false;
const exitCode = await deps.run(argv, appVersion);
// This path runs before app readiness and must not call app.exit(): on Linux
// that can throw and make the entrypoint fall back to full GUI startup.
deps.exit(exitCode);
return true;
}