mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
feat(sync-ui): main-process runtime, window, tray entry, CLI wiring for sync window
This commit is contained in:
@@ -30,6 +30,10 @@ export function runAppPassthroughCommand(
|
||||
deps.runAppCommandWithInherit(appPath, ['--settings']);
|
||||
return true;
|
||||
}
|
||||
if (args.syncUi) {
|
||||
deps.runAppCommandWithInherit(appPath, ['--sync-window']);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CliArgs {
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -122,6 +123,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -269,6 +271,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--sync-window') args.syncWindow = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
@@ -541,6 +544,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -618,6 +622,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -688,6 +693,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -744,6 +750,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -137,6 +138,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openConfigSettingsWindow: () => {
|
||||
calls.push('openConfigSettingsWindow');
|
||||
},
|
||||
openSyncUiWindow: () => {
|
||||
calls.push('openSyncUiWindow');
|
||||
},
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
@@ -659,6 +663,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
openFirstRunSetup: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface CliCommandServiceDeps {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -170,6 +171,7 @@ interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -274,6 +276,7 @@ export function createCliCommandDepsRuntime(
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||
@@ -417,6 +420,8 @@ export function handleCliCommand(
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.settings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.syncWindow) {
|
||||
deps.openSyncUiWindow();
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
} else if (args.hide || args.hideVisibleOverlay) {
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
+73
@@ -529,8 +529,16 @@ import {
|
||||
createCreateConfigSettingsWindowHandler,
|
||||
createCreateFirstRunSetupWindowHandler,
|
||||
createCreateJellyfinSetupWindowHandler,
|
||||
createCreateSyncUiWindowHandler,
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
|
||||
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
|
||||
import { createSyncAutoScheduler } from './main/runtime/sync-auto-scheduler';
|
||||
import {
|
||||
resolveSyncLauncherCommand,
|
||||
runSyncLauncher,
|
||||
} from './main/runtime/sync-launcher-client';
|
||||
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
@@ -902,6 +910,7 @@ const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
|
||||
return [
|
||||
appState.configSettingsWindow,
|
||||
appState.syncUiWindow,
|
||||
appState.yomitanSettingsWindow,
|
||||
appState.anilistSetupWindow,
|
||||
appState.jellyfinSetupWindow,
|
||||
@@ -2227,6 +2236,68 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
||||
configSettingsRuntime.registerHandlers();
|
||||
const openConfigSettingsWindow = () => configSettingsRuntime.openWindow();
|
||||
|
||||
const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({
|
||||
getSettingsWindow: () => appState.syncUiWindow,
|
||||
setSettingsWindow: (window) => {
|
||||
appState.syncUiWindow = window as BrowserWindow | null;
|
||||
},
|
||||
createSettingsWindow: createCreateSyncUiWindowHandler({
|
||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||
preloadPath: path.join(__dirname, 'preload-syncui.js'),
|
||||
}),
|
||||
settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'),
|
||||
promoteSettingsWindowAboveOverlay: (window) =>
|
||||
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
||||
log: (message) => logger.error(message),
|
||||
});
|
||||
const openSyncUiWindow = () => openSyncUiWindowHandler();
|
||||
|
||||
const syncUiRuntime = createSyncUiRuntime({
|
||||
ipcMain,
|
||||
hostsFilePath: path.join(USER_DATA_PATH, 'sync-hosts.json'),
|
||||
snapshotsDir: path.join(USER_DATA_PATH, 'sync-snapshots'),
|
||||
getDbPath: () => {
|
||||
const configured = getResolvedConfig().immersionTracking?.dbPath?.trim();
|
||||
return configured || DEFAULT_IMMERSION_DB_PATH;
|
||||
},
|
||||
resolveLauncherCommand: () => resolveSyncLauncherCommand(),
|
||||
runLauncher: runSyncLauncher,
|
||||
getWindow: () => appState.syncUiWindow,
|
||||
pickSnapshotFile: async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Choose a SubMiner stats snapshot',
|
||||
filters: [{ name: 'SQLite snapshots', extensions: ['sqlite'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
return result.canceled ? null : (result.filePaths[0] ?? null);
|
||||
},
|
||||
revealPath: (targetPath) => shell.showItemInFolder(targetPath),
|
||||
nowMs: () => Date.now(),
|
||||
log: (message) => logger.info(message),
|
||||
notify: (payload) =>
|
||||
showOverlayNotification({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
variant: payload.variant,
|
||||
}),
|
||||
});
|
||||
syncUiRuntime.registerHandlers();
|
||||
|
||||
// Auto-sync only runs while no mpv session or app-owned stats server could be
|
||||
// writing the immersion database; the launcher's quiescence guard covers
|
||||
// writers this process does not know about.
|
||||
const syncAutoScheduler = createSyncAutoScheduler({
|
||||
readState: () => syncUiRuntime.readState(),
|
||||
isRunning: () => syncUiRuntime.isRunning(),
|
||||
canAutoSync: () => appState.mpvClient === null && appState.statsServer === null,
|
||||
triggerHostSync: (host, direction) => {
|
||||
void syncUiRuntime.runHostSync({ host, direction }, { notify: true });
|
||||
},
|
||||
nowMs: () => Date.now(),
|
||||
log: (message) => logger.info(message),
|
||||
});
|
||||
syncAutoScheduler.start();
|
||||
|
||||
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
|
||||
platform: process.platform,
|
||||
dirname: __dirname,
|
||||
@@ -5983,6 +6054,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindow(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
@@ -6231,6 +6303,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindow(),
|
||||
exportLogs: () => {
|
||||
void exportLogsFromTray();
|
||||
},
|
||||
|
||||
@@ -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'];
|
||||
@@ -413,6 +414,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
printHelp: params.ui.printHelp,
|
||||
|
||||
@@ -74,6 +74,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
|
||||
@@ -48,6 +48,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -76,6 +76,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -105,6 +105,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
},
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
|
||||
@@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
printHelp: () => deps.printHelp(),
|
||||
|
||||
@@ -58,6 +58,7 @@ function createDeps() {
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createDefaultSyncHostsState,
|
||||
upsertSyncHost,
|
||||
recordSyncResult,
|
||||
type SyncHostsState,
|
||||
} from '../../shared/sync/sync-hosts-store';
|
||||
import { createSyncAutoScheduler } from './sync-auto-scheduler';
|
||||
|
||||
function makeState(): SyncHostsState {
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'auto-box', autoSync: true, direction: 'both' }, 0);
|
||||
state = upsertSyncHost(state, { host: 'manual-box', autoSync: false }, 0);
|
||||
return state;
|
||||
}
|
||||
|
||||
test('tick triggers a due auto-sync host with its saved direction', () => {
|
||||
let state = makeState();
|
||||
state = upsertSyncHost(state, { host: 'auto-box', autoSync: true, direction: 'pull' }, 0);
|
||||
const triggered: Array<{ host: string; direction: string }> = [];
|
||||
const scheduler = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
isRunning: () => false,
|
||||
canAutoSync: () => true,
|
||||
triggerHostSync: (host, direction) => triggered.push({ host, direction }),
|
||||
nowMs: () => 100 * 60_000,
|
||||
});
|
||||
|
||||
scheduler.tick();
|
||||
assert.deepEqual(triggered, [{ host: 'auto-box', direction: 'pull' }]);
|
||||
});
|
||||
|
||||
test('tick skips hosts synced more recently than the interval', () => {
|
||||
let state = makeState();
|
||||
state = recordSyncResult(state, 'auto-box', { atMs: 90 * 60_000, status: 'success', detail: null });
|
||||
const triggered: string[] = [];
|
||||
const scheduler = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
isRunning: () => false,
|
||||
canAutoSync: () => true,
|
||||
triggerHostSync: (host) => triggered.push(host),
|
||||
nowMs: () => 100 * 60_000, // 10 minutes later, default interval 30
|
||||
});
|
||||
|
||||
scheduler.tick();
|
||||
assert.deepEqual(triggered, []);
|
||||
});
|
||||
|
||||
test('tick does nothing while a run is active or auto sync is blocked', () => {
|
||||
const state = makeState();
|
||||
const triggered: string[] = [];
|
||||
const running = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
isRunning: () => true,
|
||||
canAutoSync: () => true,
|
||||
triggerHostSync: (host) => triggered.push(host),
|
||||
nowMs: () => 100 * 60_000,
|
||||
});
|
||||
running.tick();
|
||||
|
||||
const blocked = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
isRunning: () => false,
|
||||
canAutoSync: () => false,
|
||||
triggerHostSync: (host) => triggered.push(host),
|
||||
nowMs: () => 100 * 60_000,
|
||||
});
|
||||
blocked.tick();
|
||||
|
||||
assert.deepEqual(triggered, []);
|
||||
});
|
||||
|
||||
test('tick triggers at most one host per tick', () => {
|
||||
let state = makeState();
|
||||
state = upsertSyncHost(state, { host: 'second-box', autoSync: true }, 0);
|
||||
const triggered: string[] = [];
|
||||
const scheduler = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
isRunning: () => false,
|
||||
canAutoSync: () => true,
|
||||
triggerHostSync: (host) => triggered.push(host),
|
||||
nowMs: () => 100 * 60_000,
|
||||
});
|
||||
|
||||
scheduler.tick();
|
||||
assert.equal(triggered.length, 1);
|
||||
});
|
||||
|
||||
test('start/stop manage the interval timer', () => {
|
||||
const state = makeState();
|
||||
let setCalls = 0;
|
||||
let clearCalls = 0;
|
||||
const scheduler = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
isRunning: () => false,
|
||||
canAutoSync: () => true,
|
||||
triggerHostSync: () => {},
|
||||
nowMs: () => 0,
|
||||
setIntervalFn: ((): NodeJS.Timeout => {
|
||||
setCalls += 1;
|
||||
return 42 as unknown as NodeJS.Timeout;
|
||||
}) as unknown as typeof setInterval,
|
||||
clearIntervalFn: (() => {
|
||||
clearCalls += 1;
|
||||
}) as unknown as typeof clearInterval,
|
||||
});
|
||||
|
||||
scheduler.start();
|
||||
scheduler.start(); // idempotent
|
||||
assert.equal(setCalls, 1);
|
||||
scheduler.stop();
|
||||
assert.equal(clearCalls, 1);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SyncDirection, SyncHostsState } from '../../shared/sync/sync-hosts-store';
|
||||
|
||||
export interface SyncAutoSchedulerDeps {
|
||||
readState: () => SyncHostsState;
|
||||
isRunning: () => boolean;
|
||||
canAutoSync: () => boolean;
|
||||
triggerHostSync: (host: string, direction: SyncDirection) => void;
|
||||
nowMs: () => number;
|
||||
log?: (message: string) => void;
|
||||
tickIntervalMs?: number;
|
||||
setIntervalFn?: typeof setInterval;
|
||||
clearIntervalFn?: typeof clearInterval;
|
||||
}
|
||||
|
||||
const DEFAULT_TICK_INTERVAL_MS = 60_000;
|
||||
|
||||
export function createSyncAutoScheduler(deps: SyncAutoSchedulerDeps) {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
function tick(): void {
|
||||
if (deps.isRunning() || !deps.canAutoSync()) return;
|
||||
const state = deps.readState();
|
||||
const intervalMs = state.autoSyncIntervalMinutes * 60_000;
|
||||
const now = deps.nowMs();
|
||||
// A failed sync still updates lastSyncAtMs, so errors retry on the next
|
||||
// interval instead of every tick.
|
||||
const due = state.hosts.find(
|
||||
(entry) =>
|
||||
entry.autoSync && (entry.lastSyncAtMs === null || now - entry.lastSyncAtMs >= intervalMs),
|
||||
);
|
||||
if (!due) return;
|
||||
deps.log?.(`Auto-sync starting for ${due.host} (${due.direction})`);
|
||||
deps.triggerHostSync(due.host, due.direction);
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (timer !== null) return;
|
||||
const setIntervalFn = deps.setIntervalFn ?? setInterval;
|
||||
timer = setIntervalFn(tick, deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (timer === null) return;
|
||||
const clearIntervalFn = deps.clearIntervalFn ?? clearInterval;
|
||||
clearIntervalFn(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
return { tick, start, stop };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||
import { runSyncLauncher, type SyncLauncherSpawn } from './sync-launcher-client';
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
stdout = new EventEmitter();
|
||||
stderr = new EventEmitter();
|
||||
killed = false;
|
||||
kill(): boolean {
|
||||
this.killed = true;
|
||||
this.emit('close', null, 'SIGTERM');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; commands: string[][] } {
|
||||
const children: FakeChild[] = [];
|
||||
const commands: string[][] = [];
|
||||
const spawn: SyncLauncherSpawn = (command, args) => {
|
||||
commands.push([command, ...args]);
|
||||
const child = new FakeChild();
|
||||
children.push(child);
|
||||
return child as never;
|
||||
};
|
||||
return { spawn, children, commands };
|
||||
}
|
||||
|
||||
test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => {
|
||||
const { spawn, children, commands } = makeSpawn();
|
||||
const events: SyncProgressEvent[] = [];
|
||||
const handle = runSyncLauncher({
|
||||
command: ['subminer'],
|
||||
args: ['sync', 'media-box', '--json'],
|
||||
onEvent: (event) => events.push(event),
|
||||
spawn,
|
||||
});
|
||||
|
||||
const child = children[0]!;
|
||||
child.stdout.emit('data', Buffer.from('{"type":"stage","stage":"snapshot-'));
|
||||
child.stdout.emit('data', Buffer.from('local","message":"Snapshotting"}\n{"type":"result",'));
|
||||
child.stdout.emit('data', Buffer.from('"ok":true,"error":null}\n'));
|
||||
child.emit('close', 0, null);
|
||||
|
||||
const result = await handle.done;
|
||||
assert.deepEqual(commands[0], ['subminer', 'sync', 'media-box', '--json']);
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events[0]!.type, 'stage');
|
||||
assert.deepEqual(events[1], { type: 'result', ok: true, error: null });
|
||||
assert.deepEqual(result, { ok: true, error: null });
|
||||
});
|
||||
|
||||
test('runSyncLauncher reports failures with the result event error or stderr tail', async () => {
|
||||
const { spawn, children } = makeSpawn();
|
||||
const handle = runSyncLauncher({
|
||||
command: ['subminer'],
|
||||
args: ['sync', 'media-box', '--json'],
|
||||
onEvent: () => {},
|
||||
spawn,
|
||||
});
|
||||
const child = children[0]!;
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('{"type":"result","ok":false,"error":"Remote merge failed"}\n'),
|
||||
);
|
||||
child.stderr.emit('data', Buffer.from('[ERROR] Remote merge failed\n'));
|
||||
child.emit('close', 1, null);
|
||||
|
||||
const result = await handle.done;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /Remote merge failed/);
|
||||
});
|
||||
|
||||
test('runSyncLauncher falls back to stderr when no result event arrives', async () => {
|
||||
const { spawn, children } = makeSpawn();
|
||||
const handle = runSyncLauncher({
|
||||
command: ['subminer'],
|
||||
args: ['sync', '--snapshot', '/tmp/x.sqlite', '--json'],
|
||||
onEvent: () => {},
|
||||
spawn,
|
||||
});
|
||||
const child = children[0]!;
|
||||
child.stderr.emit('data', Buffer.from('[ERROR] boom\n'));
|
||||
child.emit('close', 1, null);
|
||||
|
||||
const result = await handle.done;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /boom/);
|
||||
});
|
||||
|
||||
test('runSyncLauncher cancel kills the child and resolves as cancelled', async () => {
|
||||
const { spawn, children } = makeSpawn();
|
||||
const handle = runSyncLauncher({
|
||||
command: ['subminer'],
|
||||
args: ['sync', 'media-box', '--json'],
|
||||
onEvent: () => {},
|
||||
spawn,
|
||||
});
|
||||
handle.cancel();
|
||||
assert.equal(children[0]!.killed, true);
|
||||
|
||||
const result = await handle.done;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /cancel/i);
|
||||
});
|
||||
|
||||
test('runSyncLauncher surfaces spawn errors', async () => {
|
||||
const { spawn, children } = makeSpawn();
|
||||
const handle = runSyncLauncher({
|
||||
command: ['subminer'],
|
||||
args: ['sync', 'media-box', '--json'],
|
||||
onEvent: () => {},
|
||||
spawn,
|
||||
});
|
||||
children[0]!.emit('error', new Error('ENOENT: subminer not found'));
|
||||
|
||||
const result = await handle.done;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /ENOENT/);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||
import { findCommand } from './command-line-launcher-deps';
|
||||
import { resolveLauncherResourcePath } from './command-line-launcher';
|
||||
|
||||
export interface SyncLauncherChildLike {
|
||||
stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
|
||||
stderr: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
|
||||
on(event: 'close', listener: (code: number | null, signal: string | null) => void): unknown;
|
||||
on(event: 'error', listener: (error: Error) => void): unknown;
|
||||
kill(signal?: NodeJS.Signals): boolean;
|
||||
}
|
||||
|
||||
export type SyncLauncherSpawn = (command: string, args: string[]) => SyncLauncherChildLike;
|
||||
|
||||
export interface SyncLauncherRunResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SyncLauncherRunHandle {
|
||||
cancel: () => void;
|
||||
done: Promise<SyncLauncherRunResult>;
|
||||
}
|
||||
|
||||
export interface SyncLauncherResolution {
|
||||
command: string[] | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// The launcher is a bun script: prefer the PATH-installed `subminer`, fall
|
||||
// back to running the bundled resource through bun directly.
|
||||
export function resolveSyncLauncherCommand(
|
||||
deps: {
|
||||
findCommand?: typeof findCommand;
|
||||
resolveResourcePath?: () => string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
} = {},
|
||||
): SyncLauncherResolution {
|
||||
const find = deps.findCommand ?? findCommand;
|
||||
const installed = find('subminer', {});
|
||||
if (installed) return { command: [installed], error: null };
|
||||
|
||||
const resourcePath = deps.resolveResourcePath
|
||||
? deps.resolveResourcePath()
|
||||
: resolveLauncherResourcePath({});
|
||||
const bunPath = find('bun', {});
|
||||
if (bunPath && resourcePath) return { command: [bunPath, resourcePath], error: null };
|
||||
|
||||
return {
|
||||
command: null,
|
||||
error:
|
||||
'Could not find the subminer launcher. Install the command-line launcher from SubMiner setup (or install bun).',
|
||||
};
|
||||
}
|
||||
|
||||
export function runSyncLauncher(options: {
|
||||
command: string[];
|
||||
args: string[];
|
||||
onEvent: (event: SyncProgressEvent) => void;
|
||||
onStderr?: (text: string) => void;
|
||||
spawn?: SyncLauncherSpawn;
|
||||
}): SyncLauncherRunHandle {
|
||||
const spawn = options.spawn ?? ((command, args) => nodeSpawn(command, args, { stdio: 'pipe' }));
|
||||
const [executable, ...prefixArgs] = options.command;
|
||||
const child = spawn(executable!, [...prefixArgs, ...options.args]);
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let stderrTail = '';
|
||||
let resultEvent: Extract<SyncProgressEvent, { type: 'result' }> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
let newlineIndex = stdoutBuffer.indexOf('\n');
|
||||
while (newlineIndex !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex);
|
||||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
||||
const event = parseSyncProgressLine(line);
|
||||
if (event) {
|
||||
if (event.type === 'result') resultEvent = event;
|
||||
options.onEvent(event);
|
||||
}
|
||||
newlineIndex = stdoutBuffer.indexOf('\n');
|
||||
}
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stderrTail = `${stderrTail}${text}`.slice(-4000);
|
||||
options.onStderr?.(text);
|
||||
});
|
||||
|
||||
const done = new Promise<SyncLauncherRunResult>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: SyncLauncherRunResult): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
child.on('error', (error) => {
|
||||
settle({ ok: false, error: error.message });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (cancelled) {
|
||||
settle({ ok: false, error: 'Sync cancelled.' });
|
||||
return;
|
||||
}
|
||||
if (code === 0) {
|
||||
settle({ ok: true, error: null });
|
||||
return;
|
||||
}
|
||||
const error =
|
||||
resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`);
|
||||
settle({ ok: false, error });
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// process may already be gone
|
||||
}
|
||||
},
|
||||
done,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||
import type { SyncLauncherRunHandle } from './sync-launcher-client';
|
||||
import { createSyncUiRuntime, type SyncUiRuntimeDeps } from './sync-ui-runtime';
|
||||
|
||||
interface LauncherCall {
|
||||
args: string[];
|
||||
onEvent: (event: SyncProgressEvent) => void;
|
||||
finish: (result: { ok: boolean; error: string | null }) => void;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
function makeTestRig(root: string) {
|
||||
const launcherCalls: LauncherCall[] = [];
|
||||
const sent: Array<{ channel: string; payload: unknown }> = [];
|
||||
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
const windowStub = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
send: (channel: string, payload: unknown) => sent.push({ channel, payload }),
|
||||
},
|
||||
};
|
||||
|
||||
const deps: SyncUiRuntimeDeps = {
|
||||
ipcMain: {
|
||||
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
handlers.set(channel, listener);
|
||||
},
|
||||
},
|
||||
hostsFilePath: path.join(root, 'sync-hosts.json'),
|
||||
snapshotsDir: path.join(root, 'snapshots'),
|
||||
getDbPath: () => path.join(root, 'immersion.sqlite'),
|
||||
resolveLauncherCommand: () => ({ command: ['subminer'], error: null }),
|
||||
runLauncher: (options): SyncLauncherRunHandle => {
|
||||
let finish: (result: { ok: boolean; error: string | null }) => void = () => {};
|
||||
const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const call: LauncherCall = {
|
||||
args: options.args,
|
||||
onEvent: options.onEvent,
|
||||
finish,
|
||||
cancelled: false,
|
||||
};
|
||||
launcherCalls.push(call);
|
||||
return {
|
||||
cancel: () => {
|
||||
call.cancelled = true;
|
||||
finish({ ok: false, error: 'Sync cancelled.' });
|
||||
},
|
||||
done,
|
||||
};
|
||||
},
|
||||
getWindow: () => windowStub,
|
||||
pickSnapshotFile: async () => null,
|
||||
revealPath: () => {},
|
||||
nowMs: () => 1700000000000,
|
||||
};
|
||||
|
||||
const runtime = createSyncUiRuntime(deps);
|
||||
runtime.registerHandlers();
|
||||
const invoke = (channel: string, ...args: unknown[]): unknown => {
|
||||
const handler = handlers.get(channel);
|
||||
assert.ok(handler, `missing handler for ${channel}`);
|
||||
return handler({}, ...args);
|
||||
};
|
||||
return { runtime, invoke, launcherCalls, sent, handlers };
|
||||
}
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-ui-test-'));
|
||||
const cleanup = () => fs.rmSync(dir, { recursive: true, force: true });
|
||||
const result = fn(dir);
|
||||
if (result instanceof Promise) return result.finally(cleanup);
|
||||
cleanup();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('registerHandlers registers every sync-ui request channel', () =>
|
||||
withTempDir((root) => {
|
||||
const { handlers } = makeTestRig(root);
|
||||
for (const channel of [
|
||||
'sync-ui:get-snapshot',
|
||||
'sync-ui:save-host',
|
||||
'sync-ui:remove-host',
|
||||
'sync-ui:set-auto-sync-interval',
|
||||
'sync-ui:run-sync',
|
||||
'sync-ui:cancel-run',
|
||||
'sync-ui:check-host',
|
||||
'sync-ui:create-snapshot',
|
||||
'sync-ui:merge-snapshot-file',
|
||||
'sync-ui:delete-snapshot',
|
||||
'sync-ui:reveal-snapshot',
|
||||
'sync-ui:pick-snapshot-file',
|
||||
]) {
|
||||
assert.ok(handlers.has(channel), `missing ${channel}`);
|
||||
}
|
||||
}));
|
||||
|
||||
test('save/remove host round-trips through the hosts file', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke } = makeTestRig(root);
|
||||
const saved = (await invoke('sync-ui:save-host', {
|
||||
host: 'user@laptop',
|
||||
label: 'Laptop',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
})) as { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
|
||||
assert.equal(saved.hosts.length, 1);
|
||||
assert.equal(saved.hosts[0]!.direction, 'pull');
|
||||
assert.equal(saved.hosts[0]!.autoSync, true);
|
||||
|
||||
const removed = (await invoke('sync-ui:remove-host', 'user@laptop')) as {
|
||||
hosts: unknown[];
|
||||
};
|
||||
assert.equal(removed.hosts.length, 0);
|
||||
}));
|
||||
|
||||
test('save-host rejects invalid hosts', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke } = makeTestRig(root);
|
||||
await assert.rejects(
|
||||
async () => invoke('sync-ui:save-host', { host: '-oProxyCommand=evil' }),
|
||||
/Invalid sync host/,
|
||||
);
|
||||
}));
|
||||
|
||||
test('run-sync spawns the launcher, forwards progress, and rejects concurrent runs', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke, launcherCalls, sent } = makeTestRig(root);
|
||||
const start = (await invoke('sync-ui:run-sync', {
|
||||
host: 'media-box',
|
||||
direction: 'push',
|
||||
force: true,
|
||||
})) as { started: boolean; runId: number };
|
||||
assert.equal(start.started, true);
|
||||
assert.deepEqual(launcherCalls[0]!.args, [
|
||||
'sync',
|
||||
'media-box',
|
||||
'--push',
|
||||
'--force',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const second = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
|
||||
started: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
assert.equal(second.started, false);
|
||||
assert.match(second.reason ?? '', /already running/i);
|
||||
|
||||
launcherCalls[0]!.onEvent({ type: 'stage', stage: 'snapshot-local', message: 'Snap' });
|
||||
const progress = sent.filter((entry) => entry.channel === 'sync-ui:progress');
|
||||
assert.equal(progress.length, 1);
|
||||
const payload = progress[0]!.payload as { runId: number; host: string; event: unknown };
|
||||
assert.equal(payload.runId, start.runId);
|
||||
assert.equal(payload.host, 'media-box');
|
||||
|
||||
launcherCalls[0]!.finish({ ok: true, error: null });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
|
||||
|
||||
const third = (await invoke('sync-ui:run-sync', { host: 'media-box' })) as {
|
||||
started: boolean;
|
||||
};
|
||||
assert.equal(third.started, true);
|
||||
}));
|
||||
|
||||
test('run-sync saves the host so previously used hosts persist', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke, launcherCalls } = makeTestRig(root);
|
||||
await invoke('sync-ui:run-sync', { host: 'media-box', direction: 'pull' });
|
||||
const snapshot = (await invoke('sync-ui:get-snapshot')) as {
|
||||
hosts: { hosts: Array<{ host: string; direction: string }> };
|
||||
};
|
||||
assert.equal(snapshot.hosts.hosts[0]!.host, 'media-box');
|
||||
assert.equal(snapshot.hosts.hosts[0]!.direction, 'pull');
|
||||
launcherCalls[0]!.finish({ ok: true, error: null });
|
||||
}));
|
||||
|
||||
test('create-snapshot runs the launcher with a timestamped path under the snapshots dir', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke, launcherCalls } = makeTestRig(root);
|
||||
const start = (await invoke('sync-ui:create-snapshot')) as { started: boolean };
|
||||
assert.equal(start.started, true);
|
||||
const args = launcherCalls[0]!.args;
|
||||
assert.equal(args[0], 'sync');
|
||||
assert.equal(args[1], '--snapshot');
|
||||
assert.ok(args[2]!.startsWith(path.join(root, 'snapshots')));
|
||||
assert.match(args[2]!, /immersion-.*\.sqlite$/);
|
||||
assert.equal(args[3], '--json');
|
||||
launcherCalls[0]!.finish({ ok: true, error: null });
|
||||
}));
|
||||
|
||||
test('delete-snapshot refuses paths outside the snapshots dir', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke } = makeTestRig(root);
|
||||
fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true });
|
||||
const inside = path.join(root, 'snapshots', 'immersion-1.sqlite');
|
||||
fs.writeFileSync(inside, 'x');
|
||||
const outside = path.join(root, 'immersion.sqlite');
|
||||
fs.writeFileSync(outside, 'x');
|
||||
|
||||
await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside));
|
||||
const remaining = (await invoke('sync-ui:delete-snapshot', inside)) as unknown[];
|
||||
assert.equal(remaining.length, 0);
|
||||
assert.equal(fs.existsSync(inside), false);
|
||||
assert.equal(fs.existsSync(outside), true);
|
||||
}));
|
||||
|
||||
test('check-host resolves with the check-result event', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke, launcherCalls } = makeTestRig(root);
|
||||
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{
|
||||
ok: boolean;
|
||||
sshOk: boolean;
|
||||
}>;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--check', '--json']);
|
||||
launcherCalls[0]!.onEvent({
|
||||
type: 'check-result',
|
||||
host: 'media-box',
|
||||
sshOk: true,
|
||||
remoteCommand: 'subminer',
|
||||
remoteVersion: '0.18.0',
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
launcherCalls[0]!.finish({ ok: true, error: null });
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.sshOk, true);
|
||||
}));
|
||||
|
||||
test('cancel-run cancels the active run', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke, launcherCalls } = makeTestRig(root);
|
||||
await invoke('sync-ui:run-sync', { host: 'media-box' });
|
||||
const cancelled = (await invoke('sync-ui:cancel-run')) as boolean;
|
||||
assert.equal(cancelled, true);
|
||||
assert.equal(launcherCalls[0]!.cancelled, true);
|
||||
}));
|
||||
|
||||
test('get-snapshot lists snapshot files newest first', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke } = makeTestRig(root);
|
||||
const dir = path.join(root, 'snapshots');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'immersion-old.sqlite'), 'old');
|
||||
fs.writeFileSync(path.join(dir, 'ignore.txt'), 'x');
|
||||
fs.writeFileSync(path.join(dir, 'immersion-new.sqlite'), 'new');
|
||||
fs.utimesSync(path.join(dir, 'immersion-old.sqlite'), new Date(1000), new Date(1000));
|
||||
fs.utimesSync(path.join(dir, 'immersion-new.sqlite'), new Date(2000), new Date(2000));
|
||||
|
||||
const snapshot = (await invoke('sync-ui:get-snapshot')) as {
|
||||
snapshots: Array<{ name: string }>;
|
||||
dbPath: string;
|
||||
run: { running: boolean };
|
||||
};
|
||||
assert.deepEqual(
|
||||
snapshot.snapshots.map((file) => file.name),
|
||||
['immersion-new.sqlite', 'immersion-old.sqlite'],
|
||||
);
|
||||
assert.equal(snapshot.run.running, false);
|
||||
}));
|
||||
@@ -0,0 +1,343 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
isValidSyncHost,
|
||||
readSyncHostsState,
|
||||
removeSyncHost,
|
||||
upsertSyncHost,
|
||||
writeSyncHostsState,
|
||||
type SyncDirection,
|
||||
type SyncHostsState,
|
||||
} from '../../shared/sync/sync-hosts-store';
|
||||
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||
import type {
|
||||
SyncUiCheckResult,
|
||||
SyncUiHostUpdateRequest,
|
||||
SyncUiRunKind,
|
||||
SyncUiRunRequest,
|
||||
SyncUiRunState,
|
||||
SyncUiSnapshot,
|
||||
SyncUiSnapshotFile,
|
||||
SyncUiStartResult,
|
||||
} from '../../types/sync-ui';
|
||||
import type {
|
||||
SyncLauncherResolution,
|
||||
SyncLauncherRunHandle,
|
||||
SyncLauncherRunResult,
|
||||
} from './sync-launcher-client';
|
||||
import { runSyncLauncher } from './sync-launcher-client';
|
||||
|
||||
export interface SyncUiWindowLike {
|
||||
isDestroyed(): boolean;
|
||||
webContents: { send(channel: string, payload?: unknown): void };
|
||||
}
|
||||
|
||||
export interface SyncUiRuntimeDeps {
|
||||
ipcMain: {
|
||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
||||
};
|
||||
hostsFilePath: string;
|
||||
snapshotsDir: string;
|
||||
getDbPath: () => string;
|
||||
resolveLauncherCommand: () => SyncLauncherResolution;
|
||||
runLauncher: typeof runSyncLauncher;
|
||||
getWindow: () => SyncUiWindowLike | null;
|
||||
pickSnapshotFile: () => Promise<string | null>;
|
||||
revealPath: (targetPath: string) => void;
|
||||
nowMs: () => number;
|
||||
log?: (message: string) => void;
|
||||
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
|
||||
}
|
||||
|
||||
interface ActiveRun {
|
||||
id: number;
|
||||
kind: SyncUiRunKind;
|
||||
host: string | null;
|
||||
handle: SyncLauncherRunHandle;
|
||||
resultSeen: boolean;
|
||||
}
|
||||
|
||||
function formatSnapshotName(nowMs: number): string {
|
||||
const iso = new Date(nowMs).toISOString();
|
||||
const stamp = iso.slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
|
||||
return `immersion-${stamp}.sqlite`;
|
||||
}
|
||||
|
||||
export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
let runCounter = 0;
|
||||
let currentRun: ActiveRun | null = null;
|
||||
|
||||
function sendToWindow(channel: string, payload?: unknown): void {
|
||||
const window = deps.getWindow();
|
||||
if (!window || window.isDestroyed()) return;
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
|
||||
function broadcastStateChanged(): void {
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiStateChanged);
|
||||
}
|
||||
|
||||
function readState(): SyncHostsState {
|
||||
return readSyncHostsState(deps.hostsFilePath);
|
||||
}
|
||||
|
||||
function writeState(state: SyncHostsState): SyncHostsState {
|
||||
writeSyncHostsState(deps.hostsFilePath, state);
|
||||
broadcastStateChanged();
|
||||
return state;
|
||||
}
|
||||
|
||||
function listSnapshots(): SyncUiSnapshotFile[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(deps.snapshotsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const files: SyncUiSnapshotFile[] = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.sqlite')) continue;
|
||||
const filePath = path.join(deps.snapshotsDir, name);
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) continue;
|
||||
files.push({
|
||||
path: filePath,
|
||||
name,
|
||||
sizeBytes: stat.size,
|
||||
modifiedAtMs: stat.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
// file disappeared between readdir and stat
|
||||
}
|
||||
}
|
||||
files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
|
||||
return files;
|
||||
}
|
||||
|
||||
function runState(): SyncUiRunState {
|
||||
return currentRun
|
||||
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
|
||||
: { running: false, runId: null, kind: null, host: null };
|
||||
}
|
||||
|
||||
function getSnapshot(): SyncUiSnapshot {
|
||||
const resolution = deps.resolveLauncherCommand();
|
||||
return {
|
||||
dbPath: deps.getDbPath(),
|
||||
hosts: readState(),
|
||||
snapshotsDir: deps.snapshotsDir,
|
||||
snapshots: listSnapshots(),
|
||||
run: runState(),
|
||||
launcherPath: resolution.command ? resolution.command.join(' ') : null,
|
||||
};
|
||||
}
|
||||
|
||||
function startRun(
|
||||
kind: SyncUiRunKind,
|
||||
host: string | null,
|
||||
args: string[],
|
||||
options: { notify?: boolean } = {},
|
||||
): SyncUiStartResult {
|
||||
if (currentRun) {
|
||||
return {
|
||||
started: false,
|
||||
runId: null,
|
||||
reason: 'A sync operation is already running.',
|
||||
};
|
||||
}
|
||||
const resolution = deps.resolveLauncherCommand();
|
||||
if (!resolution.command) {
|
||||
return { started: false, runId: null, reason: resolution.error };
|
||||
}
|
||||
runCounter += 1;
|
||||
const runId = runCounter;
|
||||
const run: ActiveRun = {
|
||||
id: runId,
|
||||
kind,
|
||||
host,
|
||||
resultSeen: false,
|
||||
handle: deps.runLauncher({
|
||||
command: resolution.command,
|
||||
args,
|
||||
onEvent: (event: SyncProgressEvent) => {
|
||||
if (event.type === 'result') run.resultSeen = true;
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
|
||||
},
|
||||
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
|
||||
}),
|
||||
};
|
||||
currentRun = run;
|
||||
void run.handle.done.then((result: SyncLauncherRunResult) => {
|
||||
if (currentRun?.id === runId) currentRun = null;
|
||||
// If the launcher died without emitting a result event (spawn failure,
|
||||
// kill), synthesize one so the renderer can settle its progress view.
|
||||
if (!run.resultSeen) {
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
|
||||
runId,
|
||||
kind,
|
||||
host,
|
||||
event: { type: 'result', ok: result.ok, error: result.error },
|
||||
});
|
||||
}
|
||||
broadcastStateChanged();
|
||||
if (options.notify && deps.notify) {
|
||||
const target = host ?? 'local database';
|
||||
deps.notify(
|
||||
result.ok
|
||||
? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' }
|
||||
: {
|
||||
title: 'Sync failed',
|
||||
body: `${target}: ${result.error ?? 'unknown error'}`,
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
return { started: true, runId, reason: null };
|
||||
}
|
||||
|
||||
function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) {
|
||||
const host = request.host.trim();
|
||||
if (!isValidSyncHost(host)) {
|
||||
return { started: false, runId: null, reason: `Invalid sync host: ${request.host}` };
|
||||
}
|
||||
const direction: SyncDirection = request.direction ?? 'both';
|
||||
const args = ['sync', host];
|
||||
if (direction === 'push') args.push('--push');
|
||||
if (direction === 'pull') args.push('--pull');
|
||||
if (request.force) args.push('--force');
|
||||
args.push('--json');
|
||||
const result = startRun('host-sync', host, args, options);
|
||||
if (result.started) {
|
||||
// Remember the host (and the direction used) before the launcher's own
|
||||
// bookkeeping lands, so it shows up in the UI immediately.
|
||||
writeState(
|
||||
upsertSyncHost(readState(), { host, direction: request.direction }, deps.nowMs()),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkHost(host: string): Promise<SyncUiCheckResult> {
|
||||
const trimmed = host.trim();
|
||||
const failed = (error: string): SyncUiCheckResult => ({
|
||||
host: trimmed,
|
||||
sshOk: false,
|
||||
remoteCommand: null,
|
||||
remoteVersion: null,
|
||||
ok: false,
|
||||
error,
|
||||
});
|
||||
if (!isValidSyncHost(trimmed)) {
|
||||
return Promise.resolve(failed(`Invalid sync host: ${host}`));
|
||||
}
|
||||
const resolution = deps.resolveLauncherCommand();
|
||||
if (!resolution.command) {
|
||||
return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.'));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let checkResult: SyncUiCheckResult | null = null;
|
||||
const handle = deps.runLauncher({
|
||||
command: resolution.command!,
|
||||
args: ['sync', trimmed, '--check', '--json'],
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'check-result') {
|
||||
checkResult = {
|
||||
host: event.host,
|
||||
sshOk: event.sshOk,
|
||||
remoteCommand: event.remoteCommand,
|
||||
remoteVersion: event.remoteVersion,
|
||||
ok: event.ok,
|
||||
error: event.error,
|
||||
};
|
||||
}
|
||||
},
|
||||
onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`),
|
||||
});
|
||||
void handle.done.then((result) => {
|
||||
resolve(checkResult ?? failed(result.error ?? 'Connection check failed.'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createSnapshot(): SyncUiStartResult {
|
||||
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
|
||||
return startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']);
|
||||
}
|
||||
|
||||
function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` };
|
||||
}
|
||||
const args = ['sync', '--merge', filePath];
|
||||
if (force) args.push('--force');
|
||||
args.push('--json');
|
||||
return startRun('merge', null, args);
|
||||
}
|
||||
|
||||
function deleteSnapshot(filePath: string): SyncUiSnapshotFile[] {
|
||||
const resolved = path.resolve(filePath);
|
||||
const dir = path.resolve(deps.snapshotsDir);
|
||||
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
|
||||
throw new Error('Refusing to delete a file outside the snapshots directory.');
|
||||
}
|
||||
fs.rmSync(resolved, { force: true });
|
||||
broadcastStateChanged();
|
||||
return listSnapshots();
|
||||
}
|
||||
|
||||
function cancelRun(): boolean {
|
||||
if (!currentRun) return false;
|
||||
currentRun.handle.cancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
function registerHandlers(): void {
|
||||
const channels = IPC_CHANNELS.request;
|
||||
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
|
||||
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) =>
|
||||
writeState(
|
||||
upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs()),
|
||||
),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) =>
|
||||
writeState(removeSyncHost(readState(), String(host))),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
|
||||
const value = Number(minutes);
|
||||
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
|
||||
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
|
||||
}
|
||||
return writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
|
||||
runHostSync(request as SyncUiRunRequest),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiCancelRun, () => cancelRun());
|
||||
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => checkHost(String(host)));
|
||||
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => createSnapshot());
|
||||
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
|
||||
mergeSnapshotFile(String(filePath), force === true),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) =>
|
||||
deleteSnapshot(String(filePath)),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
|
||||
deps.revealPath(String(filePath));
|
||||
return true;
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
|
||||
}
|
||||
|
||||
return {
|
||||
registerHandlers,
|
||||
getSnapshot,
|
||||
readState,
|
||||
runHostSync,
|
||||
checkHost,
|
||||
cancelRun,
|
||||
isRunning: () => currentRun !== null,
|
||||
};
|
||||
}
|
||||
@@ -71,6 +71,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('configuration'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -124,6 +125,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('configuration'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,67 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type {
|
||||
SyncHostsState,
|
||||
SyncUiAPI,
|
||||
SyncUiCheckResult,
|
||||
SyncUiHostUpdateRequest,
|
||||
SyncUiProgressPayload,
|
||||
SyncUiRunRequest,
|
||||
SyncUiSnapshot,
|
||||
SyncUiSnapshotFile,
|
||||
SyncUiStartResult,
|
||||
} from './types/sync-ui';
|
||||
|
||||
const SYNC_UI_IPC_CHANNELS = {
|
||||
getSnapshot: 'sync-ui:get-snapshot',
|
||||
saveHost: 'sync-ui:save-host',
|
||||
removeHost: 'sync-ui:remove-host',
|
||||
setAutoSyncInterval: 'sync-ui:set-auto-sync-interval',
|
||||
runSync: 'sync-ui:run-sync',
|
||||
cancelRun: 'sync-ui:cancel-run',
|
||||
checkHost: 'sync-ui:check-host',
|
||||
createSnapshot: 'sync-ui:create-snapshot',
|
||||
mergeSnapshotFile: 'sync-ui:merge-snapshot-file',
|
||||
deleteSnapshot: 'sync-ui:delete-snapshot',
|
||||
revealSnapshot: 'sync-ui:reveal-snapshot',
|
||||
pickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||
progress: 'sync-ui:progress',
|
||||
stateChanged: 'sync-ui:state-changed',
|
||||
} as const;
|
||||
|
||||
const syncUiAPI: SyncUiAPI = {
|
||||
getSnapshot: (): Promise<SyncUiSnapshot> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.getSnapshot),
|
||||
saveHost: (update: SyncUiHostUpdateRequest): Promise<SyncHostsState> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.saveHost, update),
|
||||
removeHost: (host: string): Promise<SyncHostsState> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.removeHost, host),
|
||||
setAutoSyncInterval: (minutes: number): Promise<SyncHostsState> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.setAutoSyncInterval, minutes),
|
||||
runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.runSync, request),
|
||||
cancelRun: (): Promise<boolean> => ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.cancelRun),
|
||||
checkHost: (host: string): Promise<SyncUiCheckResult> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.checkHost, host),
|
||||
createSnapshot: (): Promise<SyncUiStartResult> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.createSnapshot),
|
||||
mergeSnapshotFile: (path: string): Promise<SyncUiStartResult> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.mergeSnapshotFile, path),
|
||||
deleteSnapshot: (path: string): Promise<SyncUiSnapshotFile[]> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.deleteSnapshot, path),
|
||||
revealSnapshot: (path: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.revealSnapshot, path),
|
||||
pickSnapshotFile: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.pickSnapshotFile),
|
||||
onProgress: (listener: (payload: SyncUiProgressPayload) => void): (() => void) => {
|
||||
const handler = (_event: unknown, payload: SyncUiProgressPayload): void => listener(payload);
|
||||
ipcRenderer.on(SYNC_UI_IPC_CHANNELS.progress, handler);
|
||||
return () => ipcRenderer.removeListener(SYNC_UI_IPC_CHANNELS.progress, handler);
|
||||
},
|
||||
onStateChanged: (listener: () => void): (() => void) => {
|
||||
const handler = (): void => listener();
|
||||
ipcRenderer.on(SYNC_UI_IPC_CHANNELS.stateChanged, handler);
|
||||
return () => ipcRenderer.removeListener(SYNC_UI_IPC_CHANNELS.stateChanged, handler);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('syncUiAPI', syncUiAPI);
|
||||
@@ -119,6 +119,18 @@ export const IPC_CHANNELS = {
|
||||
getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names',
|
||||
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
|
||||
getConfigSettingsYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
|
||||
syncUiGetSnapshot: 'sync-ui:get-snapshot',
|
||||
syncUiSaveHost: 'sync-ui:save-host',
|
||||
syncUiRemoveHost: 'sync-ui:remove-host',
|
||||
syncUiSetAutoSyncInterval: 'sync-ui:set-auto-sync-interval',
|
||||
syncUiRunSync: 'sync-ui:run-sync',
|
||||
syncUiCancelRun: 'sync-ui:cancel-run',
|
||||
syncUiCheckHost: 'sync-ui:check-host',
|
||||
syncUiCreateSnapshot: 'sync-ui:create-snapshot',
|
||||
syncUiMergeSnapshotFile: 'sync-ui:merge-snapshot-file',
|
||||
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
|
||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
@@ -149,6 +161,8 @@ export const IPC_CHANNELS = {
|
||||
configHotReload: 'config:hot-reload',
|
||||
overlayNotification: 'overlay:notification',
|
||||
notificationHistoryToggle: 'notification-history:toggle',
|
||||
syncUiProgress: 'sync-ui:progress',
|
||||
syncUiStateChanged: 'sync-ui:state-changed',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { SyncProgressEvent } from '../shared/sync/sync-events';
|
||||
import type { SyncDirection, SyncHostsState } from '../shared/sync/sync-hosts-store';
|
||||
|
||||
export type { SyncProgressEvent } from '../shared/sync/sync-events';
|
||||
export type { SyncDirection, SyncHostEntry, SyncHostsState } from '../shared/sync/sync-hosts-store';
|
||||
|
||||
export interface SyncUiSnapshotFile {
|
||||
path: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
modifiedAtMs: number;
|
||||
}
|
||||
|
||||
export type SyncUiRunKind = 'host-sync' | 'merge' | 'check' | 'snapshot';
|
||||
|
||||
export interface SyncUiRunState {
|
||||
running: boolean;
|
||||
runId: number | null;
|
||||
kind: SyncUiRunKind | null;
|
||||
host: string | null;
|
||||
}
|
||||
|
||||
export interface SyncUiSnapshot {
|
||||
dbPath: string;
|
||||
hosts: SyncHostsState;
|
||||
snapshotsDir: string;
|
||||
snapshots: SyncUiSnapshotFile[];
|
||||
run: SyncUiRunState;
|
||||
launcherPath: string | null;
|
||||
}
|
||||
|
||||
export interface SyncUiRunRequest {
|
||||
host: string;
|
||||
direction?: SyncDirection;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncUiStartResult {
|
||||
started: boolean;
|
||||
runId: number | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface SyncUiCheckResult {
|
||||
host: string;
|
||||
sshOk: boolean;
|
||||
remoteCommand: string | null;
|
||||
remoteVersion: string | null;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SyncUiProgressPayload {
|
||||
runId: number;
|
||||
kind: SyncUiRunKind;
|
||||
host: string | null;
|
||||
event: SyncProgressEvent;
|
||||
}
|
||||
|
||||
export interface SyncUiHostUpdateRequest {
|
||||
host: string;
|
||||
label?: string | null;
|
||||
direction?: SyncDirection;
|
||||
autoSync?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncUiAPI {
|
||||
getSnapshot: () => Promise<SyncUiSnapshot>;
|
||||
saveHost: (update: SyncUiHostUpdateRequest) => Promise<SyncHostsState>;
|
||||
removeHost: (host: string) => Promise<SyncHostsState>;
|
||||
setAutoSyncInterval: (minutes: number) => Promise<SyncHostsState>;
|
||||
runSync: (request: SyncUiRunRequest) => Promise<SyncUiStartResult>;
|
||||
cancelRun: () => Promise<boolean>;
|
||||
checkHost: (host: string) => Promise<SyncUiCheckResult>;
|
||||
createSnapshot: () => Promise<SyncUiStartResult>;
|
||||
mergeSnapshotFile: (path: string) => Promise<SyncUiStartResult>;
|
||||
deleteSnapshot: (path: string) => Promise<SyncUiSnapshotFile[]>;
|
||||
revealSnapshot: (path: string) => Promise<boolean>;
|
||||
pickSnapshotFile: () => Promise<string | null>;
|
||||
onProgress: (listener: (payload: SyncUiProgressPayload) => void) => () => void;
|
||||
onStateChanged: (listener: () => void) => () => void;
|
||||
}
|
||||
Reference in New Issue
Block a user