feat(sync-ui): defer app quit until async cleanup resolves

- check-host participates in active-run coordination and can be cancelled
- shutdown() cancels and awaits the active launcher run on quit
- onWillQuit calls preventDefault and re-triggers quit once cleanup settles
- Snapshot mode awaits tracker quiescent before writing output
- Auto-scheduler catches synchronous triggerHostSync failures
- SCP endpoint accepts Windows absolute paths; cmd.exe rejects % in quoted values
- runAppCommand unified (inherit vs pipe stdio) in launcher/mpv.ts
- Docs: add --check, --json, --ui, --sync-cli, --make-temp/--remove-temp examples
This commit is contained in:
2026-07-12 03:35:39 -07:00
parent f8c10edce0
commit a013a7ea55
30 changed files with 356 additions and 135 deletions
+2
View File
@@ -114,6 +114,8 @@ Unfinished sessions (a crash mid-playback) are skipped until the app finalizes t
`subminer sync <host> --check` verifies a host without touching any data: it probes the SSH connection, locates SubMiner on the remote (launcher or app binary), and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes).
`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp <dir>` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically.
### Sync window
`subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine:
+10 -1
View File
@@ -122,8 +122,13 @@ subminer mpv idle # Launch detached idle mpv with SubMiner defau
subminer sync media-box # Sync stats/watch history with an SSH host
subminer sync media-box --push # Merge this machine's stats into the host only
subminer sync media-box --pull # Merge the host's stats into this machine only
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
subminer sync media-box --json # Emit machine-readable NDJSON progress
subminer sync --ui # Open the Sync Stats & History window
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
subminer sync --make-temp # Create an internal sync temp directory
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /path/to/file.mkv
@@ -159,12 +164,16 @@ SubMiner.AppImage --jellyfin-libraries
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
SubMiner.AppImage --help # Show all options
```
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
@@ -367,7 +376,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
| Keybind | Action | Scope |
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
+6 -6
View File
@@ -6,6 +6,12 @@ import { resolveConfigDir } from '../src/config/path-resolution.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js';
import { resolvePathMaybe } from './util.js';
import {
isReadonlyWalRetryError,
withReadonlyWalRetry,
} from '../src/core/services/stats-sync/wal-retry.js';
export { isReadonlyWalRetryError, withReadonlyWalRetry };
export function resolveImmersionDbPath(): string {
const root = readLauncherMainConfigObject();
@@ -43,12 +49,6 @@ export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
}
export {
withReadonlyWalRetry,
isReadonlyWalRetryError,
} from '../src/core/services/stats-sync/wal-retry.js';
import { withReadonlyWalRetry } from '../src/core/services/stats-sync/wal-retry.js';
function tableExists(db: Database, tableName: string): boolean {
return Boolean(
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
+15 -17
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import type { StdioOptions } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import {
@@ -1444,22 +1445,7 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
}
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`);
});
proc.once('close', (code) => {
process.exit(code ?? 0);
});
runAppCommand(appPath, appArgs, ['ignore', 'pipe', 'pipe'], true);
}
/**
@@ -1469,15 +1455,27 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
* Used for `subminer sync`, which proxies to the app's --sync-cli mode.
*/
export function runAppCommandInteractive(appPath: string, appArgs: string[]): void {
runAppCommand(appPath, appArgs, ['inherit', 'inherit', 'inherit'], false);
}
function runAppCommand(
appPath: string,
appArgs: string[],
stdio: StdioOptions,
attachLogging: boolean,
): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['inherit', 'inherit', 'inherit'],
stdio,
env: buildAppEnv(process.env, target.env),
});
if (attachLogging) {
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
}
proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`);
});
+1 -1
View File
@@ -82,7 +82,7 @@ ${B}Jellyfin${R}
${B}Stats sync${R}
--sync-window Open the stats sync window
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
${D}run --sync-cli --help for details)${R}
${D}run SubMiner --sync-cli --help for details)${R}
${B}Options${R}
--socket ${D}PATH${R} mpv IPC socket path
+34 -2
View File
@@ -140,6 +140,38 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
assert.equal(getLockCalls(), 1);
});
test('startAppLifecycle defers quit until async cleanup settles', async () => {
let willQuit: ((event: { preventDefault(): void }) => void) | null = null;
let releaseCleanup: (() => void) | null = null;
const cleanupDone = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
let prevented = false;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
onWillQuit: (handler) => {
willQuit = handler;
},
onWillQuitCleanup: () => cleanupDone,
});
startAppLifecycle(makeArgs({ start: true }), deps);
assert.ok(willQuit);
(willQuit as (event: { preventDefault(): void }) => void)({
preventDefault: () => {
prevented = true;
},
});
assert.equal(prevented, true);
assert.deepEqual(calls, []);
assert.ok(releaseCleanup);
(releaseCleanup as () => void)();
await cleanupDone;
await Promise.resolve();
assert.deepEqual(calls, ['quitApp']);
});
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
const { deps, calls, getLockCalls } = createDeps({
shouldStartApp: () => false,
@@ -252,7 +284,7 @@ test('startAppLifecycle routes control socket commands through the second-instan
},
});
let willQuitHandler: (() => void) | null = null;
let willQuitHandler: ((event: { preventDefault(): void }) => void) | null = null;
deps.onWillQuit = (handler) => {
willQuitHandler = handler;
};
@@ -274,7 +306,7 @@ test('startAppLifecycle routes control socket commands through the second-instan
assert.deepEqual(handled, ['ready', 'second-instance:start']);
assert.ok(willQuitHandler);
(willQuitHandler as () => void)();
(willQuitHandler as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} });
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
});
+24 -5
View File
@@ -16,11 +16,11 @@ export interface AppLifecycleServiceDeps {
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
onWillQuit: (handler: (event: { preventDefault(): void }) => void) => void;
onActivate: (handler: () => void) => void;
isDarwinPlatform: () => boolean;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
@@ -44,7 +44,7 @@ export interface AppLifecycleDepsRuntimeOptions {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
@@ -189,10 +189,29 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
}
});
deps.onWillQuit(() => {
let quitCleanupPending = false;
let quitCleanupComplete = false;
deps.onWillQuit((event) => {
if (quitCleanupComplete) return;
stopControlServer?.();
stopControlServer = null;
deps.onWillQuitCleanup();
if (quitCleanupPending) {
event.preventDefault();
return;
}
const cleanup = deps.onWillQuitCleanup();
if (!(cleanup instanceof Promise)) return;
quitCleanupPending = true;
event.preventDefault();
void cleanup
.catch((error) => {
logger.error('App quit cleanup failed:', error);
})
.finally(() => {
quitCleanupPending = false;
quitCleanupComplete = true;
deps.quitApp();
});
});
deps.onActivate(() => {
+1 -1
View File
@@ -8,8 +8,8 @@ import { selectOne, type OpenSyncDb, type SyncDb } from './driver';
export { SCHEMA_VERSION };
export type { SyncMergeSummary } from '../../../shared/sync/sync-events';
import type { SyncMergeSummary } from '../../../shared/sync/sync-events';
export type { SyncMergeSummary };
export function createEmptyMergeSummary(): SyncMergeSummary {
return {
+25 -10
View File
@@ -64,10 +64,15 @@ test('resolveRemoteSubminerCommand verifies the launcher under the remote runtim
test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => {
const probed: string[] = [];
const command = resolveRemoteSubminerCommand('media-box', null, 'posix', (_host, remoteCommand) => {
probed.push(remoteCommand);
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1);
});
const command = resolveRemoteSubminerCommand(
'media-box',
null,
'posix',
(_host, remoteCommand) => {
probed.push(remoteCommand);
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1);
},
);
assert.match(command, / SubMiner --sync-cli$/);
// Launcher candidates (PATH + ~/.local/bin) are tried before app binaries.
@@ -77,13 +82,19 @@ test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mo
});
test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => {
const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', 'posix', (_host, cmd) =>
remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
const asApp = resolveRemoteSubminerCommand(
'media-box',
'/opt/SubMiner.AppImage',
'posix',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
);
assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/);
const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', 'posix', (_host, cmd) =>
remoteResult(cmd.includes('--sync-cli') ? 1 : 0),
const asLauncher = resolveRemoteSubminerCommand(
'media-box',
'/opt/subminer',
'posix',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 1 : 0),
);
assert.match(asLauncher, /'\/opt\/subminer'$/);
@@ -104,8 +115,11 @@ test('resolveRemoteSubminerCommand probes Windows install locations without a PA
assert.equal(probed[0], 'subminer --help');
assert.equal(probed[1], '"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd" --help');
const powershell = resolveRemoteSubminerCommand('win-box', null, 'windows-powershell', (_host, cmd) =>
remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1),
const powershell = resolveRemoteSubminerCommand(
'win-box',
null,
'windows-powershell',
(_host, cmd) => remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1),
);
assert.equal(powershell, '& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe" --sync-cli');
});
@@ -158,6 +172,7 @@ test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values',
'"C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab"',
);
assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/);
assert.throws(() => quoteForRemoteShell('windows-cmd', 'C:/tmp/%TEMP%/db.sqlite'), /percent/);
assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/);
});
+4
View File
@@ -35,6 +35,7 @@ export function runSsh(host: string, remoteCommand: string): RemoteRunResult {
}
function assertSafeScpEndpoint(endpoint: string): void {
if (/^[A-Za-z]:[\\/]/.test(endpoint)) return;
const colon = endpoint.indexOf(':');
const slash = endpoint.indexOf('/');
if (colon <= 0 || (slash !== -1 && slash < colon)) {
@@ -116,6 +117,9 @@ export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): s
if (value.includes('"')) {
throw new Error(`Refusing to quote a value with quotes for a Windows shell: ${value}`);
}
if (value.includes('%')) {
throw new Error(`Refusing to quote a value with percent signs for cmd.exe: ${value}`);
}
return `"${value}"`;
}
+22 -5
View File
@@ -136,6 +136,9 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
true,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
assert.ok(
calls.indexOf('quiescent') < calls.indexOf('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'),
);
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
@@ -200,7 +203,8 @@ test('runHostSync keeps tracker quiescent through both merges and cleans up afte
});
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
() =>
runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
);
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
@@ -222,7 +226,8 @@ test('runHostSync includes remote snapshot stderr in failures', async () => {
});
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
() =>
runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
);
});
@@ -357,7 +362,12 @@ test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
});
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncCheck: true, syncJson: true }),
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
deps,
);
@@ -374,7 +384,12 @@ test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
const failLines: string[] = [];
await assert.rejects(() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncCheck: true, syncJson: true }),
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
makeDeps({
consoleLog: (line) => {
failLines.push(line);
@@ -424,7 +439,9 @@ test('runHostSync speaks Windows shells: app command, double quotes, temp protoc
assert.ok(scpCalls.some((call) => call.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`)));
assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`)));
// No POSIX shell-isms reach a Windows remote.
assert.ok(!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')));
assert.ok(
!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')),
);
assert.ok(!sshCommands.some((command) => command.includes('PATH="$HOME')));
});
+8 -4
View File
@@ -145,11 +145,12 @@ export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
};
}
export function runSnapshotMode(
export async function runSnapshotMode(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): void {
): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const outPath = deps.resolvePath(context.args.syncSnapshotPath);
deps.emitEvent({
type: 'stage',
@@ -409,7 +410,10 @@ export async function runHostSync(
}
}
export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowDeps): Promise<boolean> {
export async function runSyncFlow(
context: SyncFlowContext,
inputDeps: SyncFlowDeps,
): Promise<boolean> {
let deps = inputDeps;
const { args } = context;
if (!args.sync) return false;
@@ -430,7 +434,7 @@ export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowD
if (args.syncCheck) {
await runCheckMode(context, deps);
} else if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
await runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
+4 -1
View File
@@ -3951,7 +3951,10 @@ const {
annotationSubtitleWsService.stop();
},
stopTexthookerService: () => texthookerService.stop(),
stopSyncAutoScheduler: () => syncAutoScheduler.stop(),
stopSyncAutoScheduler: async () => {
syncAutoScheduler.stop();
await syncUiRuntime.shutdown();
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
+1 -1
View File
@@ -13,7 +13,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
@@ -16,7 +16,9 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
+4 -3
View File
@@ -6,7 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void;
@@ -34,7 +34,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
return (): Promise<void> => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -42,7 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.stopSyncAutoScheduler();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -72,6 +72,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService();
return Promise.resolve(stopSyncAutoScheduler);
};
}
@@ -19,7 +19,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -26,7 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
@@ -15,7 +15,9 @@ test('app lifecycle runtime runner main deps builder maps lifecycle callbacks',
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
@@ -23,7 +23,9 @@ test('startup runtime handlers compose lifecycle runner and startup bootstrap ap
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
@@ -91,6 +91,28 @@ test('tick triggers at most one host per tick', () => {
assert.equal(triggered.length, 1);
});
test('tick logs a synchronous trigger failure and remains usable', () => {
const state = makeState();
const logs: string[] = [];
let attempts = 0;
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
canAutoSync: () => true,
triggerHostSync: () => {
attempts += 1;
throw new Error('spawn exploded');
},
nowMs: () => 100 * 60_000,
log: (message) => logs.push(message),
});
assert.doesNotThrow(() => scheduler.tick());
assert.doesNotThrow(() => scheduler.tick());
assert.equal(attempts, 2);
assert.ok(logs.some((message) => message.includes('spawn exploded')));
});
test('start/stop manage the interval timer', () => {
const state = makeState();
let setCalls = 0;
+7 -1
View File
@@ -30,7 +30,13 @@ export function createSyncAutoScheduler(deps: SyncAutoSchedulerDeps) {
);
if (!due) return;
deps.log?.(`Auto-sync starting for ${due.host} (${due.direction})`);
deps.triggerHostSync(due.host, due.direction);
try {
deps.triggerHostSync(due.host, due.direction);
} catch (error) {
deps.log?.(
`Auto-sync failed to start: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function start(): void {
+49 -1
View File
@@ -14,7 +14,7 @@ interface LauncherCall {
cancelled: boolean;
}
function makeTestRig(root: string) {
function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
const launcherCalls: LauncherCall[] = [];
const sent: Array<{ channel: string; payload: unknown }> = [];
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
@@ -59,6 +59,7 @@ function makeTestRig(root: string) {
pickSnapshotFile: async () => null,
revealPath: () => {},
nowMs: () => 1700000000000,
...overrides,
};
const runtime = createSyncUiRuntime(deps);
@@ -230,6 +231,53 @@ test('check-host resolves with the check-result event', () =>
assert.equal(result.sshOk, true);
}));
test('check-host participates in active-run coordination and can be cancelled', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{ ok: boolean }>;
await new Promise((resolve) => setImmediate(resolve));
const blocked = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
started: boolean;
reason: string | null;
};
assert.equal(blocked.started, false);
assert.match(blocked.reason ?? '', /already running/i);
assert.equal(await invoke('sync-ui:cancel-run'), true);
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal((await pending).ok, false);
}));
test('shutdown cancels and awaits the active launcher run', () =>
withTempDir(async (root) => {
const { runtime, invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:create-snapshot');
await runtime.shutdown();
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal(runtime.isRunning(), false);
}));
test('post-run side-effect failures are logged without rejecting cleanup', () =>
withTempDir(async (root) => {
const logs: string[] = [];
const { invoke, launcherCalls } = makeTestRig(root, {
getWindow: () => ({
isDestroyed: () => false,
webContents: {
send: () => {
throw new Error('window gone');
},
},
}),
log: (message) => logs.push(message),
});
await invoke('sync-ui:create-snapshot');
launcherCalls[0]!.finish({ ok: true, error: null });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(logs.some((message) => message.includes('window gone')));
}));
test('cancel-run cancels the active run', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
+66 -28
View File
@@ -56,6 +56,7 @@ interface ActiveRun {
host: string | null;
handle: SyncLauncherRunHandle;
resultSeen: boolean;
completion?: Promise<void>;
}
// Milliseconds are part of the stamp so two snapshots taken in the same second
@@ -169,32 +170,38 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
}),
};
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',
},
run.completion = 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',
},
);
}
})
.catch((error) => {
deps.log?.(
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
});
});
return { started: true, runId, reason: null };
}
@@ -231,12 +238,17 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
if (!isValidSyncHost(trimmed)) {
return Promise.resolve(failed(`Invalid sync host: ${host}`));
}
if (currentRun) {
return Promise.resolve(failed('A sync operation is already running.'));
}
const resolution = deps.resolveLauncherCommand();
if (!resolution.command) {
return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.'));
}
return new Promise((resolve) => {
let checkResult: SyncUiCheckResult | null = null;
runCounter += 1;
const runId = runCounter;
const handle = deps.runLauncher({
command: resolution.command!,
args: ['sync', trimmed, '--check', '--json'],
@@ -254,9 +266,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
},
onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`),
});
void handle.done.then((result) => {
resolve(checkResult ?? failed(result.error ?? 'Connection check failed.'));
});
const run: ActiveRun = {
id: runId,
kind: 'check',
host: trimmed,
handle,
resultSeen: false,
};
currentRun = run;
run.completion = handle.done
.then((result) => {
if (currentRun?.id === runId) currentRun = null;
resolve(checkResult ?? failed(result.error ?? 'Connection check failed.'));
broadcastStateChanged();
})
.catch((error) => {
if (currentRun?.id === runId) currentRun = null;
resolve(failed(error instanceof Error ? error.message : String(error)));
deps.log?.(
`[sync-ui check] Cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
);
});
});
}
@@ -292,6 +322,13 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
return true;
}
async function shutdown(): Promise<void> {
const run = currentRun;
if (!run) return;
run.handle.cancel();
await (run.completion ?? run.handle.done.then(() => undefined));
}
function registerHandlers(): void {
const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
@@ -334,6 +371,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
runHostSync,
checkHost,
cancelRun,
shutdown,
isRunning: () => currentRun !== null,
};
}
+3 -1
View File
@@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.openSyncUi();
handlers.exportLogs();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true);
@@ -71,7 +72,7 @@ test('build tray template handler wires actions and init guards', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -97,6 +98,7 @@ test('build tray template handler wires actions and init guards', () => {
'setup-forced',
'yomitan',
'configuration',
'sync-ui',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
+1 -1
View File
@@ -14,7 +14,7 @@ export interface AppLifecycleRuntimeRunnerParams {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
+18 -33
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import type {
SyncHostsState,
SyncUiAPI,
@@ -11,55 +12,39 @@ import type {
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),
getSnapshot: (): Promise<SyncUiSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot),
saveHost: (update: SyncUiHostUpdateRequest): Promise<SyncHostsState> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.saveHost, update),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update),
removeHost: (host: string): Promise<SyncHostsState> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.removeHost, host),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host),
setAutoSyncInterval: (minutes: number): Promise<SyncHostsState> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.setAutoSyncInterval, minutes),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSetAutoSyncInterval, minutes),
runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.runSync, request),
cancelRun: (): Promise<boolean> => ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.cancelRun),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request),
cancelRun: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCancelRun),
checkHost: (host: string): Promise<SyncUiCheckResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.checkHost, host),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCheckHost, host),
createSnapshot: (): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.createSnapshot),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot),
mergeSnapshotFile: (path: string, force?: boolean): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.mergeSnapshotFile, path, force === true),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiMergeSnapshotFile, path, force === true),
deleteSnapshot: (path: string): Promise<SyncUiSnapshotFile[]> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.deleteSnapshot, path),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiDeleteSnapshot, path),
revealSnapshot: (path: string): Promise<boolean> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.revealSnapshot, path),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRevealSnapshot, path),
pickSnapshotFile: (): Promise<string | null> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.pickSnapshotFile),
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiPickSnapshotFile),
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);
ipcRenderer.on(IPC_CHANNELS.event.syncUiProgress, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.syncUiProgress, 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);
ipcRenderer.on(IPC_CHANNELS.event.syncUiStateChanged, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.syncUiStateChanged, handler);
},
};
+6 -6
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self';"
content="default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'self';"
/>
<title>SubMiner Sync</title>
<link rel="stylesheet" href="style.css" />
@@ -69,15 +69,15 @@
<code>ssh-copy-id user@hostname</code> (no password prompts).
</li>
<li>
The remote machine just needs SubMiner installed. It is found automatically in
its default install location (Windows), as <code>SubMiner</code> on the remote
PATH, a macOS <code>/Applications</code> install, or the optional
The remote machine just needs SubMiner installed. It is found automatically in its
default install location (Windows), as <code>SubMiner</code> on the remote PATH, a
macOS <code>/Applications</code> install, or the optional
<code>subminer</code> command-line launcher. Windows remotes need the built-in
OpenSSH Server enabled.
</li>
<li>
Use <em>Test connection</em> to verify both. It checks SSH and finds SubMiner on
the remote in one go.
Use <em>Test connection</em> to verify both. It checks SSH and finds SubMiner on the
remote in one go.
</li>
</ol>
</details>
+8 -2
View File
@@ -29,20 +29,24 @@ test('summarizeMergeCounts lists only non-zero counts', () => {
wordsAdded: 0,
kanjiAdded: 0,
subtitleLinesAdded: 450,
telemetryRowsAdded: 0,
eventsAdded: 0,
telemetryRowsAdded: 3,
eventsAdded: 4,
excludedWordsAdded: 0,
dailyRollupsCopied: 0,
monthlyRollupsCopied: 0,
rollupGroupsRecomputed: 4,
};
const lines = summarizeMergeCounts(summary);
assert.ok(lines.some((line) => line.label === 'Telemetry rows' && line.value === 3));
assert.ok(lines.some((line) => line.label === 'Events' && line.value === 4));
assert.deepEqual(lines, [
{ label: 'Sessions merged', value: 3 },
{ label: 'Already present', value: 12 },
{ label: 'Series added', value: 1 },
{ label: 'Videos added', value: 2 },
{ label: 'Subtitle lines', value: 450 },
{ label: 'Telemetry rows', value: 3 },
{ label: 'Events', value: 4 },
{ label: 'Rollups recomputed', value: 4 },
]);
@@ -53,6 +57,8 @@ test('summarizeMergeCounts lists only non-zero counts', () => {
animeAdded: 0,
videosAdded: 0,
subtitleLinesAdded: 0,
telemetryRowsAdded: 0,
eventsAdded: 0,
rollupGroupsRecomputed: 0,
});
assert.deepEqual(empty, [{ label: 'Sessions merged', value: 0 }]);
+2
View File
@@ -35,6 +35,8 @@ const MERGE_COUNT_FIELDS: Array<{ key: keyof SyncMergeSummary; label: string }>
{ key: 'wordsAdded', label: 'Words added' },
{ key: 'kanjiAdded', label: 'Kanji added' },
{ key: 'subtitleLinesAdded', label: 'Subtitle lines' },
{ key: 'telemetryRowsAdded', label: 'Telemetry rows' },
{ key: 'eventsAdded', label: 'Events' },
{ key: 'excludedWordsAdded', label: 'Excluded words' },
{ key: 'dailyRollupsCopied', label: 'Daily rollups' },
{ key: 'monthlyRollupsCopied', label: 'Monthly rollups' },