mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 01:01:34 -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).
|
`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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
'media-box',
|
||||||
|
null,
|
||||||
|
'posix',
|
||||||
|
(_host, remoteCommand) => {
|
||||||
probed.push(remoteCommand);
|
probed.push(remoteCommand);
|
||||||
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1);
|
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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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: () => {
|
||||||
|
|||||||
@@ -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: () =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
try {
|
||||||
deps.triggerHostSync(due.host, due.direction);
|
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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,7 +170,8 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
currentRun = run;
|
currentRun = run;
|
||||||
void run.handle.done.then((result: SyncLauncherRunResult) => {
|
run.completion = run.handle.done
|
||||||
|
.then((result: SyncLauncherRunResult) => {
|
||||||
if (currentRun?.id === runId) currentRun = null;
|
if (currentRun?.id === runId) currentRun = null;
|
||||||
// If the launcher died without emitting a result event (spawn failure,
|
// If the launcher died without emitting a result event (spawn failure,
|
||||||
// kill), synthesize one so the renderer can settle its progress view.
|
// kill), synthesize one so the renderer can settle its progress view.
|
||||||
@@ -194,6 +196,11 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.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,8 +266,26 @@ 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 = {
|
||||||
|
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.'));
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }]);
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user