mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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:
@@ -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
@@ -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 +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
@@ -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
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}"`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -3951,7 +3951,10 @@ const {
|
||||
annotationSubtitleWsService.stop();
|
||||
},
|
||||
stopTexthookerService: () => texthookerService.stop(),
|
||||
stopSyncAutoScheduler: () => syncAutoScheduler.stop(),
|
||||
stopSyncAutoScheduler: async () => {
|
||||
syncAutoScheduler.stop();
|
||||
await syncUiRuntime.shutdown();
|
||||
},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user