mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 16:49:50 -07:00
Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}));
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user