mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -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']);
|
deps.runAppCommandWithInherit(appPath, ['--settings']);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (args.syncUi) {
|
||||||
|
deps.runAppCommandWithInherit(appPath, ['--sync-window']);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!args.appPassthrough) {
|
if (!args.appPassthrough) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface CliArgs {
|
|||||||
togglePrimarySubtitleBar: boolean;
|
togglePrimarySubtitleBar: boolean;
|
||||||
yomitan: boolean;
|
yomitan: boolean;
|
||||||
settings: boolean;
|
settings: boolean;
|
||||||
|
syncWindow: boolean;
|
||||||
setup: boolean;
|
setup: boolean;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
hide: boolean;
|
hide: boolean;
|
||||||
@@ -122,6 +123,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
syncWindow: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: 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 === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||||
else if (arg === '--yomitan') args.yomitan = true;
|
else if (arg === '--yomitan') args.yomitan = true;
|
||||||
else if (arg === '--settings') args.settings = 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 === '--setup') args.setup = true;
|
||||||
else if (arg === '--show') args.show = true;
|
else if (arg === '--show') args.show = true;
|
||||||
else if (arg === '--hide') args.hide = true;
|
else if (arg === '--hide') args.hide = true;
|
||||||
@@ -541,6 +544,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.togglePrimarySubtitleBar ||
|
args.togglePrimarySubtitleBar ||
|
||||||
args.yomitan ||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
|
args.syncWindow ||
|
||||||
args.setup ||
|
args.setup ||
|
||||||
args.show ||
|
args.show ||
|
||||||
args.hide ||
|
args.hide ||
|
||||||
@@ -618,6 +622,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.togglePrimarySubtitleBar &&
|
!args.togglePrimarySubtitleBar &&
|
||||||
!args.yomitan &&
|
!args.yomitan &&
|
||||||
!args.settings &&
|
!args.settings &&
|
||||||
|
!args.syncWindow &&
|
||||||
!args.setup &&
|
!args.setup &&
|
||||||
!args.show &&
|
!args.show &&
|
||||||
!args.hide &&
|
!args.hide &&
|
||||||
@@ -688,6 +693,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.togglePrimarySubtitleBar ||
|
args.togglePrimarySubtitleBar ||
|
||||||
args.yomitan ||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
|
args.syncWindow ||
|
||||||
args.setup ||
|
args.setup ||
|
||||||
args.copySubtitle ||
|
args.copySubtitle ||
|
||||||
args.copySubtitleMultiple ||
|
args.copySubtitleMultiple ||
|
||||||
@@ -744,6 +750,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.toggleVisibleOverlay &&
|
!args.toggleVisibleOverlay &&
|
||||||
!args.togglePrimarySubtitleBar &&
|
!args.togglePrimarySubtitleBar &&
|
||||||
!args.settings &&
|
!args.settings &&
|
||||||
|
!args.syncWindow &&
|
||||||
!args.show &&
|
!args.show &&
|
||||||
!args.hide &&
|
!args.hide &&
|
||||||
!args.setup &&
|
!args.setup &&
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
syncWindow: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
syncWindow: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -137,6 +138,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
openConfigSettingsWindow: () => {
|
openConfigSettingsWindow: () => {
|
||||||
calls.push('openConfigSettingsWindow');
|
calls.push('openConfigSettingsWindow');
|
||||||
},
|
},
|
||||||
|
openSyncUiWindow: () => {
|
||||||
|
calls.push('openSyncUiWindow');
|
||||||
|
},
|
||||||
openFirstRunSetup: (force?: boolean) => {
|
openFirstRunSetup: (force?: boolean) => {
|
||||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||||
},
|
},
|
||||||
@@ -659,6 +663,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
|||||||
openFirstRunSetup: () => {},
|
openFirstRunSetup: () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
openSyncUiWindow: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
printHelp: () => {},
|
printHelp: () => {},
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface CliCommandServiceDeps {
|
|||||||
openFirstRunSetup: (force?: boolean) => void;
|
openFirstRunSetup: (force?: boolean) => void;
|
||||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
|
openSyncUiWindow: () => void;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
copyCurrentSubtitle: () => void;
|
copyCurrentSubtitle: () => void;
|
||||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||||
@@ -170,6 +171,7 @@ interface UiCliRuntime {
|
|||||||
openFirstRunSetup: (force?: boolean) => void;
|
openFirstRunSetup: (force?: boolean) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
|
openSyncUiWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -274,6 +276,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
}, delayMs);
|
}, delayMs);
|
||||||
},
|
},
|
||||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||||
|
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||||
@@ -417,6 +420,8 @@ export function handleCliCommand(
|
|||||||
deps.openYomitanSettingsDelayed(1000);
|
deps.openYomitanSettingsDelayed(1000);
|
||||||
} else if (args.settings) {
|
} else if (args.settings) {
|
||||||
deps.openConfigSettingsWindow();
|
deps.openConfigSettingsWindow();
|
||||||
|
} else if (args.syncWindow) {
|
||||||
|
deps.openSyncUiWindow();
|
||||||
} else if (args.show || args.showVisibleOverlay) {
|
} else if (args.show || args.showVisibleOverlay) {
|
||||||
deps.setVisibleOverlayVisible(true);
|
deps.setVisibleOverlayVisible(true);
|
||||||
} else if (args.hide || args.hideVisibleOverlay) {
|
} else if (args.hide || args.hideVisibleOverlay) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
syncWindow: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
|
|||||||
+73
@@ -529,8 +529,16 @@ import {
|
|||||||
createCreateConfigSettingsWindowHandler,
|
createCreateConfigSettingsWindowHandler,
|
||||||
createCreateFirstRunSetupWindowHandler,
|
createCreateFirstRunSetupWindowHandler,
|
||||||
createCreateJellyfinSetupWindowHandler,
|
createCreateJellyfinSetupWindowHandler,
|
||||||
|
createCreateSyncUiWindowHandler,
|
||||||
} from './main/runtime/setup-window-factory';
|
} from './main/runtime/setup-window-factory';
|
||||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
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 { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
|
||||||
import {
|
import {
|
||||||
isSameYoutubeMediaPath,
|
isSameYoutubeMediaPath,
|
||||||
@@ -902,6 +910,7 @@ const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
|||||||
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
|
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
|
||||||
return [
|
return [
|
||||||
appState.configSettingsWindow,
|
appState.configSettingsWindow,
|
||||||
|
appState.syncUiWindow,
|
||||||
appState.yomitanSettingsWindow,
|
appState.yomitanSettingsWindow,
|
||||||
appState.anilistSetupWindow,
|
appState.anilistSetupWindow,
|
||||||
appState.jellyfinSetupWindow,
|
appState.jellyfinSetupWindow,
|
||||||
@@ -2227,6 +2236,68 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
|||||||
configSettingsRuntime.registerHandlers();
|
configSettingsRuntime.registerHandlers();
|
||||||
const openConfigSettingsWindow = () => configSettingsRuntime.openWindow();
|
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({
|
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
dirname: __dirname,
|
dirname: __dirname,
|
||||||
@@ -5983,6 +6054,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
|||||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||||
|
openSyncUiWindow: () => openSyncUiWindow(),
|
||||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||||
@@ -6231,6 +6303,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
|||||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||||
|
openSyncUiWindow: () => openSyncUiWindow(),
|
||||||
exportLogs: () => {
|
exportLogs: () => {
|
||||||
void exportLogsFromTray();
|
void exportLogsFromTray();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
|
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
|
openSyncUiWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -135,6 +136,7 @@ function createCliCommandDepsFromContext(
|
|||||||
openFirstRunSetup: context.openFirstRunSetup,
|
openFirstRunSetup: context.openFirstRunSetup,
|
||||||
openYomitanSettings: context.openYomitanSettings,
|
openYomitanSettings: context.openYomitanSettings,
|
||||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||||
|
openSyncUiWindow: context.openSyncUiWindow,
|
||||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||||
printHelp: context.printHelp,
|
printHelp: context.printHelp,
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||||
|
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
||||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||||
@@ -413,6 +414,7 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||||
openYomitanSettings: params.ui.openYomitanSettings,
|
openYomitanSettings: params.ui.openYomitanSettings,
|
||||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||||
|
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||||
printHelp: params.ui.printHelp,
|
printHelp: params.ui.printHelp,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ test('build cli command context deps maps handlers and values', () => {
|
|||||||
},
|
},
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||||
|
openSyncUiWindow: () => {},
|
||||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||||
printHelp: () => calls.push('help'),
|
printHelp: () => calls.push('help'),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
|
openSyncUiWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
|
openSyncUiWindow: deps.openSyncUiWindow,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||||
printHelp: deps.printHelp,
|
printHelp: deps.printHelp,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
|||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
openSyncUiWindow: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
printHelp: () => {},
|
printHelp: () => {},
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
},
|
},
|
||||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||||
|
openSyncUiWindow: () => {},
|
||||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||||
printHelp: () => calls.push('help'),
|
printHelp: () => calls.push('help'),
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
|
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
|
openSyncUiWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||||
|
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||||
printHelp: () => deps.printHelp(),
|
printHelp: () => deps.printHelp(),
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function createDeps() {
|
|||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
openSyncUiWindow: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
printHelp: () => {},
|
printHelp: () => {},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export type CliCommandContextFactoryDeps = {
|
|||||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
|
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
|
openSyncUiWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -134,6 +135,7 @@ export function createCliCommandContext(
|
|||||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
|
openSyncUiWindow: deps.openSyncUiWindow,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||||
printHelp: deps.printHelp,
|
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));
|
const platformPath = pathModuleFor(platformOf(options));
|
||||||
if (options.launcherResourcePath) return options.launcherResourcePath;
|
if (options.launcherResourcePath) return options.launcherResourcePath;
|
||||||
const resourcesPath =
|
const resourcesPath =
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
|||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
openSyncUiWindow: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
printHelp: () => {},
|
printHelp: () => {},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
yomitan: false,
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
syncWindow: false,
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
|
|||||||
@@ -84,3 +84,18 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
|
|||||||
backgroundColor: '#24273a',
|
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,
|
showWindowsMpvLauncherSetup: () => true,
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
|
openSyncUiWindow: () => calls.push('configuration'),
|
||||||
exportLogs: () => calls.push('export-logs'),
|
exportLogs: () => calls.push('export-logs'),
|
||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
isJellyfinConfigured: () => true,
|
isJellyfinConfigured: () => true,
|
||||||
@@ -124,6 +125,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
|||||||
showWindowsMpvLauncherSetup: () => true,
|
showWindowsMpvLauncherSetup: () => true,
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
|
openSyncUiWindow: () => calls.push('configuration'),
|
||||||
exportLogs: () => calls.push('export-logs'),
|
exportLogs: () => calls.push('export-logs'),
|
||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
isJellyfinConfigured: () => false,
|
isJellyfinConfigured: () => false,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
showWindowsMpvLauncherSetup: boolean;
|
showWindowsMpvLauncherSetup: boolean;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettings: () => void;
|
openConfigSettings: () => void;
|
||||||
|
openSyncUi: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
@@ -66,6 +67,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
showWindowsMpvLauncherSetup: () => boolean;
|
showWindowsMpvLauncherSetup: () => boolean;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
|
openSyncUiWindow: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetupWindow: () => void;
|
openJellyfinSetupWindow: () => void;
|
||||||
isJellyfinConfigured: () => boolean;
|
isJellyfinConfigured: () => boolean;
|
||||||
@@ -103,6 +105,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openConfigSettings: () => {
|
openConfigSettings: () => {
|
||||||
deps.openConfigSettingsWindow();
|
deps.openConfigSettingsWindow();
|
||||||
},
|
},
|
||||||
|
openSyncUi: () => {
|
||||||
|
deps.openSyncUiWindow();
|
||||||
|
},
|
||||||
exportLogs: () => {
|
exportLogs: () => {
|
||||||
deps.exportLogs();
|
deps.exportLogs();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
showWindowsMpvLauncherSetup: () => true,
|
showWindowsMpvLauncherSetup: () => true,
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
|
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||||
exportLogs: () => calls.push('export-logs'),
|
exportLogs: () => calls.push('export-logs'),
|
||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
isJellyfinConfigured: () => true,
|
isJellyfinConfigured: () => true,
|
||||||
@@ -57,6 +58,7 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
showWindowsMpvLauncherSetup: true,
|
showWindowsMpvLauncherSetup: true,
|
||||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||||
openConfigSettings: () => calls.push('open-configuration'),
|
openConfigSettings: () => calls.push('open-configuration'),
|
||||||
|
openSyncUi: () => {},
|
||||||
exportLogs: () => calls.push('open-export-logs'),
|
exportLogs: () => calls.push('open-export-logs'),
|
||||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
showWindowsMpvLauncherSetup: boolean;
|
showWindowsMpvLauncherSetup: boolean;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettings: () => void;
|
openConfigSettings: () => void;
|
||||||
|
openSyncUi: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
@@ -56,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
showWindowsMpvLauncherSetup: () => boolean;
|
showWindowsMpvLauncherSetup: () => boolean;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
|
openSyncUiWindow: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetupWindow: () => void;
|
openJellyfinSetupWindow: () => void;
|
||||||
isJellyfinConfigured: () => boolean;
|
isJellyfinConfigured: () => boolean;
|
||||||
@@ -79,6 +81,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
|
openSyncUiWindow: deps.openSyncUiWindow,
|
||||||
exportLogs: deps.exportLogs,
|
exportLogs: deps.exportLogs,
|
||||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||||
isJellyfinConfigured: deps.isJellyfinConfigured,
|
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
|||||||
showWindowsMpvLauncherSetup: () => true,
|
showWindowsMpvLauncherSetup: () => true,
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
openSyncUiWindow: () => {},
|
||||||
exportLogs: () => {},
|
exportLogs: () => {},
|
||||||
openJellyfinSetupWindow: () => {},
|
openJellyfinSetupWindow: () => {},
|
||||||
isJellyfinConfigured: () => false,
|
isJellyfinConfigured: () => false,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
showWindowsMpvLauncherSetup: true,
|
showWindowsMpvLauncherSetup: true,
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettings: () => calls.push('configuration'),
|
openConfigSettings: () => calls.push('configuration'),
|
||||||
|
openSyncUi: () => calls.push('sync-ui'),
|
||||||
exportLogs: () => calls.push('export-logs'),
|
exportLogs: () => calls.push('export-logs'),
|
||||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
@@ -48,7 +49,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
quitApp: () => calls.push('quit'),
|
quitApp: () => calls.push('quit'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(template.length, 13);
|
assert.equal(template.length, 14);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
template.some((entry) => entry.label === 'Open Runtime Options'),
|
template.some((entry) => entry.label === 'Open Runtime Options'),
|
||||||
false,
|
false,
|
||||||
@@ -67,16 +68,19 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||||
template[1]!.click?.();
|
template[1]!.click?.();
|
||||||
assert.equal(template[5]!.label, 'Open SubMiner Settings');
|
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?.();
|
template[6]!.click?.();
|
||||||
assert.equal(template[10]!.label, 'Check for Updates');
|
assert.equal(template[7]!.label, 'Export Logs');
|
||||||
template[10]!.click?.();
|
template[7]!.click?.();
|
||||||
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
assert.equal(template[11]!.label, 'Check for Updates');
|
||||||
template[12]!.click?.();
|
template[11]!.click?.();
|
||||||
|
template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||||
|
template[13]!.click?.();
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'jellyfin-discovery:true',
|
'jellyfin-discovery:true',
|
||||||
'help',
|
'help',
|
||||||
'texthooker',
|
'texthooker',
|
||||||
|
'sync-ui',
|
||||||
'export-logs',
|
'export-logs',
|
||||||
'updates',
|
'updates',
|
||||||
'separator',
|
'separator',
|
||||||
@@ -95,6 +99,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
|||||||
showWindowsMpvLauncherSetup: false,
|
showWindowsMpvLauncherSetup: false,
|
||||||
openYomitanSettings: () => undefined,
|
openYomitanSettings: () => undefined,
|
||||||
openConfigSettings: () => undefined,
|
openConfigSettings: () => undefined,
|
||||||
|
openSyncUi: () => undefined,
|
||||||
exportLogs: () => undefined,
|
exportLogs: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
showJellyfinDiscovery: false,
|
showJellyfinDiscovery: false,
|
||||||
@@ -123,6 +128,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
|||||||
showWindowsMpvLauncherSetup: false,
|
showWindowsMpvLauncherSetup: false,
|
||||||
openYomitanSettings: () => undefined,
|
openYomitanSettings: () => undefined,
|
||||||
openConfigSettings: () => undefined,
|
openConfigSettings: () => undefined,
|
||||||
|
openSyncUi: () => undefined,
|
||||||
exportLogs: () => undefined,
|
exportLogs: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
showJellyfinDiscovery: false,
|
showJellyfinDiscovery: false,
|
||||||
@@ -149,6 +155,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
|||||||
showWindowsMpvLauncherSetup: false,
|
showWindowsMpvLauncherSetup: false,
|
||||||
openYomitanSettings: () => undefined,
|
openYomitanSettings: () => undefined,
|
||||||
openConfigSettings: () => undefined,
|
openConfigSettings: () => undefined,
|
||||||
|
openSyncUi: () => undefined,
|
||||||
exportLogs: () => undefined,
|
exportLogs: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
@@ -176,6 +183,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
|||||||
showWindowsMpvLauncherSetup: false,
|
showWindowsMpvLauncherSetup: false,
|
||||||
openYomitanSettings: () => undefined,
|
openYomitanSettings: () => undefined,
|
||||||
openConfigSettings: () => undefined,
|
openConfigSettings: () => undefined,
|
||||||
|
openSyncUi: () => undefined,
|
||||||
exportLogs: () => undefined,
|
exportLogs: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
showJellyfinDiscovery: true,
|
showJellyfinDiscovery: true,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type TrayMenuActionHandlers = {
|
|||||||
showWindowsMpvLauncherSetup: boolean;
|
showWindowsMpvLauncherSetup: boolean;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettings: () => void;
|
openConfigSettings: () => void;
|
||||||
|
openSyncUi: () => void;
|
||||||
exportLogs: () => void;
|
exportLogs: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
showJellyfinDiscovery: boolean;
|
showJellyfinDiscovery: boolean;
|
||||||
@@ -103,6 +104,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
|||||||
label: 'Open SubMiner Settings',
|
label: 'Open SubMiner Settings',
|
||||||
click: handlers.openConfigSettings,
|
click: handlers.openConfigSettings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Sync Stats && History',
|
||||||
|
click: handlers.openSyncUi,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Export Logs',
|
label: 'Export Logs',
|
||||||
click: handlers.exportLogs,
|
click: handlers.exportLogs,
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export interface AppState {
|
|||||||
jellyfinSetupWindow: BrowserWindow | null;
|
jellyfinSetupWindow: BrowserWindow | null;
|
||||||
firstRunSetupWindow: BrowserWindow | null;
|
firstRunSetupWindow: BrowserWindow | null;
|
||||||
configSettingsWindow: BrowserWindow | null;
|
configSettingsWindow: BrowserWindow | null;
|
||||||
|
syncUiWindow: BrowserWindow | null;
|
||||||
yomitanParserReadyPromise: Promise<void> | null;
|
yomitanParserReadyPromise: Promise<void> | null;
|
||||||
yomitanParserInitPromise: Promise<boolean> | null;
|
yomitanParserInitPromise: Promise<boolean> | null;
|
||||||
mpvClient: MpvIpcClient | null;
|
mpvClient: MpvIpcClient | null;
|
||||||
@@ -238,6 +239,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
jellyfinSetupWindow: null,
|
jellyfinSetupWindow: null,
|
||||||
firstRunSetupWindow: null,
|
firstRunSetupWindow: null,
|
||||||
configSettingsWindow: null,
|
configSettingsWindow: null,
|
||||||
|
syncUiWindow: null,
|
||||||
yomitanParserReadyPromise: null,
|
yomitanParserReadyPromise: null,
|
||||||
yomitanParserInitPromise: null,
|
yomitanParserInitPromise: null,
|
||||||
mpvClient: 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',
|
getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names',
|
||||||
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
|
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
|
||||||
getConfigSettingsYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
|
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: {
|
event: {
|
||||||
subtitleSet: 'subtitle:set',
|
subtitleSet: 'subtitle:set',
|
||||||
@@ -149,6 +161,8 @@ export const IPC_CHANNELS = {
|
|||||||
configHotReload: 'config:hot-reload',
|
configHotReload: 'config:hot-reload',
|
||||||
overlayNotification: 'overlay:notification',
|
overlayNotification: 'overlay:notification',
|
||||||
notificationHistoryToggle: 'notification-history:toggle',
|
notificationHistoryToggle: 'notification-history:toggle',
|
||||||
|
syncUiProgress: 'sync-ui:progress',
|
||||||
|
syncUiStateChanged: 'sync-ui:state-changed',
|
||||||
},
|
},
|
||||||
} as const;
|
} 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