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). `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 ### Sync window
`subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine: `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 # 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 --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 --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 --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB 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 /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
subminer dictionary --candidates /path/to/file.mkv subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /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-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-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 --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 # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series 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 --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
SubMiner.AppImage --help # Show all options 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. 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. 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 | | Keybind | Action | Scope |
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- | | ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) | | `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. `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 { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js'; import type { HistoryVideoRow } from './history-types.js';
import { resolvePathMaybe } from './util.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 { export function resolveImmersionDbPath(): string {
const root = readLauncherMainConfigObject(); const root = readLauncherMainConfigObject();
@@ -43,12 +49,6 @@ export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options)); 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 { function tableExists(db: Database, tableName: string): boolean {
return Boolean( return Boolean(
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName), 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 os from 'node:os';
import net from 'node:net'; import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import type { StdioOptions } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js'; import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js'; import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import { import {
@@ -1444,22 +1445,7 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
} }
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void { export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) { runAppCommand(appPath, appArgs, ['ignore', 'pipe', 'pipe'], true);
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);
});
} }
/** /**
@@ -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. * Used for `subminer sync`, which proxies to the app's --sync-cli mode.
*/ */
export function runAppCommandInteractive(appPath: string, appArgs: string[]): void { 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)) { if (maybeCaptureAppArgs(appArgs)) {
process.exit(0); process.exit(0);
} }
const target = resolveAppSpawnTarget(appPath, appArgs); const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: ['inherit', 'inherit', 'inherit'], stdio,
env: buildAppEnv(process.env, target.env), env: buildAppEnv(process.env, target.env),
}); });
if (attachLogging) {
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
}
proc.once('error', (error) => { proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`); fail(`Failed to run app command: ${error.message}`);
}); });
+1 -1
View File
@@ -82,7 +82,7 @@ ${B}Jellyfin${R}
${B}Stats sync${R} ${B}Stats sync${R}
--sync-window Open the stats sync window --sync-window Open the stats sync window
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R} --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} ${B}Options${R}
--socket ${D}PATH${R} mpv IPC socket path --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); 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', () => { test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
const { deps, calls, getLockCalls } = createDeps({ const { deps, calls, getLockCalls } = createDeps({
shouldStartApp: () => false, 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) => { deps.onWillQuit = (handler) => {
willQuitHandler = 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.deepEqual(handled, ['ready', 'second-instance:start']);
assert.ok(willQuitHandler); assert.ok(willQuitHandler);
(willQuitHandler as () => void)(); (willQuitHandler as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} });
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']); 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; startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise<void>) => void; whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void; onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void; onWillQuit: (handler: (event: { preventDefault(): void }) => void) => void;
onActivate: (handler: () => void) => void; onActivate: (handler: () => void) => void;
isDarwinPlatform: () => boolean; isDarwinPlatform: () => boolean;
onReady: () => Promise<void>; onReady: () => Promise<void>;
onWillQuitCleanup: () => void; onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean; shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void; restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean; shouldQuitOnWindowAllClosed: () => boolean;
@@ -44,7 +44,7 @@ export interface AppLifecycleDepsRuntimeOptions {
logNoRunningInstance: () => void; logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void; startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>; onReady: () => Promise<void>;
onWillQuitCleanup: () => void; onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean; shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void; restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean; 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?.();
stopControlServer = null; 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(() => { deps.onActivate(() => {
+1 -1
View File
@@ -8,8 +8,8 @@ import { selectOne, type OpenSyncDb, type SyncDb } from './driver';
export { SCHEMA_VERSION }; export { SCHEMA_VERSION };
export type { SyncMergeSummary } from '../../../shared/sync/sync-events';
import type { SyncMergeSummary } from '../../../shared/sync/sync-events'; import type { SyncMergeSummary } from '../../../shared/sync/sync-events';
export type { SyncMergeSummary };
export function createEmptyMergeSummary(): SyncMergeSummary { export function createEmptyMergeSummary(): SyncMergeSummary {
return { 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', () => { test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => {
const probed: string[] = []; const probed: string[] = [];
const command = resolveRemoteSubminerCommand('media-box', null, 'posix', (_host, remoteCommand) => { const command = resolveRemoteSubminerCommand(
probed.push(remoteCommand); 'media-box',
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1); null,
}); 'posix',
(_host, remoteCommand) => {
probed.push(remoteCommand);
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1);
},
);
assert.match(command, / SubMiner --sync-cli$/); assert.match(command, / SubMiner --sync-cli$/);
// Launcher candidates (PATH + ~/.local/bin) are tried before app binaries. // 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', () => { test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => {
const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', 'posix', (_host, cmd) => const asApp = resolveRemoteSubminerCommand(
remoteResult(cmd.includes('--sync-cli') ? 0 : 1), 'media-box',
'/opt/SubMiner.AppImage',
'posix',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
); );
assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/); assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/);
const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', 'posix', (_host, cmd) => const asLauncher = resolveRemoteSubminerCommand(
remoteResult(cmd.includes('--sync-cli') ? 1 : 0), 'media-box',
'/opt/subminer',
'posix',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 1 : 0),
); );
assert.match(asLauncher, /'\/opt\/subminer'$/); 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[0], 'subminer --help');
assert.equal(probed[1], '"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd" --help'); assert.equal(probed[1], '"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd" --help');
const powershell = resolveRemoteSubminerCommand('win-box', null, 'windows-powershell', (_host, cmd) => const powershell = resolveRemoteSubminerCommand(
remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1), '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'); 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"', '"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', '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/); 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 { function assertSafeScpEndpoint(endpoint: string): void {
if (/^[A-Za-z]:[\\/]/.test(endpoint)) return;
const colon = endpoint.indexOf(':'); const colon = endpoint.indexOf(':');
const slash = endpoint.indexOf('/'); const slash = endpoint.indexOf('/');
if (colon <= 0 || (slash !== -1 && slash < colon)) { if (colon <= 0 || (slash !== -1 && slash < colon)) {
@@ -116,6 +117,9 @@ export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): s
if (value.includes('"')) { if (value.includes('"')) {
throw new Error(`Refusing to quote a value with quotes for a Windows shell: ${value}`); 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}"`; return `"${value}"`;
} }
+22 -5
View File
@@ -136,6 +136,9 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
true, true,
); );
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite')); 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( await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }), 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( 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/, /Remote merge failed on media-box[\s\S]*remote merge exploded/,
); );
assert.equal(calls.filter((call) => call === 'quiescent').length, 3); 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( 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/, /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( 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, deps,
); );
@@ -374,7 +384,12 @@ test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
const failLines: string[] = []; const failLines: string[] = [];
await assert.rejects(() => await assert.rejects(() =>
runSyncFlow( 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({ makeDeps({
consoleLog: (line) => { consoleLog: (line) => {
failLines.push(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.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`)));
assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`))); assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`)));
// No POSIX shell-isms reach a Windows remote. // 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'))); 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, context: SyncFlowContext,
dbPath: string, dbPath: string,
deps: SyncFlowDeps, deps: SyncFlowDeps,
): void { ): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const outPath = deps.resolvePath(context.args.syncSnapshotPath); const outPath = deps.resolvePath(context.args.syncSnapshotPath);
deps.emitEvent({ deps.emitEvent({
type: 'stage', 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; let deps = inputDeps;
const { args } = context; const { args } = context;
if (!args.sync) return false; if (!args.sync) return false;
@@ -430,7 +434,7 @@ export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowD
if (args.syncCheck) { if (args.syncCheck) {
await runCheckMode(context, deps); await runCheckMode(context, deps);
} else if (args.syncSnapshotPath) { } else if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps); await runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) { } else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps); await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) { } else if (args.syncHost) {
+4 -1
View File
@@ -3951,7 +3951,10 @@ const {
annotationSubtitleWsService.stop(); annotationSubtitleWsService.stop();
}, },
stopTexthookerService: () => texthookerService.stop(), stopTexthookerService: () => texthookerService.stop(),
stopSyncAutoScheduler: () => syncAutoScheduler.stop(), stopSyncAutoScheduler: async () => {
syncAutoScheduler.stop();
await syncUiRuntime.shutdown();
},
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
clearWindowsVisibleOverlayForegroundPollLoop(), clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => { clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
+1 -1
View File
@@ -13,7 +13,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
logNoRunningInstance: () => void; logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void; startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>; onReady: () => Promise<void>;
onWillQuitCleanup: () => void; onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean; shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void; restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean; shouldQuitOnWindowAllClosed: () => boolean;
@@ -16,7 +16,9 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'), stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'), calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
+4 -3
View File
@@ -6,7 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void; stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void; clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void; clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void; destroyMainOverlayWindow: () => void;
@@ -34,7 +34,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
return (): void => { return (): Promise<void> => {
deps.destroyTray(); deps.destroyTray();
deps.stopConfigHotReload(); deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility(); deps.restorePreviousSecondarySubVisibility();
@@ -42,7 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts(); deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket(); deps.stopSubtitleWebsocket();
deps.stopTexthookerService(); deps.stopTexthookerService();
deps.stopSyncAutoScheduler(); const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
deps.clearWindowsVisibleOverlayForegroundPollLoop(); deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(); deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow(); deps.destroyMainOverlayWindow();
@@ -72,6 +72,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupYoutubeSubtitleTempDirs(); deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache(); deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService(); 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'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'), stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'), calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -26,7 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void; stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void; clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void; clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null; getMainOverlayWindow: () => DestroyableWindow | null;
@@ -15,7 +15,9 @@ test('app lifecycle runtime runner main deps builder maps lifecycle callbacks',
onReady: async () => { onReady: async () => {
calls.push('ready'); calls.push('ready');
}, },
onWillQuitCleanup: () => calls.push('cleanup'), onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true, shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'), restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false, shouldQuitOnWindowAllClosed: () => false,
@@ -23,7 +23,9 @@ test('startup runtime handlers compose lifecycle runner and startup bootstrap ap
onReady: async () => { onReady: async () => {
calls.push('ready'); calls.push('ready');
}, },
onWillQuitCleanup: () => calls.push('cleanup'), onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true, shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'), restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false, shouldQuitOnWindowAllClosed: () => false,
@@ -91,6 +91,28 @@ test('tick triggers at most one host per tick', () => {
assert.equal(triggered.length, 1); 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', () => { test('start/stop manage the interval timer', () => {
const state = makeState(); const state = makeState();
let setCalls = 0; let setCalls = 0;
+7 -1
View File
@@ -30,7 +30,13 @@ export function createSyncAutoScheduler(deps: SyncAutoSchedulerDeps) {
); );
if (!due) return; if (!due) return;
deps.log?.(`Auto-sync starting for ${due.host} (${due.direction})`); 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 { function start(): void {
+49 -1
View File
@@ -14,7 +14,7 @@ interface LauncherCall {
cancelled: boolean; cancelled: boolean;
} }
function makeTestRig(root: string) { function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
const launcherCalls: LauncherCall[] = []; const launcherCalls: LauncherCall[] = [];
const sent: Array<{ channel: string; payload: unknown }> = []; const sent: Array<{ channel: string; payload: unknown }> = [];
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>(); const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
@@ -59,6 +59,7 @@ function makeTestRig(root: string) {
pickSnapshotFile: async () => null, pickSnapshotFile: async () => null,
revealPath: () => {}, revealPath: () => {},
nowMs: () => 1700000000000, nowMs: () => 1700000000000,
...overrides,
}; };
const runtime = createSyncUiRuntime(deps); const runtime = createSyncUiRuntime(deps);
@@ -230,6 +231,53 @@ test('check-host resolves with the check-result event', () =>
assert.equal(result.sshOk, true); assert.equal(result.sshOk, true);
})); }));
test('check-host participates in active-run coordination and can be cancelled', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{ ok: boolean }>;
await new Promise((resolve) => setImmediate(resolve));
const blocked = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
started: boolean;
reason: string | null;
};
assert.equal(blocked.started, false);
assert.match(blocked.reason ?? '', /already running/i);
assert.equal(await invoke('sync-ui:cancel-run'), true);
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal((await pending).ok, false);
}));
test('shutdown cancels and awaits the active launcher run', () =>
withTempDir(async (root) => {
const { runtime, invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:create-snapshot');
await runtime.shutdown();
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal(runtime.isRunning(), false);
}));
test('post-run side-effect failures are logged without rejecting cleanup', () =>
withTempDir(async (root) => {
const logs: string[] = [];
const { invoke, launcherCalls } = makeTestRig(root, {
getWindow: () => ({
isDestroyed: () => false,
webContents: {
send: () => {
throw new Error('window gone');
},
},
}),
log: (message) => logs.push(message),
});
await invoke('sync-ui:create-snapshot');
launcherCalls[0]!.finish({ ok: true, error: null });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(logs.some((message) => message.includes('window gone')));
}));
test('cancel-run cancels the active run', () => test('cancel-run cancels the active run', () =>
withTempDir(async (root) => { withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root); const { invoke, launcherCalls } = makeTestRig(root);
+66 -28
View File
@@ -56,6 +56,7 @@ interface ActiveRun {
host: string | null; host: string | null;
handle: SyncLauncherRunHandle; handle: SyncLauncherRunHandle;
resultSeen: boolean; resultSeen: boolean;
completion?: Promise<void>;
} }
// Milliseconds are part of the stamp so two snapshots taken in the same second // 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; currentRun = run;
void run.handle.done.then((result: SyncLauncherRunResult) => { run.completion = run.handle.done
if (currentRun?.id === runId) currentRun = null; .then((result: SyncLauncherRunResult) => {
// If the launcher died without emitting a result event (spawn failure, if (currentRun?.id === runId) currentRun = null;
// kill), synthesize one so the renderer can settle its progress view. // If the launcher died without emitting a result event (spawn failure,
if (!run.resultSeen) { // kill), synthesize one so the renderer can settle its progress view.
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { if (!run.resultSeen) {
runId, sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
kind, runId,
host, kind,
event: { type: 'result', ok: result.ok, error: result.error }, host,
}); event: { type: 'result', ok: result.ok, error: result.error },
} });
broadcastStateChanged(); }
if (options.notify && deps.notify) { broadcastStateChanged();
const target = host ?? 'local database'; if (options.notify && deps.notify) {
deps.notify( const target = host ?? 'local database';
result.ok deps.notify(
? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' } result.ok
: { ? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' }
title: 'Sync failed', : {
body: `${target}: ${result.error ?? 'unknown error'}`, title: 'Sync failed',
variant: 'error', 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 }; return { started: true, runId, reason: null };
} }
@@ -231,12 +238,17 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
if (!isValidSyncHost(trimmed)) { if (!isValidSyncHost(trimmed)) {
return Promise.resolve(failed(`Invalid sync host: ${host}`)); return Promise.resolve(failed(`Invalid sync host: ${host}`));
} }
if (currentRun) {
return Promise.resolve(failed('A sync operation is already running.'));
}
const resolution = deps.resolveLauncherCommand(); const resolution = deps.resolveLauncherCommand();
if (!resolution.command) { if (!resolution.command) {
return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.')); return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.'));
} }
return new Promise((resolve) => { return new Promise((resolve) => {
let checkResult: SyncUiCheckResult | null = null; let checkResult: SyncUiCheckResult | null = null;
runCounter += 1;
const runId = runCounter;
const handle = deps.runLauncher({ const handle = deps.runLauncher({
command: resolution.command!, command: resolution.command!,
args: ['sync', trimmed, '--check', '--json'], args: ['sync', trimmed, '--check', '--json'],
@@ -254,9 +266,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
}, },
onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`), onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`),
}); });
void handle.done.then((result) => { const run: ActiveRun = {
resolve(checkResult ?? failed(result.error ?? 'Connection check failed.')); 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; 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 { function registerHandlers(): void {
const channels = IPC_CHANNELS.request; const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot()); deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
@@ -334,6 +371,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
runHostSync, runHostSync,
checkHost, checkHost,
cancelRun, cancelRun,
shutdown,
isRunning: () => currentRun !== null, isRunning: () => currentRun !== null,
}; };
} }
+3 -1
View File
@@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openWindowsMpvLauncherSetup(); handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings(); handlers.openYomitanSettings();
handlers.openConfigSettings(); handlers.openConfigSettings();
handlers.openSyncUi();
handlers.exportLogs(); handlers.exportLogs();
handlers.openJellyfinSetup(); handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true); handlers.toggleJellyfinDiscovery(true);
@@ -71,7 +72,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'), 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,
@@ -97,6 +98,7 @@ test('build tray template handler wires actions and init guards', () => {
'setup-forced', 'setup-forced',
'yomitan', 'yomitan',
'configuration', 'configuration',
'sync-ui',
'export-logs', 'export-logs',
'jellyfin', 'jellyfin',
'jellyfin-discovery:true', 'jellyfin-discovery:true',
+1 -1
View File
@@ -14,7 +14,7 @@ export interface AppLifecycleRuntimeRunnerParams {
logNoRunningInstance: () => void; logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void; startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>; onReady: () => Promise<void>;
onWillQuitCleanup: () => void; onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean; shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void; restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean; shouldQuitOnWindowAllClosed: () => boolean;
+18 -33
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import type { import type {
SyncHostsState, SyncHostsState,
SyncUiAPI, SyncUiAPI,
@@ -11,55 +12,39 @@ import type {
SyncUiStartResult, SyncUiStartResult,
} from './types/sync-ui'; } 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 = { 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> => 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> => 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> => 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> => runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.runSync, request), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request),
cancelRun: (): Promise<boolean> => ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.cancelRun), cancelRun: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCancelRun),
checkHost: (host: string): Promise<SyncUiCheckResult> => checkHost: (host: string): Promise<SyncUiCheckResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.checkHost, host), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCheckHost, host),
createSnapshot: (): Promise<SyncUiStartResult> => createSnapshot: (): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.createSnapshot), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot),
mergeSnapshotFile: (path: string, force?: boolean): Promise<SyncUiStartResult> => 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[]> => 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> => revealSnapshot: (path: string): Promise<boolean> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.revealSnapshot, path), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRevealSnapshot, path),
pickSnapshotFile: (): Promise<string | null> => pickSnapshotFile: (): Promise<string | null> =>
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.pickSnapshotFile), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiPickSnapshotFile),
onProgress: (listener: (payload: SyncUiProgressPayload) => void): (() => void) => { onProgress: (listener: (payload: SyncUiProgressPayload) => void): (() => void) => {
const handler = (_event: unknown, payload: SyncUiProgressPayload): void => listener(payload); const handler = (_event: unknown, payload: SyncUiProgressPayload): void => listener(payload);
ipcRenderer.on(SYNC_UI_IPC_CHANNELS.progress, handler); ipcRenderer.on(IPC_CHANNELS.event.syncUiProgress, handler);
return () => ipcRenderer.removeListener(SYNC_UI_IPC_CHANNELS.progress, handler); return () => ipcRenderer.removeListener(IPC_CHANNELS.event.syncUiProgress, handler);
}, },
onStateChanged: (listener: () => void): (() => void) => { onStateChanged: (listener: () => void): (() => void) => {
const handler = (): void => listener(); const handler = (): void => listener();
ipcRenderer.on(SYNC_UI_IPC_CHANNELS.stateChanged, handler); ipcRenderer.on(IPC_CHANNELS.event.syncUiStateChanged, handler);
return () => ipcRenderer.removeListener(SYNC_UI_IPC_CHANNELS.stateChanged, 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 name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" 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> <title>SubMiner Sync</title>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
@@ -69,15 +69,15 @@
<code>ssh-copy-id user@hostname</code> (no password prompts). <code>ssh-copy-id user@hostname</code> (no password prompts).
</li> </li>
<li> <li>
The remote machine just needs SubMiner installed. It is found automatically in The remote machine just needs SubMiner installed. It is found automatically in its
its default install location (Windows), as <code>SubMiner</code> on the remote default install location (Windows), as <code>SubMiner</code> on the remote PATH, a
PATH, a macOS <code>/Applications</code> install, or the optional macOS <code>/Applications</code> install, or the optional
<code>subminer</code> command-line launcher. Windows remotes need the built-in <code>subminer</code> command-line launcher. Windows remotes need the built-in
OpenSSH Server enabled. OpenSSH Server enabled.
</li> </li>
<li> <li>
Use <em>Test connection</em> to verify both. It checks SSH and finds SubMiner on Use <em>Test connection</em> to verify both. It checks SSH and finds SubMiner on the
the remote in one go. remote in one go.
</li> </li>
</ol> </ol>
</details> </details>
+8 -2
View File
@@ -29,20 +29,24 @@ test('summarizeMergeCounts lists only non-zero counts', () => {
wordsAdded: 0, wordsAdded: 0,
kanjiAdded: 0, kanjiAdded: 0,
subtitleLinesAdded: 450, subtitleLinesAdded: 450,
telemetryRowsAdded: 0, telemetryRowsAdded: 3,
eventsAdded: 0, eventsAdded: 4,
excludedWordsAdded: 0, excludedWordsAdded: 0,
dailyRollupsCopied: 0, dailyRollupsCopied: 0,
monthlyRollupsCopied: 0, monthlyRollupsCopied: 0,
rollupGroupsRecomputed: 4, rollupGroupsRecomputed: 4,
}; };
const lines = summarizeMergeCounts(summary); 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, [ assert.deepEqual(lines, [
{ label: 'Sessions merged', value: 3 }, { label: 'Sessions merged', value: 3 },
{ label: 'Already present', value: 12 }, { label: 'Already present', value: 12 },
{ label: 'Series added', value: 1 }, { label: 'Series added', value: 1 },
{ label: 'Videos added', value: 2 }, { label: 'Videos added', value: 2 },
{ label: 'Subtitle lines', value: 450 }, { label: 'Subtitle lines', value: 450 },
{ label: 'Telemetry rows', value: 3 },
{ label: 'Events', value: 4 },
{ label: 'Rollups recomputed', value: 4 }, { label: 'Rollups recomputed', value: 4 },
]); ]);
@@ -53,6 +57,8 @@ test('summarizeMergeCounts lists only non-zero counts', () => {
animeAdded: 0, animeAdded: 0,
videosAdded: 0, videosAdded: 0,
subtitleLinesAdded: 0, subtitleLinesAdded: 0,
telemetryRowsAdded: 0,
eventsAdded: 0,
rollupGroupsRecomputed: 0, rollupGroupsRecomputed: 0,
}); });
assert.deepEqual(empty, [{ label: 'Sessions merged', value: 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: 'wordsAdded', label: 'Words added' },
{ key: 'kanjiAdded', label: 'Kanji added' }, { key: 'kanjiAdded', label: 'Kanji added' },
{ key: 'subtitleLinesAdded', label: 'Subtitle lines' }, { key: 'subtitleLinesAdded', label: 'Subtitle lines' },
{ key: 'telemetryRowsAdded', label: 'Telemetry rows' },
{ key: 'eventsAdded', label: 'Events' },
{ key: 'excludedWordsAdded', label: 'Excluded words' }, { key: 'excludedWordsAdded', label: 'Excluded words' },
{ key: 'dailyRollupsCopied', label: 'Daily rollups' }, { key: 'dailyRollupsCopied', label: 'Daily rollups' },
{ key: 'monthlyRollupsCopied', label: 'Monthly rollups' }, { key: 'monthlyRollupsCopied', label: 'Monthly rollups' },