Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)

This commit is contained in:
2026-07-13 18:56:51 -07:00
committed by GitHub
parent 66f8ca4f80
commit 49b926e08c
111 changed files with 6983 additions and 1130 deletions
+7
View File
@@ -14,6 +14,7 @@ export interface CliArgs {
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
settings: boolean;
syncWindow: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -123,6 +124,7 @@ export function parseArgs(argv: string[]): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -271,6 +273,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--sync-window') args.syncWindow = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -544,6 +547,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.syncWindow ||
args.setup ||
args.show ||
args.hide ||
@@ -622,6 +626,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.settings &&
!args.syncWindow &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -693,6 +698,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.syncWindow ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -750,6 +756,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.syncWindow &&
!args.show &&
!args.hide &&
!args.setup &&
+5
View File
@@ -79,6 +79,11 @@ ${B}Jellyfin${R}
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
${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 SubMiner --sync-cli --help for details)${R}
${B}Options${R}
--socket ${D}PATH${R} mpv IPC socket path
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R}
+81 -2
View File
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -140,6 +141,65 @@ 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;
// The re-quit must not fire in the microtask turn of the will-quit
// dispatch: Electron drops a quit issued while the prevented quit is
// still unwinding, leaving a windowless process alive.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(calls, []);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['quitApp']);
});
test('startAppLifecycle contains synchronous quit cleanup failures', () => {
let willQuit: ((event: { preventDefault(): void }) => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
onWillQuit: (handler) => {
willQuit = handler;
},
onWillQuitCleanup: () => {
throw new Error('cleanup exploded');
},
});
startAppLifecycle(makeArgs({ start: true }), deps);
assert.ok(willQuit);
assert.doesNotThrow(() =>
(willQuit as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} }),
);
assert.deepEqual(calls, []);
});
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
const { deps, calls, getLockCalls } = createDeps({
shouldStartApp: () => false,
@@ -252,7 +312,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 +334,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']);
});
@@ -353,3 +413,22 @@ test('startAppLifecycle quits macOS setup-only launch when all windows close', (
handler();
assert.deepEqual(calls, ['quitApp']);
});
test('startAppLifecycle quits macOS sync-window launch when its window closes', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
isDarwinPlatform: () => true,
shouldQuitOnWindowAllClosed: () => true,
onWindowAllClosed: (handler) => {
windowAllClosedHandler = handler;
},
});
startAppLifecycle(makeArgs({ syncWindow: true }), deps);
const handler = windowAllClosedHandler as (() => void) | null;
assert.ok(handler);
handler();
assert.deepEqual(calls, ['quitApp']);
});
+38 -6
View File
@@ -16,11 +16,11 @@ export interface AppLifecycleServiceDeps {
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
onWillQuit: (handler: (event: { preventDefault(): void }) => void) => void;
onActivate: (handler: () => void) => void;
isDarwinPlatform: () => boolean;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
@@ -44,7 +44,7 @@ export interface AppLifecycleDepsRuntimeOptions {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
@@ -183,16 +183,48 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
deps.onWindowAllClosed(() => {
if (
deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
(!deps.isDarwinPlatform() ||
initialArgs.settings ||
initialArgs.setup ||
initialArgs.syncWindow)
) {
deps.quitApp();
}
});
deps.onWillQuit(() => {
let quitCleanupPending = false;
let quitCleanupComplete = false;
deps.onWillQuit((event) => {
if (quitCleanupComplete) return;
stopControlServer?.();
stopControlServer = null;
deps.onWillQuitCleanup();
if (quitCleanupPending) {
event.preventDefault();
return;
}
let cleanup: void | Promise<void>;
try {
cleanup = deps.onWillQuitCleanup();
} catch (error) {
logger.error('App quit cleanup failed:', error);
return;
}
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;
// A cleanup promise that settles in a microtask would re-quit while
// Electron is still unwinding the prevented quit, and that quit call
// is silently dropped, leaving a windowless process alive. Re-issue
// the quit from a fresh macrotask instead.
setImmediate(() => deps.quitApp());
});
});
deps.onActivate(() => {
+5
View File
@@ -21,6 +21,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -138,6 +139,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openConfigSettingsWindow: () => {
calls.push('openConfigSettingsWindow');
},
openSyncUiWindow: () => {
calls.push('openSyncUiWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -660,6 +664,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
openFirstRunSetup: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+5
View File
@@ -44,6 +44,7 @@ export interface CliCommandServiceDeps {
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -170,6 +171,7 @@ interface UiCliRuntime {
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -274,6 +276,7 @@ export function createCliCommandDepsRuntime(
}, delayMs);
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
@@ -417,6 +420,8 @@ export function handleCliCommand(
deps.openYomitanSettingsDelayed(1000);
} else if (args.settings) {
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
deps.openSyncUiWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { extractSyncCliTokens, parseSyncCliTokens } from './cli-args';
test('extractSyncCliTokens returns tokens after --sync-cli', () => {
assert.equal(extractSyncCliTokens(['/bin/electron', '/app']), null);
assert.deepEqual(extractSyncCliTokens(['/bin/electron', '/app', '--sync-cli', 'sync', 'host']), [
'sync',
'host',
]);
// A repeated flag (e.g. resolved remote command + forwarded argv) is ignored.
assert.deepEqual(extractSyncCliTokens(['/app', '--sync-cli', '--sync-cli', 'sync', 'h']), [
'sync',
'h',
]);
});
test('parseSyncCliTokens handles help, version, and run modes', () => {
assert.deepEqual(parseSyncCliTokens(['--help']), { kind: 'help' });
assert.deepEqual(parseSyncCliTokens(['--version']), { kind: 'version' });
const run = parseSyncCliTokens(['sync', 'media-box', '--pull', '--force', '--json']);
assert.equal(run.kind, 'run');
if (run.kind === 'run') {
assert.equal(run.args.syncHost, 'media-box');
assert.equal(run.args.syncDirection, 'pull');
assert.equal(run.args.syncForce, true);
assert.equal(run.args.syncJson, true);
}
const snapshot = parseSyncCliTokens(['sync', '--snapshot', '/tmp/x.sqlite', '--db', '/tmp/db']);
assert.equal(snapshot.kind, 'run');
if (snapshot.kind === 'run') {
assert.equal(snapshot.args.syncSnapshotPath, '/tmp/x.sqlite');
assert.equal(snapshot.args.syncDbPath, '/tmp/db');
}
});
test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
const makeTemp = parseSyncCliTokens(['sync', '--make-temp']);
assert.equal(makeTemp.kind, 'run');
if (makeTemp.kind === 'run') assert.equal(makeTemp.args.syncMakeTemp, true);
const removeTemp = parseSyncCliTokens(['sync', '--remove-temp', '/tmp/subminer-sync-x']);
assert.equal(removeTemp.kind, 'run');
if (removeTemp.kind === 'run') {
assert.equal(removeTemp.args.syncRemoveTempPath, '/tmp/subminer-sync-x');
}
assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error');
});
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
assert.equal(parseSyncCliTokens([]).kind, 'error');
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--snapshot', '/tmp/x', '--push']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--check']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--check', '--snapshot', '/tmp/x', 'h']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--snapshot', '/tmp/x']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--bogus']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error');
});
test('parseSyncCliTokens rejects an option-like token where a value is required', () => {
// Without this guard `--snapshot --force` writes a snapshot to a file literally
// named "--force" and silently drops the flag.
assert.deepEqual(parseSyncCliTokens(['sync', '--snapshot', '--force']), {
kind: 'error',
message: 'Missing value for --snapshot.',
});
assert.deepEqual(parseSyncCliTokens(['sync', '--remove-temp', '--force']), {
kind: 'error',
message: 'Missing value for --remove-temp.',
});
// The `--flag=<value>` form still accepts values that begin with "-".
const parsed = parseSyncCliTokens(['sync', '--snapshot=-weird-name.sqlite']);
assert.equal(parsed.kind, 'run');
assert.equal(parsed.kind === 'run' && parsed.args.syncSnapshotPath, '-weird-name.sqlite');
});
+165
View File
@@ -0,0 +1,165 @@
import type { SyncFlowArgs } from './sync-flow';
export const SYNC_CLI_FLAG = '--sync-cli';
export type ParsedSyncCli =
| { kind: 'help' }
| { kind: 'version' }
| { kind: 'run'; args: SyncFlowArgs }
| { kind: 'error'; message: string };
export function extractSyncCliTokens(argv: readonly string[]): string[] | null {
const index = argv.indexOf(SYNC_CLI_FLAG);
if (index === -1) return null;
return argv.slice(index + 1).filter((token) => token !== SYNC_CLI_FLAG);
}
/**
* Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the
* app's --sync-cli mode. This is the single owner of sync CLI validation:
* the launcher forwards `subminer sync` tokens verbatim, so both entry
* points accept the same command lines and fail the same way.
*/
export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' };
if (tokens.includes('--version') || tokens.includes('-V')) return { kind: 'version' };
const rest = [...tokens];
if (rest[0] !== 'sync') {
return {
kind: 'error',
message: `Expected a "sync" command after ${SYNC_CLI_FLAG} (e.g. ${SYNC_CLI_FLAG} sync --snapshot <file>).`,
};
}
rest.shift();
let host = '';
let snapshot = '';
let merge = '';
let push = false;
let pull = false;
let check = false;
let force = false;
let json = false;
let makeTemp = false;
let removeTemp = '';
let remoteCmd = '';
let dbPath = '';
let logLevel = 'warn';
const valueFlags = new Map<string, (value: string) => void>([
['--snapshot', (value) => (snapshot = value.trim())],
['--merge', (value) => (merge = value.trim())],
['--remove-temp', (value) => (removeTemp = value.trim())],
['--remote-cmd', (value) => (remoteCmd = value.trim())],
['--db', (value) => (dbPath = value.trim())],
['--log-level', (value) => (logLevel = value.trim() || 'warn')],
]);
for (let i = 0; i < rest.length; i += 1) {
const token = rest[i]!;
const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token);
if (assignValue) {
if (token.includes('=')) {
assignValue(token.slice(token.indexOf('=') + 1));
continue;
}
const value = rest[i + 1];
// An option-like token is never a value: `--snapshot --force` must fail
// loudly instead of writing a snapshot to a file named "--force". Paths
// that really do start with "-" can still be passed as `--snapshot=-x`.
if (value === undefined || value.startsWith('-')) {
return { kind: 'error', message: `Missing value for ${token}.` };
}
assignValue(value);
i += 1;
continue;
}
if (token === '--push') push = true;
else if (token === '--pull') pull = true;
else if (token === '--check') check = true;
else if (token === '--force' || token === '-f') force = true;
else if (token === '--json') json = true;
else if (token === '--make-temp') makeTemp = true;
else if (token.startsWith('-')) {
return { kind: 'error', message: `Unknown sync option: ${token}` };
} else if (host) {
return { kind: 'error', message: `Unexpected extra argument: ${token}` };
} else {
host = token.trim();
}
}
if (push && pull) return { kind: 'error', message: 'Sync --push and --pull cannot be combined.' };
if ((push || pull) && !host) {
return { kind: 'error', message: 'Sync --push and --pull require a host.' };
}
if (check && !host) return { kind: 'error', message: 'Sync --check requires a host.' };
if (check && (push || pull || snapshot || merge)) {
return {
kind: 'error',
message: 'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
};
}
const modes = [
Boolean(host),
Boolean(snapshot),
Boolean(merge),
makeTemp,
Boolean(removeTemp),
].filter(Boolean).length;
if (modes === 0) {
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
}
if (modes > 1) {
return {
kind: 'error',
message: 'Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.',
};
}
return {
kind: 'run',
args: {
syncHost: host,
syncSnapshotPath: snapshot,
syncMergePath: merge,
syncDirection: push ? 'push' : pull ? 'pull' : 'both',
syncRemoteCmd: remoteCmd,
syncDbPath: dbPath,
syncForce: force,
syncJson: json,
syncCheck: check,
syncMakeTemp: makeTemp,
syncRemoveTempPath: removeTemp,
logLevel,
},
};
}
export function syncCliUsage(): string {
return [
'SubMiner sync CLI',
'',
`Usage: SubMiner ${SYNC_CLI_FLAG} sync [host] [options]`,
'',
'Modes (exactly one):',
' <host> Sync stats with an SSH destination (user@host or ssh alias)',
' --snapshot <file> Write a consistent snapshot of the local stats database',
' --merge <file> Merge a snapshot database file into the local stats database',
' --make-temp Create a sync temp directory and print its path (used over SSH)',
' --remove-temp <dir> Remove a sync temp directory created by --make-temp',
'',
'Options:',
' --push Only merge local stats into the SSH host',
' --pull Only merge stats from the SSH host into the local database',
' --check Test the SSH connection and remote SubMiner availability',
' --db <file> Override the local stats database path',
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
' -f, --force Skip the running-app safety check',
' --json Emit machine-readable NDJSON progress output',
' --log-level <level> Log level',
' --help Show this help',
' --version Show the SubMiner version',
].join('\n');
}
+42
View File
@@ -0,0 +1,42 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveConfigFilePath } from '../../../config/path-resolution';
import { parseConfigContent } from '../../../config/parse';
import { getDefaultConfigDir } from '../../../shared/setup-state';
/**
* Default immersion stats database location, shared by the launcher's history
* command and the app's --sync-cli mode: honor a configured
* immersionTracking.dbPath (raw main-config read, tolerant of comments), else
* <configDir>/immersion.sqlite. Electron- and libsql-free on purpose so the
* bun launcher can import it.
*/
export function resolveImmersionDbPath(): string {
const configPath = resolveConfigFilePath({
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
let configured = '';
try {
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf8'));
const tracking =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>).immersionTracking
: null;
if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) {
const dbPath = (tracking as Record<string, unknown>).dbPath;
if (typeof dbPath === 'string') configured = dbPath.trim();
}
} catch {
// no config or unreadable config → default location
}
if (configured) {
return configured.startsWith('~')
? path.join(os.homedir(), configured.slice(1))
: configured;
}
return path.join(getDefaultConfigDir(), 'immersion.sqlite');
}
@@ -0,0 +1,68 @@
import { Database } from '../immersion-tracker/sqlite';
import type { SyncDbOpenOptions } from './wal-retry';
export interface SyncDbRunResult {
changes: number;
lastInsertRowid: number | bigint;
}
export interface SyncDbStatement {
run(...params: unknown[]): SyncDbRunResult;
get(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
}
export interface SyncDb {
/** Prepare (or reuse a cached prepared statement for) the given SQL. */
query(sql: string): SyncDbStatement;
/** Execute SQL that returns no rows (pragmas, transaction control). */
exec(sql: string): void;
close(): void;
}
export type SqlRow = Record<string, unknown>;
export function selectAll(db: SyncDb, sql: string, params: unknown[] = []): SqlRow[] {
return db.query(sql).all(...params) as SqlRow[];
}
export function selectOne(db: SyncDb, sql: string, params: unknown[] = []): SqlRow | undefined {
return (db.query(sql).get(...params) ?? undefined) as SqlRow | undefined;
}
interface LibsqlDatabase {
prepare(sql: string): SyncDbStatement;
exec(sql: string): unknown;
close(): unknown;
}
/**
* libsql (better-sqlite3 API) SQLite connection for the stats-sync engine.
* prepare() is not cached by libsql, so query() keeps a per-connection
* statement cache because the merge prepares a handful of statements and runs them
* once per copied row, so re-preparing would dominate merge time.
*/
export function openLibsqlSyncDb(dbPath: string, options: SyncDbOpenOptions): SyncDb {
const db = new Database(dbPath, {
readonly: options.readonly === true,
fileMustExist: options.create !== true,
}) as unknown as LibsqlDatabase;
const statements = new Map<string, SyncDbStatement>();
return {
query(sql: string): SyncDbStatement {
let statement = statements.get(sql);
if (!statement) {
statement = db.prepare(sql);
statements.set(sql, statement);
}
return statement;
},
exec(sql: string): void {
db.exec(sql);
},
close(): void {
statements.clear();
db.close();
},
};
}
@@ -0,0 +1,459 @@
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, tableExists, type SyncMergeSummary } from './shared';
const ANIME_COPY_COLUMNS = [
'normalized_title_key',
'canonical_title',
'anilist_id',
'title_romaji',
'title_english',
'title_native',
'episodes_total',
'description',
'metadata_json',
'CREATED_DATE',
'LAST_UPDATE_DATE',
] as const;
const VIDEO_COPY_COLUMNS = [
'video_key',
'canonical_title',
'source_type',
'source_path',
'source_url',
'parsed_basename',
'parsed_title',
'parsed_season',
'parsed_episode',
'parser_source',
'parser_confidence',
'parse_metadata_json',
'watched',
'duration_ms',
'file_size_bytes',
'codec_id',
'container_id',
'width_px',
'height_px',
'fps_x100',
'bitrate_kbps',
'audio_codec_id',
'hash_sha256',
'screenshot_path',
'metadata_json',
'CREATED_DATE',
'LAST_UPDATE_DATE',
] as const;
const MEDIA_ART_COPY_COLUMNS = [
'anilist_id',
'cover_url',
'cover_blob',
'cover_blob_hash',
'title_romaji',
'title_english',
'episodes_total',
'fetched_at_ms',
'CREATED_DATE',
'LAST_UPDATE_DATE',
] as const;
const YOUTUBE_COPY_COLUMNS = [
'youtube_video_id',
'video_url',
'video_title',
'video_thumbnail_url',
'channel_id',
'channel_name',
'channel_url',
'channel_thumbnail_url',
'uploader_id',
'uploader_url',
'description',
'metadata_json',
'fetched_at_ms',
'CREATED_DATE',
'LAST_UPDATE_DATE',
] as const;
const WORD_COPY_COLUMNS = [
'headword',
'word',
'reading',
'part_of_speech',
'pos1',
'pos2',
'pos3',
'first_seen',
'last_seen',
'frequency',
'frequency_rank',
] as const;
export function mergeAnime(
local: SyncDb,
remote: SyncDb,
summary: SyncMergeSummary,
): Map<number, number> {
const map = new Map<number, number>();
const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?');
const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?');
const fillMissing = local.query(
`UPDATE imm_anime
SET
title_romaji = COALESCE(title_romaji, ?),
title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?)
WHERE anime_id = ?`,
);
for (const row of selectAll(
remote,
`SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`,
)) {
const remoteId = Number(row.anime_id);
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined;
if (existing) {
const localId = Number(existing.anime_id);
map.set(remoteId, localId);
fillMissing.run(
row.title_romaji,
row.title_english,
row.title_native,
row.episodes_total,
row.description,
localId,
);
continue;
}
// No local row matched by anilist_id (checked first in `existing` above)
// or title key, so the remote anilist_id — if any — is free to insert as-is.
const values = ANIME_COPY_COLUMNS.map((column) => row[column]);
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
summary.animeAdded += 1;
}
return map;
}
export interface VideoMergeResult {
videoIdMap: Map<number, number>;
addedVideoIds: Set<number>;
}
export function mergeVideos(
local: SyncDb,
remote: SyncDb,
animeIdMap: Map<number, number>,
summary: SyncMergeSummary,
): VideoMergeResult {
const videoIdMap = new Map<number, number>();
const addedVideoIds = new Set<number>();
const byKey = local.query('SELECT video_id, watched FROM imm_videos WHERE video_key = ?');
const setWatched = local.query('UPDATE imm_videos SET watched = 1 WHERE video_id = ?');
for (const row of selectAll(
remote,
`SELECT video_id, anime_id, ${VIDEO_COPY_COLUMNS.join(', ')} FROM imm_videos`,
)) {
const remoteId = Number(row.video_id);
const mappedAnimeId =
row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
const existing = byKey.get(row.video_key) as SqlRow | undefined;
if (existing) {
const localId = Number(existing.video_id);
videoIdMap.set(remoteId, localId);
if (Number(row.watched) > 0 && Number(existing.watched) <= 0) {
setWatched.run(localId);
}
continue;
}
const columns = ['anime_id', ...VIDEO_COPY_COLUMNS];
const values = [mappedAnimeId, ...VIDEO_COPY_COLUMNS.map((column) => row[column])];
const localId = insertRow(local, 'imm_videos', columns, values);
videoIdMap.set(remoteId, localId);
addedVideoIds.add(remoteId);
summary.videosAdded += 1;
}
return { videoIdMap, addedVideoIds };
}
export function mergeMediaMetadata(
local: SyncDb,
remote: SyncDb,
videoIdMap: Map<number, number>,
addedVideoIds: Set<number>,
): void {
if (videoIdMap.size === 0) return;
const metadataVideoIds = new Set<number>([...addedVideoIds, ...videoIdMap.keys()]);
const hasBlobStore =
tableExists(local, 'imm_cover_art_blobs') && tableExists(remote, 'imm_cover_art_blobs');
const copyBlob = hasBlobStore
? local.query(
`INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?)
ON CONFLICT(blob_hash) DO NOTHING`,
)
: null;
const readBlob = hasBlobStore
? remote.query('SELECT * FROM imm_cover_art_blobs WHERE blob_hash = ?')
: null;
if (tableExists(remote, 'imm_media_art') && tableExists(local, 'imm_media_art')) {
const localArtExists = local.query('SELECT 1 FROM imm_media_art WHERE video_id = ? LIMIT 1');
for (const remoteVideoId of metadataVideoIds) {
const localVideoId = videoIdMap.get(remoteVideoId)!;
if (localArtExists.get(localVideoId)) continue;
const row = selectOne(
remote,
`SELECT ${MEDIA_ART_COPY_COLUMNS.join(', ')} FROM imm_media_art WHERE video_id = ?`,
[remoteVideoId],
);
if (!row) continue;
if (row.cover_blob_hash && copyBlob && readBlob) {
const blob = readBlob.get(row.cover_blob_hash) as SqlRow | undefined;
if (blob) {
copyBlob.run(blob.blob_hash, blob.cover_blob, blob.CREATED_DATE, blob.LAST_UPDATE_DATE);
}
}
insertRow(
local,
'imm_media_art',
['video_id', ...MEDIA_ART_COPY_COLUMNS],
[localVideoId, ...MEDIA_ART_COPY_COLUMNS.map((column) => row[column])],
);
}
}
if (tableExists(remote, 'imm_youtube_videos') && tableExists(local, 'imm_youtube_videos')) {
const localYoutubeExists = local.query(
'SELECT 1 FROM imm_youtube_videos WHERE video_id = ? LIMIT 1',
);
for (const remoteVideoId of metadataVideoIds) {
const localVideoId = videoIdMap.get(remoteVideoId)!;
if (localYoutubeExists.get(localVideoId)) continue;
const row = selectOne(
remote,
`SELECT ${YOUTUBE_COPY_COLUMNS.join(', ')} FROM imm_youtube_videos WHERE video_id = ?`,
[remoteVideoId],
);
if (!row) continue;
insertRow(
local,
'imm_youtube_videos',
['video_id', ...YOUTUBE_COPY_COLUMNS],
[localVideoId, ...YOUTUBE_COPY_COLUMNS.map((column) => row[column])],
);
}
}
}
export function mergeExcludedWords(
local: SyncDb,
remote: SyncDb,
summary: SyncMergeSummary,
): void {
if (
!tableExists(remote, 'imm_stats_excluded_words') ||
!tableExists(local, 'imm_stats_excluded_words')
) {
return;
}
const insert = local.query(
`INSERT INTO imm_stats_excluded_words (headword, word, reading, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(headword, word, reading) DO NOTHING`,
);
for (const row of selectAll(
remote,
'SELECT headword, word, reading, CREATED_DATE, LAST_UPDATE_DATE FROM imm_stats_excluded_words',
)) {
const result = insert.run(
row.headword,
row.word,
row.reading,
row.CREATED_DATE,
row.LAST_UPDATE_DATE,
);
summary.excludedWordsAdded += result.changes;
}
}
/**
* Lazily maps remote imm_words / imm_kanji ids onto local rows by natural key
* ((headword, word, reading) / kanji). New rows are copied with the remote's
* accumulated frequency minus any counts owed to skipped ACTIVE sessions
* (those merge later and re-add their counts); rows that already exist locally
* get their frequency incremented later with only the occurrence counts this
* merge adds (the remote total would double-count lines merged in earlier
* syncs).
*/
export class LexiconResolver {
private readonly wordMap = new Map<number, { localId: number; isNew: boolean }>();
private readonly kanjiMap = new Map<number, { localId: number; isNew: boolean }>();
readonly wordFrequencyDeltas = new Map<number, number>();
readonly kanjiFrequencyDeltas = new Map<number, number>();
constructor(
private readonly local: SyncDb,
private readonly remote: SyncDb,
private readonly summary: SyncMergeSummary,
) {}
/**
* Occurrence counts the remote's live tracker already baked into `frequency`
* but that belong to skipped ACTIVE sessions. Those lines are not copied this
* merge; they are re-added via addWordOccurrences/addKanjiOccurrences when
* the session finalizes and syncs, so a newly adopted row must not carry them
* or that slice would be counted twice.
*/
private pendingActiveSessionOccurrences(
occurrenceTable: 'imm_word_line_occurrences' | 'imm_kanji_line_occurrences',
idColumn: 'word_id' | 'kanji_id',
remoteId: number,
): number {
const row = selectOne(
this.remote,
`SELECT COALESCE(SUM(o.occurrence_count), 0) AS pending
FROM ${occurrenceTable} o
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
JOIN imm_sessions s ON s.session_id = l.session_id
WHERE o.${idColumn} = ? AND s.ended_at_ms IS NULL`,
[remoteId],
);
return Number(row?.pending ?? 0);
}
private adoptedFrequency(frequency: unknown, pending: number): unknown {
if (pending <= 0 || typeof frequency !== 'number') return frequency;
return Math.max(0, frequency - pending);
}
resolveWord(remoteWordId: number): number {
const cached = this.wordMap.get(remoteWordId);
if (cached) return cached.localId;
const row = selectOne(
this.remote,
`SELECT ${WORD_COPY_COLUMNS.join(', ')} FROM imm_words WHERE id = ?`,
[remoteWordId],
);
if (!row) throw new Error(`Snapshot references missing imm_words row ${remoteWordId}`);
const existing = selectOne(
this.local,
'SELECT id FROM imm_words WHERE headword IS ? AND word IS ? AND reading IS ?',
[row.headword, row.word, row.reading],
);
let entry: { localId: number; isNew: boolean };
if (existing) {
entry = { localId: Number(existing.id), isNew: false };
this.local
.query(
`UPDATE imm_words
SET first_seen = MIN(COALESCE(first_seen, ?), COALESCE(?, first_seen)),
last_seen = MAX(COALESCE(last_seen, ?), COALESCE(?, last_seen))
WHERE id = ?`,
)
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
} else {
const pending = this.pendingActiveSessionOccurrences(
'imm_word_line_occurrences',
'word_id',
remoteWordId,
);
const localId = insertRow(
this.local,
'imm_words',
WORD_COPY_COLUMNS,
WORD_COPY_COLUMNS.map((column) =>
column === 'frequency' ? this.adoptedFrequency(row[column], pending) : row[column],
),
);
entry = { localId, isNew: true };
this.summary.wordsAdded += 1;
}
this.wordMap.set(remoteWordId, entry);
return entry.localId;
}
resolveKanji(remoteKanjiId: number): number {
const cached = this.kanjiMap.get(remoteKanjiId);
if (cached) return cached.localId;
const row = selectOne(
this.remote,
'SELECT kanji, first_seen, last_seen, frequency FROM imm_kanji WHERE id = ?',
[remoteKanjiId],
);
if (!row) throw new Error(`Snapshot references missing imm_kanji row ${remoteKanjiId}`);
const existing = selectOne(this.local, 'SELECT id FROM imm_kanji WHERE kanji IS ?', [
row.kanji,
]);
let entry: { localId: number; isNew: boolean };
if (existing) {
entry = { localId: Number(existing.id), isNew: false };
this.local
.query(
`UPDATE imm_kanji
SET first_seen = MIN(COALESCE(first_seen, ?), COALESCE(?, first_seen)),
last_seen = MAX(COALESCE(last_seen, ?), COALESCE(?, last_seen))
WHERE id = ?`,
)
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
} else {
const pending = this.pendingActiveSessionOccurrences(
'imm_kanji_line_occurrences',
'kanji_id',
remoteKanjiId,
);
const localId = insertRow(
this.local,
'imm_kanji',
['kanji', 'first_seen', 'last_seen', 'frequency'],
[row.kanji, row.first_seen, row.last_seen, this.adoptedFrequency(row.frequency, pending)],
);
entry = { localId, isNew: true };
this.summary.kanjiAdded += 1;
}
this.kanjiMap.set(remoteKanjiId, entry);
return entry.localId;
}
addWordOccurrences(remoteWordId: number, count: number): void {
const entry = this.wordMap.get(remoteWordId);
if (!entry || entry.isNew) return;
this.wordFrequencyDeltas.set(
entry.localId,
(this.wordFrequencyDeltas.get(entry.localId) ?? 0) + count,
);
}
addKanjiOccurrences(remoteKanjiId: number, count: number): void {
const entry = this.kanjiMap.get(remoteKanjiId);
if (!entry || entry.isNew) return;
this.kanjiFrequencyDeltas.set(
entry.localId,
(this.kanjiFrequencyDeltas.get(entry.localId) ?? 0) + count,
);
}
applyFrequencyDeltas(): void {
const updateWord = this.local.query(
'UPDATE imm_words SET frequency = COALESCE(frequency, 0) + ? WHERE id = ?',
);
for (const [localId, delta] of this.wordFrequencyDeltas) {
updateWord.run(delta, localId);
}
const updateKanji = this.local.query(
'UPDATE imm_kanji SET frequency = COALESCE(frequency, 0) + ? WHERE id = ?',
);
for (const [localId, delta] of this.kanjiFrequencyDeltas) {
updateKanji.run(delta, localId);
}
}
}
@@ -0,0 +1,264 @@
import { selectAll, type SqlRow, type SyncDb } from './libsql-driver';
import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './shared';
const LOCAL_DAY_EXPR = `CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)`;
const LOCAL_MONTH_EXPR = `CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER)`;
// Ported from upsertDailyRollupsForGroups / upsertMonthlyRollupsForGroups in
// src/core/services/immersion-tracker/maintenance.ts — must stay in sync.
const DAILY_ROLLUP_UPSERT = `
WITH matching_sessions AS (
SELECT * FROM imm_sessions
WHERE ${LOCAL_DAY_EXPR} = ? AND video_id = ?
),
session_metrics AS (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards,
MAX(t.lookup_count) AS max_lookups,
MAX(t.lookup_hits) AS max_hits
FROM imm_session_telemetry t
JOIN matching_sessions s ON s.session_id = t.session_id
GROUP BY t.session_id
)
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, cards_per_hour, tokens_per_min, lookup_hit_rate,
CREATED_DATE, LAST_UPDATE_DATE
)
SELECT
${LOCAL_DAY_EXPR.replace('started_at_ms', 's.started_at_ms')} AS rollup_day,
s.video_id AS video_id,
COUNT(DISTINCT s.session_id) AS total_sessions,
COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0 AS total_active_min,
COALESCE(SUM(COALESCE(sm.max_lines, s.lines_seen)), 0) AS total_lines_seen,
COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0) AS total_tokens_seen,
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
CASE
WHEN COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) > 0
THEN (COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) * 60.0)
/ (COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0)
ELSE NULL
END AS cards_per_hour,
CASE
WHEN COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) > 0
THEN COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0)
/ (COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0)
ELSE NULL
END AS tokens_per_min,
CASE
WHEN COALESCE(SUM(COALESCE(sm.max_lookups, s.lookup_count)), 0) > 0
THEN CAST(COALESCE(SUM(COALESCE(sm.max_hits, s.lookup_hits)), 0) AS REAL)
/ CAST(COALESCE(SUM(COALESCE(sm.max_lookups, s.lookup_count)), 0) AS REAL)
ELSE NULL
END AS lookup_hit_rate,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM matching_sessions s
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
GROUP BY rollup_day, s.video_id
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions,
total_active_min = excluded.total_active_min,
total_lines_seen = excluded.total_lines_seen,
total_tokens_seen = excluded.total_tokens_seen,
total_cards = excluded.total_cards,
cards_per_hour = excluded.cards_per_hour,
tokens_per_min = excluded.tokens_per_min,
lookup_hit_rate = excluded.lookup_hit_rate,
CREATED_DATE = COALESCE(imm_daily_rollups.CREATED_DATE, excluded.CREATED_DATE),
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
`;
const MONTHLY_ROLLUP_UPSERT = `
WITH matching_sessions AS (
SELECT * FROM imm_sessions
WHERE ${LOCAL_MONTH_EXPR} = ? AND video_id = ?
),
session_metrics AS (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards
FROM imm_session_telemetry t
JOIN matching_sessions s ON s.session_id = t.session_id
GROUP BY t.session_id
)
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
)
SELECT
${LOCAL_MONTH_EXPR.replace('started_at_ms', 's.started_at_ms')} AS rollup_month,
s.video_id AS video_id,
COUNT(DISTINCT s.session_id) AS total_sessions,
COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0 AS total_active_min,
COALESCE(SUM(COALESCE(sm.max_lines, s.lines_seen)), 0) AS total_lines_seen,
COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0) AS total_tokens_seen,
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM matching_sessions s
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
GROUP BY rollup_month, s.video_id
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions,
total_active_min = excluded.total_active_min,
total_lines_seen = excluded.total_lines_seen,
total_tokens_seen = excluded.total_tokens_seen,
total_cards = excluded.total_cards,
CREATED_DATE = COALESCE(imm_monthly_rollups.CREATED_DATE, excluded.CREATED_DATE),
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
`;
/**
* Recompute daily/monthly rollup groups touched by the newly merged sessions
* from the (now merged) local session + telemetry data. The maintenance
* watermark is left alone: telemetry newer than it gets recomputed again by
* the app later, which is idempotent.
*/
export function refreshRollupsForNewSessions(
local: SyncDb,
newSessionIds: number[],
summary: SyncMergeSummary,
): void {
if (newSessionIds.length === 0) return;
const groups = new Map<string, { day: number; month: number; videoId: number }>();
for (let offset = 0; offset < newSessionIds.length; offset += 500) {
const chunk = newSessionIds.slice(offset, offset + 500);
const rows = selectAll(
local,
`SELECT DISTINCT ${LOCAL_DAY_EXPR} AS rollup_day, ${LOCAL_MONTH_EXPR} AS rollup_month, video_id
FROM imm_sessions WHERE session_id IN (${chunk.map(() => '?').join(',')})`,
chunk,
);
for (const row of rows) {
const day = Number(row.rollup_day);
const month = Number(row.rollup_month);
const videoId = Number(row.video_id);
groups.set(`${day}-${videoId}`, { day, month, videoId });
}
}
const stampMs = nowDbTimestamp();
const deleteDaily = local.query('DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?');
const deleteMonthly = local.query(
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
);
const upsertDaily = local.query(DAILY_ROLLUP_UPSERT);
const upsertMonthly = local.query(MONTHLY_ROLLUP_UPSERT);
const monthlyGroups = new Set<string>();
for (const { day, month, videoId } of groups.values()) {
deleteDaily.run(day, videoId);
upsertDaily.run(day, videoId, stampMs, stampMs);
summary.rollupGroupsRecomputed += 1;
const monthKey = `${month}-${videoId}`;
if (!monthlyGroups.has(monthKey)) {
monthlyGroups.add(monthKey);
deleteMonthly.run(month, videoId);
upsertMonthly.run(month, videoId, stampMs, stampMs);
}
}
}
/**
* Sessions are pruned after a retention window, but rollups are kept much
* longer — the remote's older rollup history can't be reconstructed from
* merged sessions. Copy remote rollup rows for groups where the local DB has
* neither a rollup row nor any sessions (i.e. history only the remote knows).
* Groups both machines have data for are never summed, to avoid
* double-counting sessions that earlier syncs already shared.
*/
export function copyRemoteOnlyRollups(
local: SyncDb,
remote: SyncDb,
videoIdMap: Map<number, number>,
summary: SyncMergeSummary,
): void {
if (!tableExists(remote, 'imm_daily_rollups') || !tableExists(local, 'imm_daily_rollups')) return;
const localDailyExists = local.query(
'SELECT 1 FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ? LIMIT 1',
);
const localDaySessions = local.query(
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_DAY_EXPR} = ? LIMIT 1`,
);
const localMonthSessions = local.query(
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_MONTH_EXPR} = ? LIMIT 1`,
);
// rollup_day is a *local* epoch day, so anchor it at local noon (+43200)
// before reading its month back: plain UTC midnight lands in the previous
// civil month for the 1st of a month at any negative UTC offset.
const localMonthSessionsForDay = local.query(
`SELECT 1 FROM imm_sessions
WHERE video_id = ?
AND ${LOCAL_MONTH_EXPR} = CAST(strftime('%Y%m', CAST(? AS INTEGER) * 86400 + 43200, 'unixepoch', 'localtime') AS INTEGER)
LIMIT 1`,
);
const insertDaily = local.query(
`INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, cards_per_hour, tokens_per_min, lookup_hit_rate,
CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const row of selectAll(remote, 'SELECT * FROM imm_daily_rollups')) {
if (row.video_id === null) continue;
const localVideoId = videoIdMap.get(Number(row.video_id));
if (localVideoId === undefined) continue;
if (localDailyExists.get(row.rollup_day, localVideoId)) continue;
if (localDaySessions.get(localVideoId, row.rollup_day)) continue;
if (localMonthSessionsForDay.get(localVideoId, row.rollup_day)) continue;
insertDaily.run(
row.rollup_day,
localVideoId,
row.total_sessions,
row.total_active_min,
row.total_lines_seen,
row.total_tokens_seen,
row.total_cards,
row.cards_per_hour,
row.tokens_per_min,
row.lookup_hit_rate,
row.CREATED_DATE,
row.LAST_UPDATE_DATE,
);
summary.dailyRollupsCopied += 1;
}
const localMonthlyExists = local.query(
'SELECT 1 FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ? LIMIT 1',
);
const insertMonthly = local.query(
`INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const row of selectAll(remote, 'SELECT * FROM imm_monthly_rollups')) {
if (row.video_id === null) continue;
const localVideoId = videoIdMap.get(Number(row.video_id));
if (localVideoId === undefined) continue;
if (localMonthlyExists.get(row.rollup_month, localVideoId)) continue;
if (localMonthSessions.get(localVideoId, row.rollup_month)) continue;
insertMonthly.run(
row.rollup_month,
localVideoId,
row.total_sessions,
row.total_active_min,
row.total_lines_seen,
row.total_tokens_seen,
row.total_cards,
row.CREATED_DATE,
row.LAST_UPDATE_DATE,
);
summary.monthlyRollupsCopied += 1;
}
}
@@ -0,0 +1,470 @@
import type { LexiconResolver } from './merge-catalog';
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './shared';
const SESSION_COPY_COLUMNS = [
'session_uuid',
'started_at_ms',
'ended_at_ms',
'status',
'locale_id',
'target_lang_id',
'difficulty_tier',
'subtitle_mode',
'ended_media_ms',
'total_watched_ms',
'active_watched_ms',
'lines_seen',
'tokens_seen',
'cards_mined',
'lookup_count',
'lookup_hits',
'yomitan_lookup_count',
'pause_count',
'pause_ms',
'seek_forward_count',
'seek_backward_count',
'media_buffer_events',
'CREATED_DATE',
'LAST_UPDATE_DATE',
] as const;
const TELEMETRY_COPY_COLUMNS = [
'sample_ms',
'total_watched_ms',
'active_watched_ms',
'lines_seen',
'tokens_seen',
'cards_mined',
'lookup_count',
'lookup_hits',
'yomitan_lookup_count',
'pause_count',
'pause_ms',
'seek_forward_count',
'seek_backward_count',
'media_buffer_events',
'CREATED_DATE',
'LAST_UPDATE_DATE',
] as const;
const EVENT_COPY_COLUMNS = [
'ts_ms',
'event_type',
'line_index',
'segment_start_ms',
'segment_end_ms',
'tokens_delta',
'cards_delta',
'payload_json',
'CREATED_DATE',
'LAST_UPDATE_DATE',
] as const;
const LINE_COPY_COLUMNS = [
'line_index',
'segment_start_ms',
'segment_end_ms',
'text',
'secondary_text',
'CREATED_DATE',
'LAST_UPDATE_DATE',
] as const;
export interface SessionMergeResult {
newSessionIds: number[];
}
export function mergeSessions(
local: SyncDb,
remote: SyncDb,
videoIdMap: Map<number, number>,
animeIdMap: Map<number, number>,
lexicon: LexiconResolver,
summary: SyncMergeSummary,
): SessionMergeResult {
const newSessionIds: number[] = [];
const uuidExists = local.query('SELECT session_id FROM imm_sessions WHERE session_uuid = ?');
const remoteSessions = selectAll(
remote,
`SELECT session_id, video_id, ${SESSION_COPY_COLUMNS.join(', ')}
FROM imm_sessions
ORDER BY CAST(started_at_ms AS REAL) ASC, session_id ASC`,
);
for (const session of remoteSessions) {
if (session.ended_at_ms === null) {
// Stale ACTIVE sessions are finalized by the app on its next startup;
// they will sync once they carry final numbers.
summary.activeSessionsSkipped += 1;
continue;
}
if (uuidExists.get(session.session_uuid)) {
summary.sessionsAlreadyPresent += 1;
continue;
}
const localVideoId = videoIdMap.get(Number(session.video_id));
if (localVideoId === undefined) {
throw new Error(
`Snapshot session ${String(session.session_uuid)} references missing video row`,
);
}
const localSessionId = insertRow(
local,
'imm_sessions',
['video_id', ...SESSION_COPY_COLUMNS],
[localVideoId, ...SESSION_COPY_COLUMNS.map((column) => session[column])],
);
newSessionIds.push(localSessionId);
summary.sessionsMerged += 1;
const remoteSessionId = Number(session.session_id);
copyTelemetry(local, remote, remoteSessionId, localSessionId, summary);
const eventIdMap = copyEvents(local, remote, remoteSessionId, localSessionId, summary);
copySubtitleLines(
local,
remote,
remoteSessionId,
localSessionId,
localVideoId,
animeIdMap,
eventIdMap,
lexicon,
summary,
);
applyMergedSessionLifetime(local, localSessionId, localVideoId, session);
}
return { newSessionIds };
}
function copyTelemetry(
local: SyncDb,
remote: SyncDb,
remoteSessionId: number,
localSessionId: number,
summary: SyncMergeSummary,
): void {
const rows = selectAll(
remote,
`SELECT ${TELEMETRY_COPY_COLUMNS.join(', ')} FROM imm_session_telemetry
WHERE session_id = ? ORDER BY telemetry_id ASC`,
[remoteSessionId],
);
for (const row of rows) {
insertRow(
local,
'imm_session_telemetry',
['session_id', ...TELEMETRY_COPY_COLUMNS],
[localSessionId, ...TELEMETRY_COPY_COLUMNS.map((column) => row[column])],
);
summary.telemetryRowsAdded += 1;
}
}
function copyEvents(
local: SyncDb,
remote: SyncDb,
remoteSessionId: number,
localSessionId: number,
summary: SyncMergeSummary,
): Map<number, number> {
const eventIdMap = new Map<number, number>();
const rows = selectAll(
remote,
`SELECT event_id, ${EVENT_COPY_COLUMNS.join(', ')} FROM imm_session_events
WHERE session_id = ? ORDER BY event_id ASC`,
[remoteSessionId],
);
for (const row of rows) {
const localEventId = insertRow(
local,
'imm_session_events',
['session_id', ...EVENT_COPY_COLUMNS],
[localSessionId, ...EVENT_COPY_COLUMNS.map((column) => row[column])],
);
eventIdMap.set(Number(row.event_id), localEventId);
summary.eventsAdded += 1;
}
return eventIdMap;
}
function copySubtitleLines(
local: SyncDb,
remote: SyncDb,
remoteSessionId: number,
localSessionId: number,
localVideoId: number,
animeIdMap: Map<number, number>,
eventIdMap: Map<number, number>,
lexicon: LexiconResolver,
summary: SyncMergeSummary,
): void {
const rows = selectAll(
remote,
`SELECT line_id, event_id, anime_id, ${LINE_COPY_COLUMNS.join(', ')} FROM imm_subtitle_lines
WHERE session_id = ? ORDER BY line_id ASC`,
[remoteSessionId],
);
const wordOccurrences = remote.query(
'SELECT word_id, occurrence_count FROM imm_word_line_occurrences WHERE line_id = ?',
);
const kanjiOccurrences = remote.query(
'SELECT kanji_id, occurrence_count FROM imm_kanji_line_occurrences WHERE line_id = ?',
);
const insertWordOccurrence = local.query(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)
ON CONFLICT(line_id, word_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
);
const insertKanjiOccurrence = local.query(
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count) VALUES (?, ?, ?)
ON CONFLICT(line_id, kanji_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
);
for (const row of rows) {
const localEventId =
row.event_id === null ? null : (eventIdMap.get(Number(row.event_id)) ?? null);
const localAnimeId =
row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
const localLineId = insertRow(
local,
'imm_subtitle_lines',
['session_id', 'event_id', 'video_id', 'anime_id', ...LINE_COPY_COLUMNS],
[
localSessionId,
localEventId,
localVideoId,
localAnimeId,
...LINE_COPY_COLUMNS.map((column) => row[column]),
],
);
summary.subtitleLinesAdded += 1;
for (const occurrence of wordOccurrences.all(row.line_id) as SqlRow[]) {
const localWordId = lexicon.resolveWord(Number(occurrence.word_id));
const count = Number(occurrence.occurrence_count);
insertWordOccurrence.run(localLineId, localWordId, count);
lexicon.addWordOccurrences(Number(occurrence.word_id), count);
}
for (const occurrence of kanjiOccurrences.all(row.line_id) as SqlRow[]) {
const localKanjiId = lexicon.resolveKanji(Number(occurrence.kanji_id));
const count = Number(occurrence.occurrence_count);
insertKanjiOccurrence.run(localLineId, localKanjiId, count);
lexicon.addKanjiOccurrences(Number(occurrence.kanji_id), count);
}
}
}
/**
* Port of applySessionLifetimeSummary (src/core/services/immersion-tracker/
* lifetime.ts) for sessions arriving out of chronological order. The
* "first session of the day / for this video" checks are order-independent
* here (any other session counts, not just earlier ones): the local machine
* already credited active_days/episodes_started when its own session was
* applied, even if the merged session started earlier that day.
*/
function applyMergedSessionLifetime(
local: SyncDb,
sessionId: number,
videoId: number,
session: SqlRow,
): void {
const updatedAtMs = nowDbTimestamp();
const applied = local
.query(
`INSERT INTO imm_lifetime_applied_sessions (session_id, applied_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO NOTHING`,
)
.run(sessionId, session.ended_at_ms, updatedAtMs, updatedAtMs);
if (applied.changes <= 0) return;
const telemetry = selectOne(
local,
`SELECT active_watched_ms, cards_mined, lines_seen, tokens_seen
FROM imm_session_telemetry
WHERE session_id = ?
ORDER BY sample_ms DESC, telemetry_id DESC
LIMIT 1`,
[sessionId],
);
const metric = (telemetryValue: unknown, sessionValue: unknown): number => {
const fromTelemetry = telemetry ? Number(telemetryValue) : Number.NaN;
const value = Number.isFinite(fromTelemetry) ? fromTelemetry : Number(sessionValue);
return Math.max(0, Math.floor(Number.isFinite(value) ? value : 0));
};
const activeMs = metric(telemetry?.active_watched_ms, session.active_watched_ms);
const cardsMined = metric(telemetry?.cards_mined, session.cards_mined);
const linesSeen = metric(telemetry?.lines_seen, session.lines_seen);
const tokensSeen = metric(telemetry?.tokens_seen, session.tokens_seen);
const video = selectOne(local, 'SELECT anime_id, watched FROM imm_videos WHERE video_id = ?', [
videoId,
]);
const watched = Number(video?.watched ?? 0);
const animeId =
video?.anime_id === null || video?.anime_id === undefined ? null : Number(video.anime_id);
const mediaLifetime = selectOne(
local,
'SELECT completed FROM imm_lifetime_media WHERE video_id = ?',
[videoId],
);
const hasOtherSessionForVideo = Boolean(
local
.query('SELECT 1 FROM imm_sessions WHERE video_id = ? AND session_id != ? LIMIT 1')
.get(videoId, sessionId),
);
const isFirstSessionForVideoRun = !mediaLifetime && !hasOtherSessionForVideo;
const isFirstCompletedSessionForVideoRun = watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0;
const hasOtherSessionOnDay = Boolean(
local
.query(
`SELECT 1 FROM imm_sessions
WHERE session_id != ?
AND CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)
= CAST(julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)
LIMIT 1`,
)
.get(sessionId, session.started_at_ms),
);
let animeCompletedDelta = 0;
if (animeId !== null && watched > 0 && isFirstCompletedSessionForVideoRun) {
const animeLifetime = selectOne(
local,
'SELECT episodes_completed FROM imm_lifetime_anime WHERE anime_id = ?',
[animeId],
);
const anime = selectOne(local, 'SELECT episodes_total FROM imm_anime WHERE anime_id = ?', [
animeId,
]);
const episodesCompletedBefore = Number(animeLifetime?.episodes_completed ?? 0);
const episodesTotal =
anime?.episodes_total === null || anime?.episodes_total === undefined
? null
: Number(anime.episodes_total);
if (
episodesTotal !== null &&
episodesTotal > 0 &&
episodesCompletedBefore < episodesTotal &&
episodesCompletedBefore + 1 >= episodesTotal
) {
animeCompletedDelta = 1;
}
}
local
.query(
`UPDATE imm_lifetime_global
SET total_sessions = total_sessions + 1,
total_active_ms = total_active_ms + ?,
total_cards = total_cards + ?,
active_days = active_days + ?,
episodes_started = episodes_started + ?,
episodes_completed = episodes_completed + ?,
anime_completed = anime_completed + ?,
LAST_UPDATE_DATE = ?
WHERE global_id = 1`,
)
.run(
activeMs,
cardsMined,
hasOtherSessionOnDay ? 0 : 1,
isFirstSessionForVideoRun ? 1 : 0,
isFirstCompletedSessionForVideoRun ? 1 : 0,
animeCompletedDelta,
updatedAtMs,
);
local
.query(
`INSERT INTO imm_lifetime_media(
video_id, total_sessions, total_active_ms, total_cards, total_lines_seen,
total_tokens_seen, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
)
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(video_id) DO UPDATE SET
total_sessions = total_sessions + 1,
total_active_ms = total_active_ms + excluded.total_active_ms,
total_cards = total_cards + excluded.total_cards,
total_lines_seen = total_lines_seen + excluded.total_lines_seen,
total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen,
completed = MAX(completed, excluded.completed),
first_watched_ms = CASE
WHEN excluded.first_watched_ms IS NULL THEN first_watched_ms
WHEN first_watched_ms IS NULL THEN excluded.first_watched_ms
WHEN excluded.first_watched_ms < first_watched_ms THEN excluded.first_watched_ms
ELSE first_watched_ms
END,
last_watched_ms = CASE
WHEN excluded.last_watched_ms IS NULL THEN last_watched_ms
WHEN last_watched_ms IS NULL THEN excluded.last_watched_ms
WHEN excluded.last_watched_ms > last_watched_ms THEN excluded.last_watched_ms
ELSE last_watched_ms
END,
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
)
.run(
videoId,
activeMs,
cardsMined,
linesSeen,
tokensSeen,
watched > 0 ? 1 : 0,
session.started_at_ms,
session.ended_at_ms,
updatedAtMs,
updatedAtMs,
);
if (animeId !== null) {
local
.query(
`INSERT INTO imm_lifetime_anime(
anime_id, total_sessions, total_active_ms, total_cards, total_lines_seen,
total_tokens_seen, episodes_started, episodes_completed, first_watched_ms,
last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
)
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(anime_id) DO UPDATE SET
total_sessions = total_sessions + 1,
total_active_ms = total_active_ms + excluded.total_active_ms,
total_cards = total_cards + excluded.total_cards,
total_lines_seen = total_lines_seen + excluded.total_lines_seen,
total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen,
episodes_started = episodes_started + excluded.episodes_started,
episodes_completed = episodes_completed + excluded.episodes_completed,
first_watched_ms = CASE
WHEN excluded.first_watched_ms IS NULL THEN first_watched_ms
WHEN first_watched_ms IS NULL THEN excluded.first_watched_ms
WHEN excluded.first_watched_ms < first_watched_ms THEN excluded.first_watched_ms
ELSE first_watched_ms
END,
last_watched_ms = CASE
WHEN excluded.last_watched_ms IS NULL THEN last_watched_ms
WHEN last_watched_ms IS NULL THEN excluded.last_watched_ms
WHEN excluded.last_watched_ms > last_watched_ms THEN excluded.last_watched_ms
ELSE last_watched_ms
END,
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
)
.run(
animeId,
activeMs,
cardsMined,
linesSeen,
tokensSeen,
isFirstSessionForVideoRun ? 1 : 0,
isFirstCompletedSessionForVideoRun ? 1 : 0,
session.started_at_ms,
session.ended_at_ms,
updatedAtMs,
updatedAtMs,
);
}
}
+104
View File
@@ -0,0 +1,104 @@
import fs from 'node:fs';
import {
LexiconResolver,
mergeAnime,
mergeExcludedWords,
mergeMediaMetadata,
mergeVideos,
} from './merge-catalog';
import { mergeSessions } from './merge-sessions';
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups';
import {
assertMergeableSchema,
createEmptyMergeSummary,
type SyncMergeSummary,
} from './shared';
import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
export type { SyncMergeSummary } from './shared';
/**
* Merge a snapshot of another machine's immersion database into the local
* one. Insert-only union keyed on natural keys (session_uuid, video_key,
* normalized_title_key, word/kanji identity); lifetime and rollup aggregates
* are updated incrementally so history older than the session retention
* window is preserved on both sides. Idempotent: re-merging the same
* snapshot is a no-op.
*/
export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string): SyncMergeSummary {
if (!fs.existsSync(localDbPath)) {
throw new Error(`Local stats database not found: ${localDbPath}`);
}
if (!fs.existsSync(snapshotPath)) {
throw new Error(`Snapshot database not found: ${snapshotPath}`);
}
const remote = openLibsqlSyncDb(snapshotPath, { readonly: true });
let local: SyncDb;
try {
local = openLibsqlSyncDb(localDbPath, { create: false });
} catch (error) {
remote.close();
throw error;
}
try {
assertMergeableSchema(remote, 'Snapshot');
assertMergeableSchema(local, 'Local');
const summary = createEmptyMergeSummary();
local.exec('PRAGMA foreign_keys = ON');
local.exec('PRAGMA busy_timeout = 5000');
local.exec('BEGIN IMMEDIATE');
try {
const animeIdMap = mergeAnime(local, remote, summary);
const { videoIdMap, addedVideoIds } = mergeVideos(local, remote, animeIdMap, summary);
mergeMediaMetadata(local, remote, videoIdMap, addedVideoIds);
mergeExcludedWords(local, remote, summary);
const lexicon = new LexiconResolver(local, remote, summary);
const { newSessionIds } = mergeSessions(
local,
remote,
videoIdMap,
animeIdMap,
lexicon,
summary,
);
lexicon.applyFrequencyDeltas();
refreshRollupsForNewSessions(local, newSessionIds, summary);
copyRemoteOnlyRollups(local, remote, videoIdMap, summary);
local.exec('COMMIT');
return summary;
} catch (error) {
local.exec('ROLLBACK');
throw error;
}
} finally {
local.close();
remote.close();
}
}
export function formatMergeSummary(summary: SyncMergeSummary): string {
const lines = [
`Sessions merged: ${summary.sessionsMerged} (${summary.sessionsAlreadyPresent} already present, ${summary.activeSessionsSkipped} unfinished skipped)`,
];
const detail: string[] = [];
if (summary.animeAdded) detail.push(`${summary.animeAdded} series`);
if (summary.videosAdded) detail.push(`${summary.videosAdded} videos`);
if (summary.wordsAdded) detail.push(`${summary.wordsAdded} words`);
if (summary.kanjiAdded) detail.push(`${summary.kanjiAdded} kanji`);
if (summary.subtitleLinesAdded) detail.push(`${summary.subtitleLinesAdded} subtitle lines`);
if (summary.excludedWordsAdded) detail.push(`${summary.excludedWordsAdded} excluded words`);
if (detail.length > 0) lines.push(`Added: ${detail.join(', ')}`);
if (summary.dailyRollupsCopied || summary.monthlyRollupsCopied) {
lines.push(
`Historical rollups copied: ${summary.dailyRollupsCopied} daily, ${summary.monthlyRollupsCopied} monthly`,
);
}
if (summary.rollupGroupsRecomputed) {
lines.push(`Rollup groups recomputed: ${summary.rollupGroupsRecomputed}`);
}
return lines.join('\n');
}
+150
View File
@@ -0,0 +1,150 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { SCHEMA_VERSION } from '../immersion-tracker/types';
import { getDefaultConfigDir } from '../../../shared/setup-state';
import { withReadonlyWalRetry } from './wal-retry';
import { openLibsqlSyncDb, selectOne, type SyncDb } from './libsql-driver';
export { SCHEMA_VERSION };
import type { SyncMergeSummary } from '../../../shared/sync/sync-events';
export type { SyncMergeSummary };
export function createEmptyMergeSummary(): SyncMergeSummary {
return {
sessionsMerged: 0,
sessionsAlreadyPresent: 0,
activeSessionsSkipped: 0,
animeAdded: 0,
videosAdded: 0,
wordsAdded: 0,
kanjiAdded: 0,
subtitleLinesAdded: 0,
telemetryRowsAdded: 0,
eventsAdded: 0,
excludedWordsAdded: 0,
dailyRollupsCopied: 0,
monthlyRollupsCopied: 0,
rollupGroupsRecomputed: 0,
};
}
export function nowDbTimestamp(): string {
return String(Date.now());
}
export function tableExists(db: SyncDb, tableName: string): boolean {
return Boolean(
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
);
}
function readSchemaVersion(db: SyncDb): number | null {
if (!tableExists(db, 'imm_schema_version')) return null;
const row = selectOne(db, 'SELECT MAX(schema_version) AS schema_version FROM imm_schema_version');
return typeof row?.schema_version === 'number' ? row.schema_version : null;
}
export function assertMergeableSchema(db: SyncDb, label: string): void {
const version = readSchemaVersion(db);
if (version === null) {
throw new Error(
`${label} database has no schema version. Run SubMiner once on that machine so the stats database is initialized.`,
);
}
if (version !== SCHEMA_VERSION) {
throw new Error(
`${label} database is at schema version ${version} but this SubMiner install expects ${SCHEMA_VERSION}. Update SubMiner on both machines to the same version and run each app once before syncing.`,
);
}
for (const table of ['imm_sessions', 'imm_videos', 'imm_lifetime_global']) {
if (!tableExists(db, table)) {
throw new Error(`${label} database is missing table ${table}; cannot sync.`);
}
}
}
export function insertRow(
db: SyncDb,
table: string,
columns: readonly string[],
values: unknown[],
): number {
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
// query() caches the prepared statement per SQL string; this runs once per
// copied row, so re-preparing each time would dominate merge time.
const result = db.query(sql).run(...values);
return Number(result.lastInsertRowid);
}
export function createDbSnapshot(dbPath: string, outPath: string): void {
if (!fs.existsSync(dbPath)) {
throw new Error(`Stats database not found: ${dbPath}`);
}
fs.rmSync(outPath, { force: true });
fs.mkdirSync(path.dirname(outPath), { recursive: true });
withReadonlyWalRetry(dbPath, (options) => {
const db = openLibsqlSyncDb(dbPath, options);
try {
assertMergeableSchema(db, 'Local');
db.query('VACUUM INTO ?').run(outPath);
} finally {
db.close();
}
});
}
interface DaemonStateFile {
pid?: unknown;
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM means the process exists but we can't signal it → still alive.
// Only ESRCH (no such process) means it's actually gone.
return (error as NodeJS.ErrnoException)?.code === 'EPERM';
}
}
function statsDaemonStateCandidates(dbPath: string): string[] {
const homeDir = os.homedir();
const candidates = new Set<string>([path.join(path.dirname(dbPath), 'stats-daemon.json')]);
candidates.add(path.join(getDefaultConfigDir(), 'stats-daemon.json'));
if (process.platform === 'darwin') {
candidates.add(
path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'),
);
}
return [...candidates];
}
/**
* Best-effort guard against merging while a SubMiner process holds the
* tracker's write queue in memory. Detects the background stats daemon via
* its pid state file; the interactive app is caught by the mpv-socket check
* in the sync flow.
*/
export function findLiveStatsDaemonPid(dbPath: string): number | null {
for (const statePath of statsDaemonStateCandidates(dbPath)) {
let raw: string;
try {
raw = fs.readFileSync(statePath, 'utf8');
} catch {
continue;
}
try {
const parsed = JSON.parse(raw) as DaemonStateFile;
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) ? parsed.pid : 0;
if (pid > 0 && isProcessAlive(pid)) {
return pid;
}
} catch {
continue;
}
}
return null;
}
+191
View File
@@ -0,0 +1,191 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertSafeSshHost,
detectRemoteShellFlavor,
quoteForRemoteShell,
resolveRemoteSubminerCommand,
runScp,
shellQuote,
type RemoteRunResult,
} from './ssh';
function remoteResult(status: number, stdout = ''): RemoteRunResult {
return { status, stdout, stderr: '' };
}
test('assertSafeSshHost rejects option-like hosts', () => {
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
assert.throws(() => assertSafeSshHost('-lroot'), /looks like an option/);
});
test('assertSafeSshHost accepts normal destinations', () => {
assert.doesNotThrow(() => assertSafeSshHost('macbook'));
assert.doesNotThrow(() => assertSafeSshHost('user@192.168.1.20'));
assert.doesNotThrow(() => assertSafeSshHost('ssh-alias'));
});
test('shellQuote escapes single quotes and wraps in quotes', () => {
assert.equal(shellQuote('subminer'), `'subminer'`);
assert.equal(shellQuote(`a'; rm -rf ~; '`), `'a'\\''; rm -rf ~; '\\'''`);
});
test('runScp rejects option-like local endpoints before spawning scp', () => {
assert.throws(() => runScp('-oProxyCommand=sh', '/tmp/out.sqlite'), /looks like an option/);
assert.throws(() => runScp('/tmp/in.sqlite', '-bad-destination'), /looks like an option/);
});
test('runScp rejects option-like remote host components', () => {
assert.throws(
() => runScp('-oProxyCommand=sh:/tmp/in.sqlite', '/tmp/out.sqlite'),
/SSH host that looks like an option/,
);
});
test('resolveRemoteSubminerCommand verifies the launcher under the remote runtime PATH', () => {
const calls: Array<{ host: string; remoteCommand: string }> = [];
const command = resolveRemoteSubminerCommand('macbook', null, 'posix', (host, remoteCommand) => {
calls.push({ host, remoteCommand });
return remoteResult(0);
});
assert.equal(
command,
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer',
);
assert.deepEqual(calls, [
{
host: 'macbook',
remoteCommand:
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer --help >/dev/null 2>&1',
},
]);
});
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);
},
);
assert.match(command, / SubMiner --sync-cli$/);
// Launcher candidates (PATH + ~/.local/bin) are tried before app binaries.
assert.equal(probed.length, 3);
assert.match(probed[0]!, / subminer --help /);
assert.match(probed[1]!, / ~\/\.local\/bin\/subminer --help /);
});
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),
);
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),
);
assert.match(asLauncher, /'\/opt\/subminer'$/);
assert.throws(
() => resolveRemoteSubminerCommand('media-box', '/missing', 'posix', () => remoteResult(127)),
/Remote command not found on media-box: \/missing/,
);
});
test('resolveRemoteSubminerCommand probes Windows install locations without a PATH prefix', () => {
const probed: string[] = [];
const command = resolveRemoteSubminerCommand('win-box', null, 'windows-cmd', (_host, cmd) => {
probed.push(cmd);
return remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1);
});
assert.equal(command, '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli');
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),
);
assert.equal(powershell, '& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe" --sync-cli');
});
test('resolveRemoteSubminerCommand quotes Windows overrides with double quotes', () => {
const command = resolveRemoteSubminerCommand(
'win-box',
'C:/Apps/SubMiner/SubMiner.exe',
'windows-cmd',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
);
assert.equal(command, '"C:/Apps/SubMiner/SubMiner.exe" --sync-cli');
});
test('detectRemoteShellFlavor identifies posix, cmd, and powershell remotes', () => {
assert.equal(
detectRemoteShellFlavor('linux-box', (_host, cmd) =>
cmd === 'uname -s' ? remoteResult(0, 'Linux\n') : remoteResult(1),
),
'posix',
);
assert.equal(
detectRemoteShellFlavor('win-box', (_host, cmd) => {
if (cmd === 'uname -s') return remoteResult(1);
if (cmd === 'echo %OS%') return remoteResult(0, 'Windows_NT\r\n');
return remoteResult(1);
}),
'windows-cmd',
);
assert.equal(
detectRemoteShellFlavor('ps-box', (_host, cmd) => {
if (cmd === 'uname -s') return remoteResult(1);
if (cmd === 'echo %OS%') return remoteResult(0, '%OS%\r\n');
if (cmd === 'echo $env:OS') return remoteResult(0, 'Windows_NT\r\n');
return remoteResult(1);
}),
'windows-powershell',
);
// Unidentifiable remotes keep the pre-detection POSIX behavior.
assert.equal(
detectRemoteShellFlavor('odd-box', () => remoteResult(1)),
'posix',
);
});
test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values', () => {
assert.equal(quoteForRemoteShell('posix', "/tmp/it's"), `'/tmp/it'\\''s'`);
assert.equal(
quoteForRemoteShell('windows-cmd', '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', 'C:/tmp/%TEMP%/db.sqlite'), /percent/);
assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/);
});
test('quoteForRemoteShell does not let PowerShell expand a quoted value', () => {
// PowerShell expands $(...) and $var inside double quotes, so a single-quoted
// literal is the only safe form; '' is the escape for an embedded quote.
assert.equal(
quoteForRemoteShell('windows-powershell', 'C:/tmp/$(calc.exe)'),
`'C:/tmp/$(calc.exe)'`,
);
assert.equal(quoteForRemoteShell('windows-powershell', "C:/tmp/it's"), `'C:/tmp/it''s'`);
assert.equal(
quoteForRemoteShell('windows-powershell', 'C:/Users/First Last/Temp/subminer-sync-ab'),
`'C:/Users/First Last/Temp/subminer-sync-ab'`,
);
});
+223
View File
@@ -0,0 +1,223 @@
import { spawnSync } from 'node:child_process';
import { SYNC_CLI_FLAG } from './cli-args';
export interface RemoteRunResult {
status: number;
stdout: string;
stderr: string;
}
export interface RunSshOptions {
batchMode?: boolean;
connectTimeoutSeconds?: number;
timeoutMs?: number;
}
/**
* ssh/scp have no `--` terminator for the destination, so a host that starts
* with `-` (e.g. `-oProxyCommand=...`) is parsed as an option. Reject those
* before spawning.
*/
export function assertSafeSshHost(host: string): void {
if (host.startsWith('-')) {
throw new Error(`Refusing to use SSH host that looks like an option: ${host}`);
}
}
/**
* Run a command on the SSH host. stdin stays attached so interactive prompts
* can still read from the terminal; stdout/stderr are captured for callers
* that need actionable remote failure messages.
*/
export function runSsh(
host: string,
remoteCommand: string,
options: RunSshOptions = {},
): RemoteRunResult {
assertSafeSshHost(host);
const args: string[] = [];
if (options.batchMode) args.push('-o', 'BatchMode=yes');
if (options.connectTimeoutSeconds !== undefined) {
args.push('-o', `ConnectTimeout=${options.connectTimeoutSeconds}`);
}
args.push(host, remoteCommand);
const result = spawnSync('ssh', args, {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'pipe'],
timeout: options.timeoutMs,
});
if (result.error) {
throw new Error(`Failed to run ssh: ${(result.error as Error).message}`);
}
return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
}
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)) {
if (endpoint.startsWith('-')) {
throw new Error(`Refusing to use scp endpoint that looks like an option: ${endpoint}`);
}
return;
}
const host = endpoint.slice(0, colon);
const remotePath = endpoint.slice(colon + 1);
assertSafeSshHost(host);
if (remotePath.startsWith('-')) {
throw new Error(`Refusing to use scp remote path that looks like an option: ${remotePath}`);
}
}
export function runScp(from: string, to: string): void {
assertSafeScpEndpoint(from);
assertSafeScpEndpoint(to);
const result = spawnSync('scp', ['-q', from, to], {
encoding: 'utf8',
stdio: ['inherit', 'inherit', 'inherit'],
});
if (result.error) {
throw new Error(`Failed to run scp: ${(result.error as Error).message}`);
}
if ((result.status ?? 1) !== 0) {
throw new Error(`scp failed copying ${from} -> ${to}`);
}
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
/**
* The shell that Windows OpenSSH hands remote commands to (cmd.exe by
* default, PowerShell when DefaultShell is changed); it decides quoting,
* environment-variable expansion, and which SubMiner install paths to probe.
*/
export type RemoteShellFlavor = 'posix' | 'windows-cmd' | 'windows-powershell';
/**
* Identify the remote shell with probes that are harmless everywhere:
* `uname -s` only succeeds on a POSIX shell, `echo %OS%` only expands under
* cmd.exe, and `echo $env:OS` only expands under PowerShell. Defaults to
* posix so unreachable/odd hosts fail with the familiar POSIX errors.
*/
export function detectRemoteShellFlavor(
host: string,
runRemote: (host: string, remoteCommand: string) => RemoteRunResult,
): RemoteShellFlavor {
const posixProbe = runRemote(host, 'uname -s');
if (posixProbe.status === 0 && posixProbe.stdout.trim().length > 0) return 'posix';
const cmdProbe = runRemote(host, 'echo %OS%');
if (cmdProbe.status === 0 && cmdProbe.stdout.includes('Windows_NT')) return 'windows-cmd';
const powershellProbe = runRemote(host, 'echo $env:OS');
if (powershellProbe.status === 0 && powershellProbe.stdout.includes('Windows_NT')) {
return 'windows-powershell';
}
return 'posix';
}
/**
* Quote one argument for the detected remote shell. PowerShell expands $(...)
* and $var inside double quotes, so it gets a single-quoted literal ('' escapes
* a quote). cmd.exe has no single-quote form and treats ' literally, so it keeps
* double quotes and rejects values carrying a double quote of their own.
*/
export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): string {
if (flavor === 'posix') return shellQuote(value);
if (/[\r\n]/.test(value)) {
throw new Error(`Refusing to quote a value with newlines for a Windows shell: ${value}`);
}
if (flavor === 'windows-powershell') {
return `'${value.replaceAll("'", "''")}'`;
}
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}"`;
}
const REMOTE_RUNTIME_PATH =
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"';
// The Electron app answers the same launcher-style `sync ...` argv when
// invoked with --sync-cli (SYNC_CLI_FLAG), so a remote machine only needs the
// app installed; the command-line launcher is one candidate, not a
// requirement. Each candidate is the remote invocation to probe, in order.
function defaultRemoteCandidates(flavor: RemoteShellFlavor): string[] {
if (flavor === 'windows-cmd' || flavor === 'windows-powershell') {
// cmd.exe expands %VAR% inside double quotes; PowerShell needs $env: and
// the & call operator to run a quoted path.
const launcherShim =
flavor === 'windows-cmd'
? `"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd"`
: `& "$env:LOCALAPPDATA\\SubMiner\\bin\\subminer.cmd"`;
const appInstall =
flavor === 'windows-cmd'
? `"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe"`
: `& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe"`;
return [
// Command-line launcher shim on PATH or in its default install dir.
'subminer',
launcherShim,
// The app binary itself in sync-CLI mode (default NSIS install dir).
`${appInstall} ${SYNC_CLI_FLAG}`,
`SubMiner ${SYNC_CLI_FLAG}`,
];
}
return [
// Command-line launcher (bun script) on PATH or in its default install dir.
'subminer',
'~/.local/bin/subminer',
// The app binary itself in sync-CLI mode.
`SubMiner ${SYNC_CLI_FLAG}`,
`/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
`~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
];
}
function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): string[] {
const quoted = quoteForRemoteShell(flavor, preferred);
const invocation = flavor === 'windows-powershell' ? `& ${quoted}` : quoted;
// App binaries also answer plain --help by opening the GUI-oriented help
// path, so probe the sync-CLI shape first.
return [`${invocation} ${SYNC_CLI_FLAG}`, invocation];
}
/**
* Non-interactive POSIX SSH shells often miss user-installed launchers and
* Bun, so those candidates are probed under the same deterministic PATH sync
* itself uses; Windows shells get their default install locations instead.
* Trusted defaults stay unquoted so the remote shell expands `~`/%VAR%; a
* user-supplied override is quoted to prevent command injection and probed
* both as an app binary (--sync-cli) and as a launcher.
*/
export function resolveRemoteSubminerCommand(
host: string,
preferred: string | null,
flavor: RemoteShellFlavor = 'posix',
runRemote: typeof runSsh = runSsh,
): string {
const candidates = preferred
? preferredCandidates(flavor, preferred)
: defaultRemoteCandidates(flavor);
for (const candidate of candidates) {
const command = flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate}` : candidate;
const probe = runRemote(
host,
flavor === 'posix' ? `${command} --help >/dev/null 2>&1` : `${command} --help`,
);
if (probe.status === 0) {
return command;
}
}
throw new Error(
preferred
? `Remote command not found on ${host}: ${preferred}`
: `SubMiner not found on ${host} (tried the subminer launcher and the SubMiner app binary in their default install locations). Pass --remote-cmd <path> pointing at the SubMiner app or launcher.`,
);
}
@@ -0,0 +1,492 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createEmptyMergeSummary } from './shared';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
type SyncFlowContext,
type SyncFlowDeps,
} from './sync-flow';
function makeContext(overrides: Partial<SyncFlowContext['args']> = {}): SyncFlowContext {
return {
args: {
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn',
...overrides,
},
mpvSocketPath: '',
};
}
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
// recordHostSyncResult defaults to a no-op here: the real disk-writing
// implementations are bound by the entry points, never by the flow itself.
function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return {
createDbSnapshot: () => {},
mergeSnapshotIntoDb: () => createEmptyMergeSummary(),
findLiveStatsDaemonPid: () => null,
assertSafeSshHost: () => {},
detectRemoteShellFlavor: () => 'posix',
resolveRemoteSubminerCommand: () => 'subminer',
runScp: () => {},
runSsh: () => ok(),
canConnectUnixSocket: async () => false,
realpathSync: (candidate) => candidate,
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options),
consoleLog: () => {},
writeStdout: () => true,
ensureTrackerQuiescent: async () => {},
emitEvent: () => {},
recordHostSyncResult: () => {},
resolveDefaultDbPath: () => '/tracker.sqlite',
...overrides,
};
}
test('ensureTrackerQuiescentFlow ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket';
let socketConnectable = false;
const deps = makeDeps({
realpathSync: () => '/tracker.sqlite',
canConnectUnixSocket: async () => socketConnectable,
});
await ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps);
socketConnectable = true;
await assert.rejects(
async () => ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps),
/mpv\/SubMiner session appears to be running/,
);
});
test('ensureTrackerQuiescentFlow rejects a live stats daemon and honors --force', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
const deps = makeDeps({
realpathSync: () => '/tracker.sqlite',
findLiveStatsDaemonPid: () => 4242,
});
await assert.rejects(
async () => ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps),
/stats server is running \(pid 4242\)/,
);
const forced = makeContext({ syncDbPath: '/tmp/local.sqlite', syncForce: true });
await ensureTrackerQuiescentFlow(forced, '/tmp/local.sqlite', deps);
});
test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = [];
const deps = makeDeps({
createDbSnapshot: (dbPath, outPath) => {
calls.push(`snapshot:${dbPath}->${outPath}`);
},
mergeSnapshotIntoDb: (dbPath, snapshotPath) => {
calls.push(`merge:${dbPath}<-${snapshotPath}`);
return createEmptyMergeSummary();
},
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: (host) => {
calls.push(`host:${host}`);
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
return command.includes(' sync --make-temp') ? ok('/tmp/subminer-sync-remote\n') : ok();
},
runScp: (from, to) => {
calls.push(`scp:${from}->${to}`);
},
});
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
);
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' }),
deps,
);
assert.ok(calls.includes('quiescent'));
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
await runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps);
assert.ok(calls.includes('host:media-box'));
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/,
);
});
function makeHostDeps(calls: string[], overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return makeDeps({
createDbSnapshot: (_dbPath, outPath) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
return ok();
},
runScp: (from, to) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
...overrides,
});
}
test('runHostSync keeps tracker quiescent through both merges and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
const deps = makeHostDeps(calls, {
mkdtempSync: (prefix) => {
localTmpDir = fs.mkdtempSync(prefix);
return localTmpDir;
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) {
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
}
return ok();
},
});
await assert.rejects(
() =>
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);
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
assert.ok(calls.includes('local-merge'));
assert.ok(calls.some((call) => call.includes(' sync --remove-temp ')));
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps = makeHostDeps([], {
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --snapshot ')) {
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
}
return ok();
},
});
await assert.rejects(
() =>
runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
);
});
test('runHostSync push only snapshots locally and merges remotely', async () => {
const calls: string[] = [];
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncDirection: 'push' }),
makeHostDeps(calls),
);
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(!calls.includes('local-merge'));
});
test('runHostSync pull only snapshots remotely and merges locally', async () => {
const calls: string[] = [];
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncDirection: 'pull' }),
makeHostDeps(calls),
);
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
assert.ok(calls.includes('local-merge'));
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
});
test('runSyncFlow --json emits NDJSON progress events and a final result', async () => {
const lines: string[] = [];
const remoteSummary = {
...createEmptyMergeSummary(),
sessionsMerged: 2,
videosAdded: 1,
};
const deps = makeHostDeps([], {
consoleLog: (line) => {
lines.push(line);
},
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) {
assert.match(command, / --json(?: |$)/);
return ok(
`${JSON.stringify({ type: 'merge-summary', target: 'local', summary: remoteSummary })}\n` +
`${JSON.stringify({ type: 'result', ok: true, error: null })}\n`,
);
}
return ok();
},
});
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
deps,
);
const events = lines.map((line) => JSON.parse(line));
assert.ok(events.some((event) => event.type === 'stage' && event.stage === 'snapshot-local'));
assert.ok(events.some((event) => event.type === 'merge-summary' && event.target === 'local'));
assert.deepEqual(
events.find((event) => event.type === 'merge-summary' && event.target === 'remote'),
{ type: 'merge-summary', target: 'remote', summary: remoteSummary },
);
assert.deepEqual(events[events.length - 1], { type: 'result', ok: true, error: null });
});
test('runSyncFlow --json emits an error result when the sync fails', async () => {
const lines: string[] = [];
const deps = makeHostDeps([], {
consoleLog: (line) => {
lines.push(line);
},
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) return { status: 9, stdout: '', stderr: 'boom' };
return ok();
},
});
await assert.rejects(() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
deps,
),
);
const events = lines.map((line) => JSON.parse(line));
const last = events[events.length - 1];
assert.equal(last.type, 'result');
assert.equal(last.ok, false);
assert.match(last.error, /Remote merge failed/);
});
test('runHostSync records host sync results for saved-host bookkeeping', async () => {
const recorded: Array<{ host: string; status: string; detail: string | null }> = [];
const record: SyncFlowDeps['recordHostSyncResult'] = (host, status, detail) => {
recorded.push({ host, status, detail });
};
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps([], { recordHostSyncResult: record }),
);
assert.deepEqual(recorded[0], {
host: 'media-box',
status: 'success',
detail: '0 sessions merged; pushed local stats',
});
await assert.rejects(() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps([], {
recordHostSyncResult: record,
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) return { status: 9, stdout: '', stderr: 'boom' };
return ok();
},
}),
),
);
assert.equal(recorded.length, 2);
assert.equal(recorded[1]!.status, 'error');
});
test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
const lines: string[] = [];
const sshOptions: unknown[] = [];
const deps = makeDeps({
consoleLog: (line) => {
lines.push(line);
},
resolveRemoteSubminerCommand: (host, _preferred, _flavor, runRemote) => {
runRemote!(host, 'subminer --help');
return 'subminer';
},
runSsh: (_host, command, options) => {
sshOptions.push(options);
if (command.includes('--version')) return ok('SubMiner 0.18.0\n');
return ok('subminer-check-ok\n');
},
});
await runSyncFlow(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
deps,
);
const events = lines.map((line) => JSON.parse(line));
const check = events.find((event) => event.type === 'check-result');
assert.ok(check);
assert.equal(check.host, 'media-box');
assert.equal(check.sshOk, true);
assert.equal(check.remoteCommand, 'subminer');
assert.equal(check.remoteVersion, 'SubMiner 0.18.0');
assert.equal(check.ok, true);
assert.equal(check.error, null);
assert.equal(sshOptions.length, 3);
assert.ok(
sshOptions.every(
(options) =>
JSON.stringify(options) ===
JSON.stringify({ batchMode: true, connectTimeoutSeconds: 10, timeoutMs: 15_000 }),
),
);
const failLines: string[] = [];
await assert.rejects(() =>
runSyncFlow(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
makeDeps({
consoleLog: (line) => {
failLines.push(line);
},
resolveRemoteSubminerCommand: () => {
throw new Error('Could not find a runnable "subminer" on media-box.');
},
runSsh: () => ok('subminer-check-ok\n'),
}),
),
);
const failEvents = failLines.map((line) => JSON.parse(line));
const failedCheck = failEvents.find((event) => event.type === 'check-result');
assert.ok(failedCheck);
assert.equal(failedCheck.ok, false);
assert.match(failedCheck.error, /Could not find a runnable/);
});
test('runHostSync speaks Windows shells: app command, double quotes, temp protocol', async () => {
const sshCommands: string[] = [];
const scpCalls: string[] = [];
const winTemp = 'C:\\Users\\First Last\\AppData\\Local\\Temp\\subminer-sync-remote';
const appCmd = '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli';
const deps = makeHostDeps([], {
detectRemoteShellFlavor: () => 'windows-cmd',
resolveRemoteSubminerCommand: () => appCmd,
createDbSnapshot: (_dbPath, outPath) => {
fs.writeFileSync(outPath, 'snapshot');
},
runSsh: (_host, command) => {
sshCommands.push(command);
if (command.includes(' sync --make-temp')) return ok(`${winTemp}\r\n`);
return ok();
},
runScp: (from, to) => {
scpCalls.push(`${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
});
await runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'win-box' }), deps);
const expectedDir = 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-remote';
assert.ok(sshCommands.includes(`${appCmd} sync --snapshot "${expectedDir}/snapshot.sqlite"`));
assert.ok(sshCommands.includes(`${appCmd} sync --merge "${expectedDir}/incoming.sqlite"`));
assert.ok(sshCommands.includes(`${appCmd} sync --remove-temp "${expectedDir}"`));
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.includes('PATH="$HOME')));
});
test('runSyncFlow --make-temp prints a temp dir and --remove-temp only removes sync temp dirs', async () => {
const printed: string[] = [];
const removed: string[] = [];
const madeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
try {
await runSyncFlow(
makeContext({ syncMakeTemp: true }),
makeDeps({
mkdtempSync: () => madeDir,
consoleLog: (line) => {
printed.push(line);
},
}),
);
assert.deepEqual(printed, [madeDir]);
const removeDeps = makeDeps({
rmSync: (target) => {
removed.push(target);
},
});
await runSyncFlow(makeContext({ syncRemoveTempPath: madeDir }), removeDeps);
assert.deepEqual(removed, [madeDir]);
await assert.rejects(
() => runSyncFlow(makeContext({ syncRemoveTempPath: '/etc' }), removeDeps),
/Refusing to remove a directory outside the sync temp area/,
);
assert.equal(removed.length, 1);
} finally {
fs.rmSync(madeDir, { recursive: true, force: true });
}
});
+482
View File
@@ -0,0 +1,482 @@
import os from 'node:os';
import path from 'node:path';
import { formatMergeSummary } from './merge';
import { quoteForRemoteShell } from './ssh';
import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh';
import {
parseSyncProgressLine,
type SyncMergeSummary,
type SyncProgressEvent,
} from '../../../shared/sync/sync-events';
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
export interface SyncFlowArgs {
syncHost: string;
syncSnapshotPath: string;
syncMergePath: string;
syncDirection: 'both' | 'push' | 'pull' | null;
syncRemoteCmd: string;
syncDbPath: string;
syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncMakeTemp: boolean;
syncRemoveTempPath: string;
logLevel: string;
}
export interface SyncFlowContext {
args: SyncFlowArgs;
mpvSocketPath: string;
}
/**
* Process/IO seams the sync flow needs stubbed in tests: SSH/scp, the DB
* snapshot/merge engine, filesystem, and progress/bookkeeping output. The
* app's --sync-cli mode (src/main/sync-cli.ts) provides the only production
* binding; pure helpers are imported directly.
*/
export interface SyncFlowDeps {
createDbSnapshot: (dbPath: string, outPath: string) => void;
mergeSnapshotIntoDb: (localDbPath: string, snapshotPath: string) => SyncMergeSummary;
findLiveStatsDaemonPid: (dbPath: string) => number | null;
assertSafeSshHost: (host: string) => void;
detectRemoteShellFlavor: (
host: string,
runRemote: (host: string, remoteCommand: string) => RemoteRunResult,
) => RemoteShellFlavor;
resolveRemoteSubminerCommand: (
host: string,
preferred: string | null,
flavor: RemoteShellFlavor,
runRemote?: (host: string, remoteCommand: string) => RemoteRunResult,
) => string;
runScp: (from: string, to: string) => void;
runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult;
canConnectUnixSocket: (socketPath: string) => Promise<boolean>;
realpathSync: (candidate: string) => string;
mkdtempSync: (prefix: string) => string;
rmSync: (target: string, options: { recursive: boolean; force: boolean }) => void;
consoleLog: (message: string) => void;
writeStdout: (text: string) => boolean;
ensureTrackerQuiescent: (context: SyncFlowContext, dbPath: string) => Promise<void>;
emitEvent: (event: SyncProgressEvent) => void;
recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void;
resolveDefaultDbPath: () => string;
}
/** Expand a leading `~` (as ssh users write paths) and make the path absolute. */
function resolveCliPath(input: string): string {
return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input);
}
/**
* Prefix shared by every sync temp dir, local or remote. Remote temp dirs are
* created and removed by the remote SubMiner itself (sync --make-temp /
* --remove-temp) so the flow never depends on mktemp/rm existing in the
* remote shell. This is what makes Windows remotes work.
*/
const SYNC_TEMP_PREFIX = 'subminer-sync-';
function makeSyncTempDir(mkdtempSync: SyncFlowDeps['mkdtempSync']): string {
return mkdtempSync(path.join(os.tmpdir(), SYNC_TEMP_PREFIX));
}
/** Only dirs directly under os.tmpdir() with the sync prefix may be removed. */
function assertRemovableSyncTempDir(target: string): string {
const resolved = path.resolve(target.trim());
const normalizeCase = (value: string) =>
process.platform === 'win32' ? value.toLowerCase() : value;
const insideTmp =
normalizeCase(path.dirname(resolved)) === normalizeCase(path.resolve(os.tmpdir()));
if (!insideTmp || !path.basename(resolved).startsWith(SYNC_TEMP_PREFIX)) {
throw new Error(`Refusing to remove a directory outside the sync temp area: ${target}`);
}
return resolved;
}
function runMakeTempMode(deps: SyncFlowDeps): void {
deps.consoleLog(makeSyncTempDir(deps.mkdtempSync));
}
function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void {
const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath);
deps.rmSync(target, { recursive: true, force: true });
}
function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string {
const override = context.args.syncDbPath.trim();
return override ? resolveCliPath(override) : deps.resolveDefaultDbPath();
}
function isTrackerDb(dbPath: string, deps: SyncFlowDeps): boolean {
const trackerDbPath = deps.resolveDefaultDbPath();
try {
return deps.realpathSync(dbPath) === deps.realpathSync(trackerDbPath);
} catch {
return dbPath === trackerDbPath;
}
}
export async function ensureTrackerQuiescentFlow(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
// elsewhere needs no guard.
if (!isTrackerDb(dbPath, deps)) return;
const daemonPid = deps.findLiveStatsDaemonPid(dbPath);
if (daemonPid !== null) {
throw new Error(
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
);
}
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
throw new Error(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
}
}
// In --json mode every line on stdout is an NDJSON event: human console output
// is silenced and events are written through the original console logger.
function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
const writeLine = deps.consoleLog;
return {
...deps,
consoleLog: () => {},
writeStdout: () => true,
emitEvent: (event) => writeLine(JSON.stringify(event)),
};
}
async function runSnapshotMode(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const outPath = resolveCliPath(context.args.syncSnapshotPath);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
message: `Snapshotting local database (${dbPath})`,
});
deps.createDbSnapshot(dbPath, outPath);
deps.emitEvent({ type: 'snapshot-created', path: outPath });
deps.consoleLog(outPath);
}
async function runMergeMode(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolveCliPath(context.args.syncMergePath);
deps.emitEvent({
type: 'stage',
stage: 'merge-local',
message: `Merging ${snapshotPath} into the local database`,
});
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(formatMergeSummary(summary));
}
function formatHostSyncDetail(
direction: 'both' | 'push' | 'pull',
pulledSummary: SyncMergeSummary | null,
): string {
if (!pulledSummary) return direction === 'push' ? 'Pushed local stats' : 'Sync complete';
const merged = `${pulledSummary.sessionsMerged} session${pulledSummary.sessionsMerged === 1 ? '' : 's'} merged`;
return direction === 'pull' ? merged : `${merged}; pushed local stats`;
}
export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps): Promise<void> {
const { args } = context;
const host = args.syncHost;
deps.assertSafeSshHost(host);
deps.consoleLog(`Checking SSH connection to ${host}...`);
let remoteCommand: string | null = null;
let remoteVersion: string | null = null;
let error: string | null = null;
const runCheck = (checkHost: string, command: string) =>
deps.runSsh(checkHost, command, {
batchMode: true,
connectTimeoutSeconds: 10,
timeoutMs: 15_000,
});
const probe = runCheck(host, 'echo subminer-check-ok');
const sshOk = probe.status === 0 && probe.stdout.includes('subminer-check-ok');
if (!sshOk) {
error = formatRemoteRunError(`Could not reach ${host} over SSH.`, probe);
} else {
deps.consoleLog('SSH connection: ok');
try {
const flavor = deps.detectRemoteShellFlavor(host, runCheck);
if (flavor !== 'posix') deps.consoleLog(`Remote platform: Windows (${flavor})`);
remoteCommand = deps.resolveRemoteSubminerCommand(
host,
args.syncRemoteCmd || null,
flavor,
runCheck,
);
const version = runCheck(host, `${remoteCommand} --version`);
remoteVersion = version.status === 0 ? version.stdout.trim() || null : null;
deps.consoleLog(
`Remote subminer: ${remoteCommand}${remoteVersion ? ` (${remoteVersion})` : ''}`,
);
} catch (resolveError) {
error = resolveError instanceof Error ? resolveError.message : String(resolveError);
}
}
const ok = sshOk && remoteCommand !== null;
deps.emitEvent({
type: 'check-result',
host,
sshOk,
remoteCommand,
remoteVersion,
ok,
error,
});
if (!ok) {
throw new Error(error ?? `Connection check failed for ${host}.`);
}
deps.consoleLog('Check passed.');
}
// The remote validates --remove-temp against its own tmpdir; this guard only
// keeps garbage output from an earlier failure out of the remote command.
function cleanupRemote(
host: string,
remoteCmd: string,
remoteTmpDir: string,
quote: (value: string) => string,
deps: SyncFlowDeps,
): void {
if (!path.posix.basename(remoteTmpDir).startsWith(SYNC_TEMP_PREFIX)) return;
deps.runSsh(host, `${remoteCmd} sync --remove-temp ${quote(remoteTmpDir)}`);
}
/**
* `sync --make-temp` prints the created dir as its last stdout line (a
* launcher wrapper may log above it). Backslashes are normalized to forward
* slashes: scp, the remote SubMiner, and Windows itself all accept them, and
* it keeps the later `${dir}/file` compositions valid on every platform.
*/
function parseRemoteTempDir(stdout: string): string {
const lines = stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const candidate = (lines[lines.length - 1] ?? '').replaceAll('\\', '/');
return path.posix.basename(candidate).startsWith(SYNC_TEMP_PREFIX) ? candidate : '';
}
function parseRemoteMergeSummary(stdout: string): SyncMergeSummary | null {
for (const line of stdout.split('\n')) {
const event = parseSyncProgressLine(line);
if (event?.type === 'merge-summary' && event.target === 'local') return event.summary;
}
return null;
}
function formatRemoteRunError(message: string, run: RemoteRunResult): string {
const stderr = run.stderr.trim();
return stderr ? `${message}\n${stderr}` : message;
}
export async function runHostSync(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
const { args } = context;
const host = args.syncHost;
const direction = args.syncDirection ?? 'both';
const shouldPull = direction !== 'push';
const shouldPush = direction !== 'pull';
deps.assertSafeSshHost(host);
await deps.ensureTrackerQuiescent(context, dbPath);
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor);
const quote = (value: string) => quoteForRemoteShell(flavor, value);
if (args.logLevel === 'debug') {
console.error(`Remote subminer command (${flavor}): ${remoteCmd}`);
}
const localTmpDir = makeSyncTempDir(deps.mkdtempSync);
let remoteTmpDir = '';
let pulledSummary: SyncMergeSummary | null = null;
try {
// Signal failures by throwing (not fail(), which exits synchronously and
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
// main().catch() reports the message the same way fail() would.
const mktemp = deps.runSsh(host, `${remoteCmd} sync --make-temp`);
remoteTmpDir = mktemp.status === 0 ? parseRemoteTempDir(mktemp.stdout) : '';
if (!remoteTmpDir) {
throw new Error(
formatRemoteRunError(`Could not create a temporary directory on ${host}.`, mktemp),
);
}
const forceFlag = args.syncForce ? ' --force' : '';
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
if (shouldPush) {
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
message: `Snapshotting local database (${dbPath})`,
});
deps.createDbSnapshot(dbPath, localSnapshot);
}
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
if (shouldPull) {
deps.consoleLog(`Snapshotting ${host}...`);
deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` });
const snapshotRun = deps.runSsh(
host,
`${remoteCmd} sync --snapshot ${quote(remoteSnapshot)}${forceFlag}`,
);
if (snapshotRun.status !== 0) {
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
}
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
if (shouldPull) {
deps.emitEvent({
type: 'stage',
stage: 'download',
message: `Copying snapshot from ${host}`,
});
deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
}
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
if (shouldPush) {
deps.emitEvent({ type: 'stage', stage: 'upload', message: `Copying snapshot to ${host}` });
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
}
if (shouldPull) {
deps.consoleLog(`\nMerging ${host} -> local:`);
deps.emitEvent({
type: 'stage',
stage: 'merge-local',
message: `Merging ${host} into the local database`,
});
await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
pulledSummary = summary;
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(formatMergeSummary(summary));
}
if (shouldPush) {
deps.consoleLog(`\nMerging local -> ${host}:`);
deps.emitEvent({
type: 'stage',
stage: 'merge-remote',
message: `Merging the local database into ${host}`,
});
await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh(
host,
`${remoteCmd} sync --merge ${quote(incomingSnapshot)}${forceFlag}${args.syncJson ? ' --json' : ''}`,
);
deps.writeStdout(mergeRun.stdout);
const remoteSummary = args.syncJson ? parseRemoteMergeSummary(mergeRun.stdout) : null;
if (remoteSummary) {
deps.emitEvent({ type: 'merge-summary', target: 'remote', summary: remoteSummary });
} else if (mergeRun.stdout.trim()) {
deps.emitEvent({ type: 'remote-output', text: mergeRun.stdout });
}
if (mergeRun.status !== 0) {
const retryCommand =
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
const localUpdate = shouldPull ? ' The local database was updated;' : '';
throw new Error(
formatRemoteRunError(
`Remote merge failed on ${host}.${localUpdate} re-run "${retryCommand}" once the remote issue is fixed.`,
mergeRun,
),
);
}
}
deps.consoleLog('\nSync complete.');
deps.recordHostSyncResult(host, 'success', formatHostSyncDetail(direction, pulledSummary));
} catch (error) {
try {
deps.recordHostSyncResult(
host,
'error',
error instanceof Error ? error.message : String(error),
);
} catch {
// best effort
}
throw error;
} finally {
deps.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
try {
cleanupRemote(host, remoteCmd, remoteTmpDir, quote, deps);
} catch {
// best effort
}
}
}
}
export async function runSyncFlow(
context: SyncFlowContext,
inputDeps: SyncFlowDeps,
): Promise<void> {
let deps = inputDeps;
const { args } = context;
if (args.syncJson) deps = withJsonEvents(deps);
try {
if (args.syncMakeTemp) {
runMakeTempMode(deps);
} else if (args.syncRemoveTempPath) {
runRemoveTempMode(context, deps);
} else {
const dbPath = resolveSyncDbPath(context, deps);
if (args.syncCheck) {
await runCheckMode(context, deps);
} else if (args.syncSnapshotPath) {
await runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
await runHostSync(context, dbPath, deps);
} else {
throw new Error('sync requires a host, --snapshot <file>, or --merge <file>.');
}
}
} catch (error) {
if (args.syncJson) {
deps.emitEvent({
type: 'result',
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
throw error;
}
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
}
+61
View File
@@ -0,0 +1,61 @@
import fs from 'node:fs';
export interface SyncDbOpenOptions {
readonly?: boolean;
/**
* Read-write open. The libsql opener treats "not readonly" as read-write,
* but the launcher's bun:sqlite history reader passes these options straight
* to `new Database(...)`, where an explicit readwrite flag is what prevents
* bun from defaulting to readwrite+create. Keep it.
*/
readwrite?: boolean;
create?: boolean;
}
/**
* Opening a WAL-mode SQLite database strictly read-only fails when the -shm
* file is missing or stale (the reader must be able to create it). Retry such
* failures with a read-write handle; the caller still only issues reads.
*/
export function withReadonlyWalRetry<T>(
dbPath: string,
query: (options: SyncDbOpenOptions) => T,
): T {
try {
return query({ readonly: true });
} catch (error) {
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
return query({ readwrite: true, create: false });
}
}
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
if (!isWalModeSqliteDatabase(dbPath)) return false;
const code =
typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const message = error instanceof Error ? error.message : String(error);
const text = `${code} ${message}`.toLowerCase();
return (
text.includes('readonly') ||
text.includes('read-only') ||
text.includes('attempt to write a readonly database') ||
text.includes('sqlite_cantopen') ||
text.includes('unable to open database file')
);
}
function isWalModeSqliteDatabase(dbPath: string): boolean {
const header = Buffer.alloc(20);
let fd: number | null = null;
try {
fd = fs.openSync(dbPath, 'r');
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
} catch {
return false;
} finally {
if (fd !== null) fs.closeSync(fd);
}
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
}
+7
View File
@@ -180,6 +180,13 @@ test('direct runtime commands forward through app control', () => {
test('entry-only and internal commands do not forward through app control', () => {
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe'], {}), false);
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--help'], {}), false);
assert.equal(
shouldForwardStartupArgvViaAppControl(
['SubMiner.exe', '--sync-cli', 'sync', 'media-box', '--json'],
{},
),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--generate-config'], {}),
false,
+2
View File
@@ -158,6 +158,8 @@ export function shouldForwardStartupArgvViaAppControl(
const args = parseCliArgs(argv);
if (args.help || args.appPing || args.launchMpv || args.generateConfig) return false;
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
// Sync CLI argv is handled headless at entry, never forwarded to a running app.
if (argv.includes('--sync-cli')) return false;
return hasExplicitCommand(args);
}
+5
View File
@@ -31,6 +31,7 @@ import {
} from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { handleSyncCliAtEntry } from './main/sync-cli';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
@@ -222,6 +223,10 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
}
async function runEntryProcess(): Promise<void> {
// Headless sync CLI: must run first (its own --help/--version handling) and
// exit before app.whenReady() so it works over SSH with no display server.
if (await handleSyncCliAtEntry(process.argv, process.env, app.getVersion())) return;
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeHelpEnv(process.env);
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
+82
View File
@@ -530,8 +530,14 @@ import {
createCreateConfigSettingsWindowHandler,
createCreateFirstRunSetupWindowHandler,
createCreateJellyfinSetupWindowHandler,
createCreateSyncUiWindowHandler,
} from './main/runtime/setup-window-factory';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
import { createSyncAutoScheduler } from './main/runtime/sync-auto-scheduler';
import { resolveSyncLauncherCommand, runSyncLauncher } from './main/runtime/sync-launcher-client';
import { getSyncHostsPath } from './shared/sync/sync-hosts-store';
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
import {
isSameYoutubeMediaPath,
@@ -903,6 +909,7 @@ const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
return [
appState.configSettingsWindow,
appState.syncUiWindow,
appState.yomitanSettingsWindow,
appState.anilistSetupWindow,
appState.jellyfinSetupWindow,
@@ -2233,6 +2240,75 @@ const configSettingsRuntime = createConfigSettingsRuntime({
configSettingsRuntime.registerHandlers();
const openConfigSettingsWindow = () => configSettingsRuntime.openWindow();
const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({
getSettingsWindow: () => appState.syncUiWindow,
setSettingsWindow: (window) => {
appState.syncUiWindow = window as BrowserWindow | null;
},
createSettingsWindow: createCreateSyncUiWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
preloadPath: path.join(__dirname, 'preload-syncui.js'),
}),
settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
onClosed: () => {
// requestAppQuit (not app.quit) so the forced-exit fallback covers a
// stalled quit; a standalone sync window is the app's only reason to live.
if (appState.initialArgs?.syncWindow) requestAppQuit();
},
log: (message) => logger.error(message),
});
const openSyncUiWindow = () => openSyncUiWindowHandler();
const syncUiRuntime = createSyncUiRuntime({
ipcMain,
hostsFilePath: getSyncHostsPath(USER_DATA_PATH),
snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'),
getDbPath: () => {
const configured = getResolvedConfig().immersionTracking?.dbPath?.trim();
return configured || DEFAULT_IMMERSION_DB_PATH;
},
resolveLauncherCommand: () =>
resolveSyncLauncherCommand({
execPath: process.execPath,
appPath: app.isPackaged ? null : app.getAppPath(),
}),
runLauncher: runSyncLauncher,
getWindow: () => appState.syncUiWindow,
pickSnapshotFile: async () => {
const result = await dialog.showOpenDialog({
title: 'Choose a SubMiner stats snapshot',
filters: [{ name: 'SQLite snapshots', extensions: ['sqlite'] }],
properties: ['openFile'],
});
return result.canceled ? null : (result.filePaths[0] ?? null);
},
revealPath: (targetPath) => shell.showItemInFolder(targetPath),
nowMs: () => Date.now(),
log: (message) => logger.info(message),
notify: (payload) =>
showOverlayNotification({
title: payload.title,
body: payload.body,
variant: payload.variant,
}),
});
syncUiRuntime.registerHandlers();
// Live sync uses a consistent WAL snapshot plus a transactional merge, and it
// skips unfinished sessions, so resident stats writers do not block scheduling.
const syncAutoScheduler = createSyncAutoScheduler({
readState: () => syncUiRuntime.readState(),
isRunning: () => syncUiRuntime.isRunning(),
triggerHostSync: (host, direction) => {
void syncUiRuntime.runHostSync({ host, direction }, { notify: true });
},
nowMs: () => Date.now(),
log: (message) => logger.info(message),
});
syncAutoScheduler.start();
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
platform: process.platform,
dirname: __dirname,
@@ -3893,6 +3969,10 @@ const {
annotationSubtitleWsService.stop();
},
stopTexthookerService: () => texthookerService.stop(),
stopSyncAutoScheduler: async () => {
syncAutoScheduler.stop();
await syncUiRuntime.shutdown();
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
@@ -6006,6 +6086,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
openSyncUiWindow: () => openSyncUiWindow(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
@@ -6254,6 +6335,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
openSyncUiWindow: () => openSyncUiWindow(),
exportLogs: () => {
void exportLogsFromTray();
},
+1 -1
View File
@@ -13,7 +13,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
+2
View File
@@ -50,6 +50,7 @@ export interface CliCommandRuntimeServiceContext {
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -135,6 +136,7 @@ function createCliCommandDepsFromContext(
openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp,
+2
View File
@@ -210,6 +210,7 @@ export interface CliCommandRuntimeServiceDepsParams {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
@@ -414,6 +415,7 @@ export function createCliCommandRuntimeServiceDeps(
openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
printHelp: params.ui.printHelp,
@@ -16,6 +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');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -47,7 +50,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 33);
assert.equal(calls.length, 34);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -68,6 +71,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
+4 -1
View File
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void;
@@ -33,7 +34,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
return (): Promise<void> => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -70,6 +72,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService();
return Promise.resolve(stopSyncAutoScheduler);
};
}
@@ -19,6 +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');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -113,6 +116,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => ({
@@ -173,6 +177,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
@@ -73,6 +74,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
stopSyncAutoScheduler: () => deps.stopSyncAutoScheduler(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -74,6 +74,7 @@ test('build cli command context deps maps handlers and values', () => {
},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
printHelp: () => calls.push('help'),
@@ -48,6 +48,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -76,6 +76,7 @@ test('cli command context factory composes main deps and context handlers', () =
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -105,6 +105,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
},
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
@@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
@@ -58,6 +58,7 @@ function createDeps() {
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+2
View File
@@ -53,6 +53,7 @@ export type CliCommandContextFactoryDeps = {
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -134,6 +135,7 @@ export function createCliCommandContext(
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
+1 -1
View File
@@ -153,7 +153,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
};
}
function resolveLauncherResourcePath(options: CommonOptions): string {
export function resolveLauncherResourcePath(options: CommonOptions): string {
const platformPath = pathModuleFor(platformOf(options));
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath =
@@ -52,6 +52,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
@@ -45,13 +45,14 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
createSettingsWindow: () => created,
settingsHtmlPath: '/tmp/settings.html',
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
onClosed: () => calls.push('on-closed'),
});
assert.equal(open(), true);
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']);
assert.ok(handlers.closed);
handlers.closed();
assert.equal(calls.at(-1), 'set:null');
assert.deepEqual(calls.slice(-2), ['set:null', 'on-closed']);
});
test('createOpenConfigSettingsWindowHandler clears failed load window state', async () => {
@@ -13,6 +13,7 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
onClosed?: () => void;
log?: (message: string) => void;
}
@@ -42,6 +43,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
deps.setSettingsWindow(window);
window.on('closed', () => {
deps.setSettingsWindow(null);
deps.onClosed?.();
});
showAndFocus(window);
return true;
@@ -31,6 +31,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
+15
View File
@@ -84,3 +84,18 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
backgroundColor: '#24273a',
});
}
export function createCreateSyncUiWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
preloadPath: string;
}) {
return createSetupWindowHandler(deps, {
width: 880,
height: 720,
title: 'SubMiner Sync',
show: false,
resizable: true,
preloadPath: deps.preloadPath,
backgroundColor: '#24273a',
});
}
@@ -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,
@@ -0,0 +1,139 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createDefaultSyncHostsState,
upsertSyncHost,
recordSyncResult,
type SyncHostsState,
} from '../../shared/sync/sync-hosts-store';
import { createSyncAutoScheduler } from './sync-auto-scheduler';
function makeState(): SyncHostsState {
let state = createDefaultSyncHostsState();
state = upsertSyncHost(state, { host: 'auto-box', autoSync: true, direction: 'both' }, 0);
state = upsertSyncHost(state, { host: 'manual-box', autoSync: false }, 0);
return state;
}
test('tick triggers a due auto-sync host with its saved direction', () => {
let state = makeState();
state = upsertSyncHost(state, { host: 'auto-box', autoSync: true, direction: 'pull' }, 0);
const triggered: Array<{ host: string; direction: string }> = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host, direction) => triggered.push({ host, direction }),
nowMs: () => 100 * 60_000,
});
scheduler.tick();
assert.deepEqual(triggered, [{ host: 'auto-box', direction: 'pull' }]);
});
test('tick does not require the resident app and stats writer to stop', () => {
const state = makeState();
const triggered: string[] = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
assert.doesNotThrow(() => scheduler.tick());
assert.deepEqual(triggered, ['auto-box']);
});
test('tick skips hosts synced more recently than the interval', () => {
let state = makeState();
state = recordSyncResult(state, 'auto-box', {
atMs: 90 * 60_000,
status: 'success',
detail: null,
});
const triggered: string[] = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000, // 10 minutes later, default interval 30
});
scheduler.tick();
assert.deepEqual(triggered, []);
});
test('tick does nothing while a run is active', () => {
const state = makeState();
const triggered: string[] = [];
const running = createSyncAutoScheduler({
readState: () => state,
isRunning: () => true,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
running.tick();
assert.deepEqual(triggered, []);
});
test('tick triggers at most one host per tick', () => {
let state = makeState();
state = upsertSyncHost(state, { host: 'second-box', autoSync: true }, 0);
const triggered: string[] = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
scheduler.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,
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;
let clearCalls = 0;
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: () => {},
nowMs: () => 0,
setIntervalFn: ((): NodeJS.Timeout => {
setCalls += 1;
return 42 as unknown as NodeJS.Timeout;
}) as unknown as typeof setInterval,
clearIntervalFn: (() => {
clearCalls += 1;
}) as unknown as typeof clearInterval,
});
scheduler.start();
scheduler.start(); // idempotent
assert.equal(setCalls, 1);
scheduler.stop();
assert.equal(clearCalls, 1);
});
+55
View File
@@ -0,0 +1,55 @@
import type { SyncDirection, SyncHostsState } from '../../shared/sync/sync-hosts-store';
export interface SyncAutoSchedulerDeps {
readState: () => SyncHostsState;
isRunning: () => boolean;
triggerHostSync: (host: string, direction: SyncDirection) => void;
nowMs: () => number;
log?: (message: string) => void;
tickIntervalMs?: number;
setIntervalFn?: typeof setInterval;
clearIntervalFn?: typeof clearInterval;
}
const DEFAULT_TICK_INTERVAL_MS = 60_000;
export function createSyncAutoScheduler(deps: SyncAutoSchedulerDeps) {
let timer: NodeJS.Timeout | null = null;
function tick(): void {
if (deps.isRunning()) return;
const state = deps.readState();
const intervalMs = state.autoSyncIntervalMinutes * 60_000;
const now = deps.nowMs();
// A failed sync still updates lastSyncAtMs, so errors retry on the next
// interval instead of every tick.
const due = state.hosts.find(
(entry) =>
entry.autoSync && (entry.lastSyncAtMs === null || now - entry.lastSyncAtMs >= intervalMs),
);
if (!due) return;
deps.log?.(`Auto-sync starting for ${due.host} (${due.direction})`);
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 {
if (timer !== null) return;
const setIntervalFn = deps.setIntervalFn ?? setInterval;
timer = setIntervalFn(tick, deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS);
}
function stop(): void {
if (timer === null) return;
const clearIntervalFn = deps.clearIntervalFn ?? clearInterval;
clearIntervalFn(timer);
timer = null;
}
return { tick, start, stop };
}
@@ -0,0 +1,330 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import {
runSyncLauncher,
sanitizeSyncLauncherEnv,
type SyncLauncherSpawn,
} from './sync-launcher-client';
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
killed = false;
kill(): boolean {
this.killed = true;
this.emit('close', null, 'SIGTERM');
return true;
}
}
function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; commands: string[][] } {
const children: FakeChild[] = [];
const commands: string[][] = [];
const spawn: SyncLauncherSpawn = (command, args) => {
commands.push([command, ...args]);
const child = new FakeChild();
children.push(child);
return child as never;
};
return { spawn, children, commands };
}
test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => {
const { spawn, children, commands } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
child.stdout.emit('data', Buffer.from('{"type":"stage","stage":"snapshot-'));
child.stdout.emit('data', Buffer.from('local","message":"Snapshotting"}\n{"type":"result",'));
child.stdout.emit('data', Buffer.from('"ok":true,"error":null}\n'));
child.emit('close', 0, null);
const result = await handle.done;
assert.deepEqual(commands[0], ['subminer', 'sync', 'media-box', '--json']);
assert.equal(events.length, 2);
assert.equal(events[0]!.type, 'stage');
assert.deepEqual(events[1], { type: 'result', ok: true, error: null });
assert.deepEqual(result, { ok: true, error: null });
});
test('runSyncLauncher preserves multibyte characters split across chunks', async () => {
const { spawn, children } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
const line = Buffer.from('{"type":"result","ok":false,"error":"進捗の同期に失敗"}\n');
// Split mid-character: the boundary lands inside a multibyte code point.
const boundary = line.indexOf(Buffer.from('進')) + 1;
child.stdout.emit('data', line.subarray(0, boundary));
child.stdout.emit('data', line.subarray(boundary));
child.emit('close', 1, null);
const result = await handle.done;
assert.deepEqual(events.at(-1), {
type: 'result',
ok: false,
error: '進捗の同期に失敗',
});
assert.equal(result.error, '進捗の同期に失敗');
});
test('runSyncLauncher settles after exit when close never arrives', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.stderr.emit('data', Buffer.from('[ERROR] remote refused\n'));
// The child is gone, but a descendant still holds the inherited stdio pipes,
// so `close` never fires.
child.emit('exit', 1, null);
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /remote refused/);
});
test('runSyncLauncher reports failures with the result event error or stderr tail', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.stdout.emit(
'data',
Buffer.from('{"type":"result","ok":false,"error":"Remote merge failed"}\n'),
);
child.stderr.emit('data', Buffer.from('[ERROR] Remote merge failed\n'));
child.emit('close', 1, null);
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /Remote merge failed/);
});
test('runSyncLauncher falls back to stderr when no result event arrives', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', '--snapshot', '/tmp/x.sqlite', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.stderr.emit('data', Buffer.from('[ERROR] boom\n'));
child.emit('close', 1, null);
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /boom/);
});
test('runSyncLauncher settles on exit when the terminal event is already parsed', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
});
children[0]!.stdout.emit(
'data',
Buffer.from('{"type":"result","ok":true,"error":null}\n'),
);
children[0]!.emit('exit', 0, null);
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: true, error: null });
});
test('runSyncLauncher waits for terminal NDJSON when exit precedes final stdout data', async () => {
const { spawn, children } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
let settled = false;
void handle.done.then(() => {
settled = true;
});
child.emit('exit', 0, null);
await Promise.resolve();
assert.equal(settled, false);
child.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n'));
assert.deepEqual(await handle.done, { ok: true, error: null });
assert.deepEqual(events.at(-1), { type: 'result', ok: true, error: null });
});
test('runSyncLauncher cancel kills the child and resolves as cancelled', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
handle.cancel();
assert.equal(child.killed, true);
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.ok(result);
assert.equal(result.ok, false);
assert.match(result.error ?? '', /cancel/i);
});
test('runSyncLauncher cancel settles a child that already exited without a result', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
// The child is gone, but a descendant still holds the inherited stdio pipes,
// so `close` never arrives.
child.kill = () => {
child.killed = true;
return true;
};
child.emit('exit', null, 'SIGTERM');
handle.cancel();
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync cancelled.' });
assert.equal(child.killed, false);
});
test('runSyncLauncher times out a child that already exited without a result', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
timeoutMs: 1,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
return true;
};
child.emit('exit', null, 'SIGTERM');
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
assert.equal(child.killed, false);
});
test('runSyncLauncher times out a child that never completes', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
timeoutMs: 1,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.equal(child.killed, true);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
});
test('runSyncLauncher surfaces spawn errors', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
children[0]!.emit('error', new Error('ENOENT: subminer not found'));
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /ENOENT/);
});
test('resolveSyncLauncherCommand self-spawns the app in --sync-cli mode', async () => {
const { resolveSyncLauncherCommand } = await import('./sync-launcher-client');
const packaged = resolveSyncLauncherCommand({
execPath: '/opt/SubMiner/subminer-app',
appPath: null,
});
assert.deepEqual(packaged, ['/opt/SubMiner/subminer-app', '--sync-cli']);
const dev = resolveSyncLauncherCommand({
execPath: '/usr/bin/electron',
appPath: '/home/u/SubMiner',
});
assert.deepEqual(dev, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']);
});
test('sanitizeSyncLauncherEnv removes transported parent startup arguments', () => {
const parentEnv = {
PATH: '/usr/bin',
ELECTRON_RUN_AS_NODE: '1',
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--start',
SUBMINER_APP_ARG_1: '--background',
};
assert.deepEqual(sanitizeSyncLauncherEnv(parentEnv), { PATH: '/usr/bin' });
assert.equal(parentEnv.SUBMINER_APP_ARGC, '2');
});
+221
View File
@@ -0,0 +1,221 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args';
/** How long a cancelled sync child gets to exit on SIGTERM before SIGKILL. */
const CANCEL_GRACE_MS = 5000;
/**
* How long a child that exited without terminal NDJSON gets to flush its
* remaining stdout before the exit is treated as authoritative.
*/
const EXIT_DRAIN_MS = 2000;
export interface SyncLauncherChildLike {
stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
stderr: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
on(event: 'close', listener: (code: number | null, signal: string | null) => void): unknown;
on(event: 'exit', listener: (code: number | null, signal: string | null) => void): unknown;
on(event: 'error', listener: (error: Error) => void): unknown;
kill(signal?: NodeJS.Signals): boolean;
}
export type SyncLauncherSpawn = (command: string, args: string[]) => SyncLauncherChildLike;
export interface SyncLauncherRunResult {
ok: boolean;
error: string | null;
}
export interface SyncLauncherRunHandle {
cancel: () => void;
done: Promise<SyncLauncherRunResult>;
}
export function sanitizeSyncLauncherEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
delete env.ELECTRON_RUN_AS_NODE;
delete env.SUBMINER_APP_ARGC;
for (const name of Object.keys(env)) {
if (name.startsWith('SUBMINER_APP_ARG_')) delete env[name];
}
return env;
}
// Sync runs in a child copy of this app in headless --sync-cli mode: same
// engine and NDJSON protocol as `subminer sync --json`, with no dependency on
// bun or an installed command-line launcher. In dev runs process.execPath is
// a bare electron binary, so the app path is passed as its entry argument.
export function resolveSyncLauncherCommand(
deps: {
execPath?: string;
appPath?: string | null;
} = {},
): string[] {
const execPath = deps.execPath ?? process.execPath;
const appPath = deps.appPath ?? null;
return appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG];
}
export function runSyncLauncher(options: {
command: string[];
args: string[];
onEvent: (event: SyncProgressEvent) => void;
onStderr?: (text: string) => void;
spawn?: SyncLauncherSpawn;
timeoutMs?: number;
}): SyncLauncherRunHandle {
const spawn =
options.spawn ??
((command, args) => {
// The child must boot as a full Electron app (its entry handles
// --sync-cli); a leaked ELECTRON_RUN_AS_NODE would turn it into node.
const env = sanitizeSyncLauncherEnv(process.env);
return nodeSpawn(command, args, { stdio: 'pipe', env });
});
const [executable, ...prefixArgs] = options.command;
const child = spawn(executable!, [...prefixArgs, ...options.args]);
let stdoutBuffer = '';
let stderrTail = '';
let resultEvent: Extract<SyncProgressEvent, { type: 'result' }> | null = null;
let terminationError: string | null = null;
let settleAfterTerminalEvent: (() => void) | null = null;
let settleAfterTermination: (() => boolean) | null = null;
// Decode incrementally: a multibyte character can straddle a chunk boundary,
// and a per-chunk toString() would corrupt it (sync payloads carry Japanese
// media titles and error detail).
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');
const decodeChunk = (decoder: StringDecoder, chunk: Buffer | string): string =>
typeof chunk === 'string' ? chunk : decoder.write(chunk);
child.stdout?.on('data', (chunk) => {
stdoutBuffer += decodeChunk(stdoutDecoder, chunk);
let newlineIndex = stdoutBuffer.indexOf('\n');
while (newlineIndex !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex);
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
const event = parseSyncProgressLine(line);
if (event) {
if (event.type === 'result') {
resultEvent = event;
settleAfterTerminalEvent?.();
}
options.onEvent(event);
}
newlineIndex = stdoutBuffer.indexOf('\n');
}
});
child.stderr?.on('data', (chunk) => {
const text = decodeChunk(stderrDecoder, chunk);
stderrTail = `${stderrTail}${text}`.slice(-4000);
options.onStderr?.(text);
});
let killTimer: ReturnType<typeof setTimeout> | null = null;
let operationTimer: ReturnType<typeof setTimeout> | null = null;
let drainTimer: ReturnType<typeof setTimeout> | null = null;
const clearTimers = (): void => {
if (killTimer !== null) clearTimeout(killTimer);
if (operationTimer !== null) clearTimeout(operationTimer);
if (drainTimer !== null) clearTimeout(drainTimer);
killTimer = null;
operationTimer = null;
drainTimer = null;
};
const done = new Promise<SyncLauncherRunResult>((resolve) => {
let settled = false;
let exitObserved = false;
let exitCode: number | null = null;
const settle = (result: SyncLauncherRunResult): void => {
if (settled) return;
settled = true;
clearTimers();
resolve(result);
};
child.on('error', (error) => {
settle({ ok: false, error: error.message });
});
const settleFromExit = (code: number | null) => {
if (terminationError) {
settle({ ok: false, error: terminationError });
return;
}
if (code === 0) {
settle({ ok: true, error: null });
return;
}
const error =
resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`);
settle({ ok: false, error });
};
settleAfterTerminalEvent = () => {
if (exitObserved) settleFromExit(exitCode);
};
settleAfterTermination = () => {
if (!exitObserved) return false;
settleFromExit(exitCode);
return true;
};
// Electron descendants can retain inherited stdio pipes after the main
// child exits, delaying `close` indefinitely. Once terminal NDJSON has
// arrived, `exit` is authoritative; keep `close` as the fallback.
child.on('exit', (code) => {
exitObserved = true;
exitCode = code;
if (resultEvent || terminationError) {
settleFromExit(code);
return;
}
// No terminal event yet: stdout may still be flushing, so give it a
// bounded window rather than waiting on a `close` that retained pipes
// can withhold forever.
drainTimer = setTimeout(() => {
drainTimer = null;
settleFromExit(code);
}, EXIT_DRAIN_MS);
drainTimer.unref?.();
});
child.on('close', settleFromExit);
});
// A sync child blocked on an ssh password prompt may ignore SIGTERM, so
// escalate to SIGKILL after a grace period.
const terminate = (error: string): void => {
if (terminationError) return;
terminationError = error;
// A child that already exited without terminal NDJSON is still waiting on
// `close`, which descendants holding inherited pipes can delay
// indefinitely. There is nothing left to signal, so settle now instead of
// leaving `done` (and the quit-time shutdown that awaits it) pending.
if (settleAfterTermination?.()) return;
killTimer = setTimeout(() => {
killTimer = null;
try {
child.kill('SIGKILL');
} catch {
// process may already be gone
}
}, CANCEL_GRACE_MS);
killTimer.unref?.();
try {
child.kill('SIGTERM');
} catch {
// process may already be gone
}
};
if (options.timeoutMs !== undefined) {
operationTimer = setTimeout(() => terminate('Sync operation timed out.'), options.timeoutMs);
operationTimer.unref?.();
}
return {
cancel: () => terminate('Sync cancelled.'),
done,
};
}
+23
View File
@@ -0,0 +1,23 @@
import {
readSyncHostsState,
writeSyncHostsState,
type SyncHostsState,
} from '../../shared/sync/sync-hosts-store';
interface SyncUiHostsStateDeps {
hostsFilePath: string;
broadcastStateChanged: () => void;
}
export function createSyncUiHostsState(deps: SyncUiHostsStateDeps) {
function readState(): SyncHostsState {
return readSyncHostsState(deps.hostsFilePath);
}
function writeState(state: SyncHostsState): void {
writeSyncHostsState(deps.hostsFilePath, state);
deps.broadcastStateChanged();
}
return { readState, writeState };
}
+63
View File
@@ -0,0 +1,63 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { removeSyncHost, upsertSyncHost } from '../../shared/sync/sync-hosts-store';
import type {
SyncUiHostUpdateRequest,
SyncUiRunRequest,
SyncUiSnapshot,
SyncUiStartResult,
} from '../../types/sync-ui';
interface SyncUiIpcHandlersDeps {
ipcMain: {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
nowMs: () => number;
revealPath: (targetPath: string) => void;
pickSnapshotFile: () => Promise<string | null>;
getSnapshot: () => SyncUiSnapshot;
readState: () => SyncUiSnapshot['hosts'];
writeState: (state: SyncUiSnapshot['hosts']) => void;
runHostSync: (request: SyncUiRunRequest) => SyncUiStartResult;
cancelRun: () => boolean;
checkHost: (host: string) => unknown;
createSnapshot: () => SyncUiStartResult;
mergeSnapshotFile: (filePath: string, force?: boolean) => SyncUiStartResult;
deleteSnapshot: (filePath: string) => void;
}
export function registerSyncUiIpcHandlers(deps: SyncUiIpcHandlersDeps): void {
const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => deps.getSnapshot());
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
deps.writeState(
upsertSyncHost(deps.readState(), update as SyncUiHostUpdateRequest, deps.nowMs()),
);
});
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
deps.writeState(removeSyncHost(deps.readState(), String(host)));
});
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
const value = Number(minutes);
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
}
deps.writeState({ ...deps.readState(), autoSyncIntervalMinutes: Math.floor(value) });
});
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
deps.runHostSync(request as SyncUiRunRequest),
);
deps.ipcMain.handle(channels.syncUiCancelRun, () => deps.cancelRun());
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => deps.checkHost(String(host)));
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => deps.createSnapshot());
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
deps.mergeSnapshotFile(String(filePath), force === true),
);
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
deps.deleteSnapshot(String(filePath));
});
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
deps.revealPath(String(filePath));
return true;
});
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
}
+183
View File
@@ -0,0 +1,183 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { isValidSyncHost } from '../../shared/sync/sync-hosts-store';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import type {
SyncUiCheckResult,
SyncUiRunKind,
SyncUiRunState,
SyncUiStartResult,
} from '../../types/sync-ui';
import {
runSyncLauncher,
type SyncLauncherRunHandle,
type SyncLauncherRunResult,
} from './sync-launcher-client';
interface ActiveRun {
id: number;
kind: SyncUiRunKind;
host: string | null;
handle: SyncLauncherRunHandle;
resultSeen: boolean;
completion?: Promise<void>;
}
interface SyncUiRunLifecycleDeps {
runLauncher: typeof runSyncLauncher;
resolveLauncherCommand: () => string[];
log?: (message: string) => void;
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
sendToWindow: (channel: string, payload?: unknown) => void;
broadcastStateChanged: () => void;
}
export function createSyncUiRunLifecycle(deps: SyncUiRunLifecycleDeps) {
let runCounter = 0;
let currentRun: ActiveRun | null = null;
function launchRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: {
notify?: boolean;
timeoutMs?: number;
emitProgress?: boolean;
onEvent?: (event: SyncProgressEvent) => void;
} = {},
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
if (currentRun) {
return {
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
done: null,
};
}
const emitProgress = options.emitProgress ?? true;
runCounter += 1;
const runId = runCounter;
const run: ActiveRun = {
id: runId,
kind,
host,
resultSeen: false,
handle: deps.runLauncher({
command: deps.resolveLauncherCommand(),
args,
timeoutMs: options.timeoutMs,
onEvent: (event: SyncProgressEvent) => {
if (event.type === 'result') run.resultSeen = true;
options.onEvent?.(event);
if (emitProgress) {
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
}
},
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
}),
};
currentRun = run;
run.completion = run.handle.done
.then((result: SyncLauncherRunResult) => {
if (currentRun?.id === runId) currentRun = null;
if (emitProgress && !run.resultSeen) {
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
runId,
kind,
host,
event: { type: 'result', ok: result.ok, error: result.error },
});
}
deps.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 { start: { started: true, runId, reason: null }, done: run.handle.done };
}
function startRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: { notify?: boolean } = {},
): SyncUiStartResult {
return launchRun(kind, host, args, options).start;
}
function checkHost(host: string): Promise<SyncUiCheckResult> {
const trimmed = host.trim();
const failed = (error: string): SyncUiCheckResult => ({
host: trimmed,
sshOk: false,
remoteCommand: null,
remoteVersion: null,
ok: false,
error,
});
if (!isValidSyncHost(trimmed)) {
return Promise.resolve(failed(`Invalid sync host: ${host}`));
}
let checkResult: SyncUiCheckResult | null = null;
const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], {
timeoutMs: 30_000,
emitProgress: false,
onEvent: (event) => {
if (event.type === 'check-result') {
checkResult = {
host: event.host,
sshOk: event.sshOk,
remoteCommand: event.remoteCommand,
remoteVersion: event.remoteVersion,
ok: event.ok,
error: event.error,
};
}
},
});
if (!start.started || !done) {
return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.'));
}
return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.'));
}
function cancelRun(): boolean {
if (!currentRun) return false;
currentRun.handle.cancel();
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 runState(): SyncUiRunState {
return currentRun
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
: { running: false, runId: null, kind: null, host: null };
}
return {
startRun,
checkHost,
cancelRun,
shutdown,
runState,
isRunning: () => currentRun !== null,
};
}
+319
View File
@@ -0,0 +1,319 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import type { SyncLauncherRunHandle } from './sync-launcher-client';
import { createSyncUiRuntime, type SyncUiRuntimeDeps } from './sync-ui-runtime';
interface LauncherCall {
args: string[];
timeoutMs: number | undefined;
onEvent: (event: SyncProgressEvent) => void;
finish: (result: { ok: boolean; error: string | null }) => void;
cancelled: boolean;
}
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>();
const windowStub = {
isDestroyed: () => false,
webContents: {
send: (channel: string, payload?: unknown) => sent.push({ channel, payload }),
},
};
const deps: SyncUiRuntimeDeps = {
ipcMain: {
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, listener);
},
},
hostsFilePath: path.join(root, 'sync-hosts.json'),
snapshotsDir: path.join(root, 'snapshots'),
getDbPath: () => path.join(root, 'immersion.sqlite'),
resolveLauncherCommand: () => ['subminer'],
runLauncher: (options): SyncLauncherRunHandle => {
let finish: (result: { ok: boolean; error: string | null }) => void = () => {};
const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => {
finish = resolve;
});
const call: LauncherCall = {
args: options.args,
timeoutMs: options.timeoutMs,
onEvent: options.onEvent,
finish,
cancelled: false,
};
launcherCalls.push(call);
return {
cancel: () => {
call.cancelled = true;
finish({ ok: false, error: 'Sync cancelled.' });
},
done,
};
},
getWindow: () => windowStub,
pickSnapshotFile: async () => null,
revealPath: () => {},
nowMs: () => 1700000000000,
...overrides,
};
const runtime = createSyncUiRuntime(deps);
runtime.registerHandlers();
const invoke = (channel: string, ...args: unknown[]): unknown => {
const handler = handlers.get(channel);
assert.ok(handler, `missing handler for ${channel}`);
return handler({}, ...args);
};
return { runtime, invoke, launcherCalls, sent, handlers };
}
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-ui-test-'));
const cleanup = () => fs.rmSync(dir, { recursive: true, force: true });
const result = fn(dir);
if (result instanceof Promise) return result.finally(cleanup);
cleanup();
return result;
}
test('registerHandlers registers every sync-ui request channel', () =>
withTempDir((root) => {
const { handlers } = makeTestRig(root);
for (const channel of [
'sync-ui:get-snapshot',
'sync-ui:save-host',
'sync-ui:remove-host',
'sync-ui:set-auto-sync-interval',
'sync-ui:run-sync',
'sync-ui:cancel-run',
'sync-ui:check-host',
'sync-ui:create-snapshot',
'sync-ui:merge-snapshot-file',
'sync-ui:delete-snapshot',
'sync-ui:reveal-snapshot',
'sync-ui:pick-snapshot-file',
]) {
assert.ok(handlers.has(channel), `missing ${channel}`);
}
}));
test('save/remove host round-trips through the hosts file', () =>
withTempDir(async (root) => {
const { invoke, sent } = makeTestRig(root);
await invoke('sync-ui:save-host', {
host: 'user@laptop',
label: 'Laptop',
direction: 'pull',
autoSync: true,
});
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const saved = (await invoke('sync-ui:get-snapshot')) as {
hosts: { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
};
assert.equal(saved.hosts.hosts.length, 1);
assert.equal(saved.hosts.hosts[0]!.direction, 'pull');
assert.equal(saved.hosts.hosts[0]!.autoSync, true);
await invoke('sync-ui:remove-host', 'user@laptop');
const removed = (await invoke('sync-ui:get-snapshot')) as { hosts: { hosts: unknown[] } };
assert.equal(removed.hosts.hosts.length, 0);
}));
test('save-host rejects invalid hosts', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
await assert.rejects(
async () => invoke('sync-ui:save-host', { host: '-oProxyCommand=evil' }),
/Invalid sync host/,
);
}));
test('run-sync uses live mode, forwards progress, and rejects concurrent runs', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls, sent } = makeTestRig(root);
const start = (await invoke('sync-ui:run-sync', {
host: 'media-box',
direction: 'push',
})) as { started: boolean; runId: number };
assert.equal(start.started, true);
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--push', '--force', '--json']);
const second = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
started: boolean;
reason: string | null;
};
assert.equal(second.started, false);
assert.match(second.reason ?? '', /already running/i);
launcherCalls[0]!.onEvent({ type: 'stage', stage: 'snapshot-local', message: 'Snap' });
const progress = sent.filter((entry) => entry.channel === 'sync-ui:progress');
assert.equal(progress.length, 1);
const payload = progress[0]!.payload as { runId: number; host: string; event: unknown };
assert.equal(payload.runId, start.runId);
assert.equal(payload.host, 'media-box');
launcherCalls[0]!.finish({ ok: true, error: null });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const third = (await invoke('sync-ui:run-sync', { host: 'media-box' })) as {
started: boolean;
};
assert.equal(third.started, true);
}));
test('run-sync saves the host so previously used hosts persist', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:run-sync', { host: 'media-box', direction: 'pull' });
const snapshot = (await invoke('sync-ui:get-snapshot')) as {
hosts: { hosts: Array<{ host: string; direction: string }> };
};
assert.equal(snapshot.hosts.hosts[0]!.host, 'media-box');
assert.equal(snapshot.hosts.hosts[0]!.direction, 'pull');
launcherCalls[0]!.finish({ ok: true, error: null });
}));
test('create-snapshot runs the launcher with a timestamped path under the snapshots dir', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const start = (await invoke('sync-ui:create-snapshot')) as { started: boolean };
assert.equal(start.started, true);
const args = launcherCalls[0]!.args;
assert.equal(args[0], 'sync');
assert.equal(args[1], '--snapshot');
assert.ok(args[2]!.startsWith(path.join(root, 'snapshots')));
assert.match(args[2]!, /immersion-.*\.sqlite$/);
assert.equal(args[3], '--json');
launcherCalls[0]!.finish({ ok: true, error: null });
}));
test('delete-snapshot refuses paths outside the snapshots dir', () =>
withTempDir(async (root) => {
const { invoke, sent } = makeTestRig(root);
fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true });
const inside = path.join(root, 'snapshots', 'immersion-1.sqlite');
fs.writeFileSync(inside, 'x');
const outside = path.join(root, 'immersion.sqlite');
fs.writeFileSync(outside, 'x');
await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside));
await invoke('sync-ui:delete-snapshot', inside);
assert.equal(fs.existsSync(inside), false);
assert.equal(fs.existsSync(outside), true);
// The renderer relies on the state-changed broadcast to refresh its list.
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const snapshot = (await invoke('sync-ui:get-snapshot')) as { snapshots: unknown[] };
assert.equal(snapshot.snapshots.length, 0);
}));
test('check-host resolves with the check-result event', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{
ok: boolean;
sshOk: boolean;
}>;
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--check', '--json']);
assert.equal(launcherCalls[0]!.timeoutMs, 30_000);
launcherCalls[0]!.onEvent({
type: 'check-result',
host: 'media-box',
sshOk: true,
remoteCommand: 'subminer',
remoteVersion: '0.18.0',
ok: true,
error: null,
});
launcherCalls[0]!.finish({ ok: true, error: null });
const result = await pending;
assert.equal(result.ok, true);
assert.equal(result.sshOk, true);
}));
test('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);
await invoke('sync-ui:run-sync', { host: 'media-box' });
const cancelled = (await invoke('sync-ui:cancel-run')) as boolean;
assert.equal(cancelled, true);
assert.equal(launcherCalls[0]!.cancelled, true);
}));
test('get-snapshot lists snapshot files newest first', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
const dir = path.join(root, 'snapshots');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'immersion-old.sqlite'), 'old');
fs.writeFileSync(path.join(dir, 'ignore.txt'), 'x');
fs.writeFileSync(path.join(dir, 'immersion-new.sqlite'), 'new');
fs.utimesSync(path.join(dir, 'immersion-old.sqlite'), new Date(1000), new Date(1000));
fs.utimesSync(path.join(dir, 'immersion-new.sqlite'), new Date(2000), new Date(2000));
const snapshot = (await invoke('sync-ui:get-snapshot')) as {
snapshots: Array<{ name: string }>;
dbPath: string;
run: { running: boolean };
};
assert.deepEqual(
snapshot.snapshots.map((file) => file.name),
['immersion-new.sqlite', 'immersion-old.sqlite'],
);
assert.equal(snapshot.run.running, false);
}));
+132
View File
@@ -0,0 +1,132 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
isValidSyncHost,
upsertSyncHost,
type SyncDirection,
} from '../../shared/sync/sync-hosts-store';
import type { SyncUiRunRequest, SyncUiSnapshot } from '../../types/sync-ui';
import { createSyncUiHostsState } from './sync-ui-hosts-state';
import { registerSyncUiIpcHandlers } from './sync-ui-ipc-handlers';
import { createSyncUiRunLifecycle } from './sync-ui-run-lifecycle';
import { runSyncLauncher } from './sync-launcher-client';
import { createSyncUiSnapshots } from './sync-ui-snapshots';
interface SyncUiWindowLike {
isDestroyed(): boolean;
webContents: { send(channel: string, payload?: unknown): void };
}
export interface SyncUiRuntimeDeps {
ipcMain: {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
hostsFilePath: string;
snapshotsDir: string;
getDbPath: () => string;
resolveLauncherCommand: () => string[];
runLauncher: typeof runSyncLauncher;
getWindow: () => SyncUiWindowLike | null;
pickSnapshotFile: () => Promise<string | null>;
revealPath: (targetPath: string) => void;
nowMs: () => number;
log?: (message: string) => void;
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
}
export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
function sendToWindow(channel: string, payload?: unknown): void {
const window = deps.getWindow();
if (!window || window.isDestroyed()) return;
window.webContents.send(channel, payload);
}
function broadcastStateChanged(): void {
sendToWindow(IPC_CHANNELS.event.syncUiStateChanged);
}
const hostsState = createSyncUiHostsState({
hostsFilePath: deps.hostsFilePath,
broadcastStateChanged,
});
const runLifecycle = createSyncUiRunLifecycle({
runLauncher: deps.runLauncher,
resolveLauncherCommand: deps.resolveLauncherCommand,
log: deps.log,
notify: deps.notify,
sendToWindow,
broadcastStateChanged,
});
const snapshots = createSyncUiSnapshots({
snapshotsDir: deps.snapshotsDir,
nowMs: deps.nowMs,
startRun: runLifecycle.startRun,
broadcastStateChanged,
});
function getSnapshot(): SyncUiSnapshot {
return {
dbPath: deps.getDbPath(),
hosts: hostsState.readState(),
snapshotsDir: deps.snapshotsDir,
snapshots: snapshots.listSnapshots(),
run: runLifecycle.runState(),
};
}
function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) {
const host = request.host.trim();
if (!isValidSyncHost(host)) {
return { started: false, runId: null, reason: `Invalid sync host: ${request.host}` };
}
const direction: SyncDirection = request.direction ?? 'both';
const args = ['sync', host];
if (direction === 'push') args.push('--push');
if (direction === 'pull') args.push('--pull');
// The sync window runs inside the resident app, so waiting for all app
// writers to disappear would make manual and automatic sync impossible.
// WAL keeps snapshots consistent, transactional merges serialize with live
// writes, and unfinished sessions wait for a later sync after finalization.
args.push('--force', '--json');
const result = runLifecycle.startRun('host-sync', host, args, options);
if (result.started) {
// Persist before launcher bookkeeping lands so the host appears immediately.
hostsState.writeState(
upsertSyncHost(
hostsState.readState(),
{ host, direction: request.direction },
deps.nowMs(),
),
);
}
return result;
}
function registerHandlers(): void {
registerSyncUiIpcHandlers({
ipcMain: deps.ipcMain,
nowMs: deps.nowMs,
revealPath: deps.revealPath,
pickSnapshotFile: deps.pickSnapshotFile,
getSnapshot,
readState: hostsState.readState,
writeState: hostsState.writeState,
runHostSync,
cancelRun: runLifecycle.cancelRun,
checkHost: runLifecycle.checkHost,
createSnapshot: snapshots.createSnapshot,
mergeSnapshotFile: snapshots.mergeSnapshotFile,
deleteSnapshot: snapshots.deleteSnapshot,
});
}
return {
registerHandlers,
getSnapshot,
readState: hostsState.readState,
runHostSync,
checkHost: runLifecycle.checkHost,
cancelRun: runLifecycle.cancelRun,
shutdown: runLifecycle.shutdown,
isRunning: runLifecycle.isRunning,
};
}
+74
View File
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import type { SyncUiSnapshotFile, SyncUiStartResult } from '../../types/sync-ui';
interface SyncUiSnapshotsDeps {
snapshotsDir: string;
nowMs: () => number;
startRun: (kind: 'snapshot' | 'merge', host: null, args: string[]) => SyncUiStartResult;
broadcastStateChanged: () => void;
}
function formatSnapshotName(nowMs: number): string {
// Milliseconds prevent snapshots taken in the same second from overwriting each other.
const iso = new Date(nowMs).toISOString();
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`;
}
export function createSyncUiSnapshots(deps: SyncUiSnapshotsDeps) {
function listSnapshots(): SyncUiSnapshotFile[] {
let names: string[];
try {
names = fs.readdirSync(deps.snapshotsDir);
} catch {
return [];
}
const files: SyncUiSnapshotFile[] = [];
for (const name of names) {
if (!name.endsWith('.sqlite')) continue;
const filePath = path.join(deps.snapshotsDir, name);
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) continue;
files.push({
path: filePath,
name,
sizeBytes: stat.size,
modifiedAtMs: stat.mtimeMs,
});
} catch {
// file disappeared between readdir and stat
}
}
files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
return files;
}
function createSnapshot(): SyncUiStartResult {
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
return deps.startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']);
}
function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult {
if (!fs.existsSync(filePath)) {
return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` };
}
const args = ['sync', '--merge', filePath];
if (force) args.push('--force');
args.push('--json');
return deps.startRun('merge', null, args);
}
function deleteSnapshot(filePath: string): void {
const resolved = path.resolve(filePath);
const dir = path.resolve(deps.snapshotsDir);
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
throw new Error('Refusing to delete a file outside the snapshots directory.');
}
fs.rmSync(resolved, { force: true });
deps.broadcastStateChanged();
}
return { listSnapshots, createSnapshot, mergeSnapshotFile, deleteSnapshot };
}
@@ -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,6 +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('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -96,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',
@@ -124,6 +127,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
+5
View File
@@ -47,6 +47,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -66,6 +67,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -103,6 +105,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openConfigSettings: () => {
deps.openConfigSettingsWindow();
},
openSyncUi: () => {
deps.openSyncUiWindow();
},
exportLogs: () => {
deps.exportLogs();
},
+2
View File
@@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -57,6 +58,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettings: () => calls.push('open-configuration'),
openSyncUi: () => {},
exportLogs: () => calls.push('open-export-logs'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
+3
View File
@@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -56,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -79,6 +81,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
exportLogs: deps.exportLogs,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
@@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
+14 -6
View File
@@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
openSyncUi: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
@@ -48,7 +49,7 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 13);
assert.equal(template.length, 14);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
@@ -67,16 +68,19 @@ test('tray menu template contains expected entries and handlers', () => {
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
assert.equal(template[5]!.label, 'Open SubMiner Settings');
assert.equal(template[6]!.label, 'Export Logs');
assert.equal(template[6]!.label, 'Sync Stats && History');
template[6]!.click?.();
assert.equal(template[10]!.label, 'Check for Updates');
template[10]!.click?.();
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[12]!.click?.();
assert.equal(template[7]!.label, 'Export Logs');
template[7]!.click?.();
assert.equal(template[11]!.label, 'Check for Updates');
template[11]!.click?.();
template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[13]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'texthooker',
'sync-ui',
'export-logs',
'updates',
'separator',
@@ -95,6 +99,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -123,6 +128,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -149,6 +155,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
@@ -176,6 +183,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
+5
View File
@@ -40,6 +40,7 @@ export type TrayMenuActionHandlers = {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -103,6 +104,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open SubMiner Settings',
click: handlers.openConfigSettings,
},
{
label: 'Sync Stats && History',
click: handlers.openSyncUi,
},
{
label: 'Export Logs',
click: handlers.exportLogs,
+1 -23
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files';
import { canConnectSocket } from '../../shared/socket-probe';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import { buildMpvMsgLevel } from '../../shared/mpv-logging-args';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
@@ -93,28 +93,6 @@ async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}
async function waitForSocketReady(socketPath: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
+1 -1
View File
@@ -14,7 +14,7 @@ export interface AppLifecycleRuntimeRunnerParams {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
+2
View File
@@ -152,6 +152,7 @@ export interface AppState {
jellyfinSetupWindow: BrowserWindow | null;
firstRunSetupWindow: BrowserWindow | null;
configSettingsWindow: BrowserWindow | null;
syncUiWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null;
@@ -238,6 +239,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
jellyfinSetupWindow: null,
firstRunSetupWindow: null,
configSettingsWindow: null,
syncUiWindow: null,
yomitanParserReadyPromise: null,
yomitanParserInitPromise: null,
mpvClient: null,
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { handleSyncCliAtEntry } from './sync-cli';
test('handleSyncCliAtEntry exits through the process exit dependency', async () => {
const exits: number[] = [];
const handled = await handleSyncCliAtEntry(
['SubMiner', '--sync-cli', 'sync', '--make-temp'],
{},
'0.18.0',
{
run: async () => 7,
exit: (code) => {
exits.push(code);
},
},
);
assert.equal(handled, true);
assert.deepEqual(exits, [7]);
});
test('handleSyncCliAtEntry ignores normal GUI startup', async () => {
const handled = await handleSyncCliAtEntry(['SubMiner', '--start'], {}, '0.18.0', {
run: async () => {
throw new Error('should not run');
},
exit: () => {
throw new Error('should not exit');
},
});
assert.equal(handled, false);
});
+140
View File
@@ -0,0 +1,140 @@
import fs from 'node:fs';
import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path';
import { canConnectSocket } from '../shared/socket-probe';
import { getDefaultConfigDir } from '../shared/setup-state';
import {
extractSyncCliTokens,
parseSyncCliTokens,
syncCliUsage,
} from '../core/services/stats-sync/cli-args';
import { resolveImmersionDbPath } from '../core/services/stats-sync/db-path';
import { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared';
import { mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
import {
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
} from '../core/services/stats-sync/ssh';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
type SyncFlowDeps,
} from '../core/services/stats-sync/sync-flow';
import {
recordSyncResult,
readSyncHostsState,
writeSyncHostsState,
getSyncHostsPath,
} from '../shared/sync/sync-hosts-store';
export function shouldHandleSyncCliAtEntry(
argv: readonly string[],
env: NodeJS.ProcessEnv,
): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
return extractSyncCliTokens(argv) !== null;
}
function recordHostSyncResultToDisk(
host: string,
status: 'success' | 'error',
detail: string | null,
): void {
try {
const filePath = getSyncHostsPath(getDefaultConfigDir());
const state = recordSyncResult(readSyncHostsState(filePath), host, {
atMs: Date.now(),
status,
detail,
});
writeSyncHostsState(filePath, state);
} catch {
// best effort: bookkeeping must never fail the sync itself
}
}
function buildSyncCliDeps(): SyncFlowDeps {
const deps: SyncFlowDeps = {
createDbSnapshot,
mergeSnapshotIntoDb,
findLiveStatsDaemonPid,
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
canConnectUnixSocket: canConnectSocket,
realpathSync: (candidate) => fs.realpathSync(candidate),
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options),
consoleLog: (message) => console.log(message),
writeStdout: (text) => process.stdout.write(text),
ensureTrackerQuiescent: async (context, dbPath) =>
ensureTrackerQuiescentFlow(context, dbPath, deps),
emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk,
resolveDefaultDbPath: resolveImmersionDbPath,
};
return deps;
}
/**
* Headless sync entry for the Electron app: answers the same launcher-style
* `sync ...` argv as `subminer sync`, so a machine only needs the app
* installed to participate in stats sync (locally or as an SSH remote). Runs
* before any window/display initialization and never touches Electron APIs.
*/
export async function runSyncCliFromProcess(
argv: readonly string[],
appVersion: string,
): Promise<number> {
const tokens = extractSyncCliTokens(argv);
const parsed = parseSyncCliTokens(tokens ?? []);
if (parsed.kind === 'help') {
console.log(syncCliUsage());
return 0;
}
if (parsed.kind === 'version') {
console.log(`SubMiner ${appVersion}`);
return 0;
}
if (parsed.kind === 'error') {
console.error(parsed.message);
console.error(`\n${syncCliUsage()}`);
return 2;
}
const context = {
args: parsed.args,
mpvSocketPath: getDefaultMpvSocketPath(process.platform),
};
try {
await runSyncFlow(context, buildSyncCliDeps());
return 0;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function handleSyncCliAtEntry(
argv: readonly string[],
env: NodeJS.ProcessEnv,
appVersion: string,
deps: {
run: typeof runSyncCliFromProcess;
exit: (code: number) => void;
} = {
run: runSyncCliFromProcess,
exit: (code) => process.exit(code),
},
): Promise<boolean> {
if (!shouldHandleSyncCliAtEntry(argv, env)) return false;
const exitCode = await deps.run(argv, appVersion);
// This path runs before app readiness and must not call app.exit(): on Linux
// that can throw and make the entrypoint fall back to full GUI startup.
deps.exit(exitCode);
return true;
}
+49
View File
@@ -0,0 +1,49 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import type {
SyncUiAPI,
SyncUiCheckResult,
SyncUiHostUpdateRequest,
SyncUiProgressPayload,
SyncUiRunRequest,
SyncUiSnapshot,
SyncUiStartResult,
} from './types/sync-ui';
const syncUiAPI: SyncUiAPI = {
getSnapshot: (): Promise<SyncUiSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot),
saveHost: (update: SyncUiHostUpdateRequest): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update),
removeHost: (host: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host),
setAutoSyncInterval: (minutes: number): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSetAutoSyncInterval, minutes),
runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request),
cancelRun: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCancelRun),
checkHost: (host: string): Promise<SyncUiCheckResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCheckHost, host),
createSnapshot: (): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot),
mergeSnapshotFile: (path: string, force?: boolean): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiMergeSnapshotFile, path, force === true),
deleteSnapshot: (path: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiDeleteSnapshot, path),
revealSnapshot: (path: string): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRevealSnapshot, path),
pickSnapshotFile: (): Promise<string | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiPickSnapshotFile),
onProgress: (listener: (payload: SyncUiProgressPayload) => void): (() => void) => {
const handler = (_event: unknown, payload: SyncUiProgressPayload): void => listener(payload);
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(IPC_CHANNELS.event.syncUiStateChanged, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.syncUiStateChanged, handler);
},
};
contextBridge.exposeInMainWorld('syncUiAPI', syncUiAPI);
+14
View File
@@ -111,6 +111,18 @@ export const IPC_CHANNELS = {
getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names',
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
getConfigSettingsYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
syncUiGetSnapshot: 'sync-ui:get-snapshot',
syncUiSaveHost: 'sync-ui:save-host',
syncUiRemoveHost: 'sync-ui:remove-host',
syncUiSetAutoSyncInterval: 'sync-ui:set-auto-sync-interval',
syncUiRunSync: 'sync-ui:run-sync',
syncUiCancelRun: 'sync-ui:cancel-run',
syncUiCheckHost: 'sync-ui:check-host',
syncUiCreateSnapshot: 'sync-ui:create-snapshot',
syncUiMergeSnapshotFile: 'sync-ui:merge-snapshot-file',
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
},
event: {
subtitleSet: 'subtitle:set',
@@ -142,6 +154,8 @@ export const IPC_CHANNELS = {
configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification',
notificationHistoryToggle: 'notification-history:toggle',
syncUiProgress: 'sync-ui:progress',
syncUiStateChanged: 'sync-ui:state-changed',
},
} as const;
+28
View File
@@ -0,0 +1,28 @@
import net from 'node:net';
/**
* True when something is accepting connections on the unix socket / Windows
* named pipe (400 ms probe). Electron-free; shared by the sync CLI's
* running-app guard and the Windows mpv launch attach wait.
*/
export async function canConnectSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSyncProgressLine } from './sync-events';
test('parseSyncProgressLine parses known event types', () => {
assert.deepEqual(
parseSyncProgressLine('{"type":"stage","stage":"snapshot-local","message":"Snapshotting"}'),
{ type: 'stage', stage: 'snapshot-local', message: 'Snapshotting' },
);
const summaryLine = JSON.stringify({
type: 'merge-summary',
target: 'local',
summary: {
sessionsMerged: 2,
sessionsAlreadyPresent: 0,
activeSessionsSkipped: 0,
animeAdded: 0,
videosAdded: 0,
wordsAdded: 0,
kanjiAdded: 0,
subtitleLinesAdded: 0,
telemetryRowsAdded: 0,
eventsAdded: 0,
excludedWordsAdded: 0,
dailyRollupsCopied: 0,
monthlyRollupsCopied: 0,
rollupGroupsRecomputed: 0,
},
});
const parsed = parseSyncProgressLine(summaryLine);
assert.equal(parsed?.type, 'merge-summary');
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
type: 'result',
ok: true,
error: null,
});
});
test('parseSyncProgressLine rejects non-events and garbage', () => {
assert.equal(parseSyncProgressLine('plain text output'), null);
assert.equal(parseSyncProgressLine('{"no":"type"}'), null);
assert.equal(parseSyncProgressLine('{"type":"unknown-event"}'), null);
assert.equal(parseSyncProgressLine(''), null);
assert.equal(parseSyncProgressLine('42'), null);
});
test('parseSyncProgressLine rejects events missing required fields', () => {
// A half-formed event used to be cast straight through; the UI reads
// `.ok` / `.summary` directly and would misreport a failed sync as done.
assert.equal(parseSyncProgressLine('{"type":"result"}'), null);
assert.equal(parseSyncProgressLine('{"type":"result","ok":"yes","error":null}'), null);
assert.equal(parseSyncProgressLine('{"type":"merge-summary","target":"local"}'), null);
assert.equal(
parseSyncProgressLine('{"type":"merge-summary","target":"nowhere","summary":{}}'),
null,
);
assert.equal(parseSyncProgressLine('{"type":"stage","stage":"bogus","message":"x"}'), null);
assert.equal(parseSyncProgressLine('{"type":"snapshot-created"}'), null);
assert.equal(parseSyncProgressLine('{"type":"check-result","host":"h","sshOk":true}'), null);
// Well-formed events still parse.
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
type: 'result',
ok: true,
error: null,
});
});
+132
View File
@@ -0,0 +1,132 @@
// NDJSON progress protocol emitted by `subminer sync --json` and consumed by
// the sync UI's launcher client. Avoid Electron/bun imports because this file
// is shared between the launcher (bun) and the app main process (node).
export interface SyncMergeSummary {
sessionsMerged: number;
sessionsAlreadyPresent: number;
activeSessionsSkipped: number;
animeAdded: number;
videosAdded: number;
wordsAdded: number;
kanjiAdded: number;
subtitleLinesAdded: number;
telemetryRowsAdded: number;
eventsAdded: number;
excludedWordsAdded: number;
dailyRollupsCopied: number;
monthlyRollupsCopied: number;
rollupGroupsRecomputed: number;
}
export type SyncStage =
| 'snapshot-local'
| 'snapshot-remote'
| 'download'
| 'upload'
| 'merge-local'
| 'merge-remote';
export type SyncProgressEvent =
| { type: 'stage'; stage: SyncStage; message: string }
| { type: 'merge-summary'; target: 'local' | 'remote'; summary: SyncMergeSummary }
| { type: 'remote-output'; text: string }
| { type: 'snapshot-created'; path: string }
| {
type: 'check-result';
host: string;
sshOk: boolean;
remoteCommand: string | null;
remoteVersion: string | null;
ok: boolean;
error: string | null;
}
| { type: 'result'; ok: boolean; error: string | null };
const SUMMARY_KEYS: readonly (keyof SyncMergeSummary)[] = [
'sessionsMerged',
'sessionsAlreadyPresent',
'activeSessionsSkipped',
'animeAdded',
'videosAdded',
'wordsAdded',
'kanjiAdded',
'subtitleLinesAdded',
'telemetryRowsAdded',
'eventsAdded',
'excludedWordsAdded',
'dailyRollupsCopied',
'monthlyRollupsCopied',
'rollupGroupsRecomputed',
];
const STAGES: readonly SyncStage[] = [
'snapshot-local',
'snapshot-remote',
'download',
'upload',
'merge-local',
'merge-remote',
];
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function isMergeSummary(value: unknown): value is SyncMergeSummary {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return SUMMARY_KEYS.every((key) => typeof record[key] === 'number');
}
/**
* The events cross a process boundary (the sync child's stdout), so each variant
* is validated field-by-field: a half-formed `result` or `merge-summary` would
* otherwise be cast straight into the UI, which reads `.ok`/`.summary` directly.
*/
export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
const trimmed = line.trim();
if (!trimmed.startsWith('{')) return null;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const event = parsed as Record<string, unknown>;
switch (event.type) {
case 'stage':
return STAGES.includes(event.stage as SyncStage) && isString(event.message)
? (parsed as SyncProgressEvent)
: null;
case 'merge-summary':
return (event.target === 'local' || event.target === 'remote') && isMergeSummary(event.summary)
? (parsed as SyncProgressEvent)
: null;
case 'remote-output':
return isString(event.text) ? (parsed as SyncProgressEvent) : null;
case 'snapshot-created':
return isString(event.path) ? (parsed as SyncProgressEvent) : null;
case 'check-result':
return isString(event.host) &&
typeof event.sshOk === 'boolean' &&
typeof event.ok === 'boolean' &&
isNullableString(event.remoteCommand) &&
isNullableString(event.remoteVersion) &&
isNullableString(event.error)
? (parsed as SyncProgressEvent)
: null;
case 'result':
return typeof event.ok === 'boolean' && isNullableString(event.error)
? (parsed as SyncProgressEvent)
: null;
default:
return null;
}
}
+204
View File
@@ -0,0 +1,204 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
createDefaultSyncHostsState,
getSyncHostsPath,
isValidSyncHost,
normalizeSyncHostsState,
readSyncHostsState,
recordSyncResult,
removeSyncHost,
upsertSyncHost,
writeSyncHostsState,
} from './sync-hosts-store';
function withTempDir(fn: (dir: string) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-hosts-test-'));
try {
fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('createDefaultSyncHostsState returns empty v1 state', () => {
assert.deepEqual(createDefaultSyncHostsState(), {
version: 1,
autoSyncIntervalMinutes: 60,
hosts: [],
});
});
test('isValidSyncHost accepts ssh destinations and rejects unsafe values', () => {
assert.equal(isValidSyncHost('desktop'), true);
assert.equal(isValidSyncHost('user@10.0.0.5'), true);
assert.equal(isValidSyncHost('my-alias.local'), true);
assert.equal(isValidSyncHost(''), false);
assert.equal(isValidSyncHost(' '), false);
assert.equal(isValidSyncHost('-oProxyCommand=evil'), false);
assert.equal(isValidSyncHost('host with spaces'), false);
});
test('upsertSyncHost adds a new host with defaults and preserves fields on update', () => {
const state = createDefaultSyncHostsState();
const added = upsertSyncHost(state, { host: 'user@laptop' }, 1000);
assert.deepEqual(added.hosts, [
{
host: 'user@laptop',
label: null,
direction: 'both',
autoSync: false,
addedAtMs: 1000,
lastSyncAtMs: null,
lastSyncStatus: null,
lastSyncDetail: null,
},
]);
// original state untouched
assert.equal(state.hosts.length, 0);
const synced = recordSyncResult(added, 'user@laptop', {
atMs: 2000,
status: 'success',
detail: 'Sessions merged: 3',
});
const updated = upsertSyncHost(synced, { host: 'user@laptop', direction: 'push' }, 3000);
assert.equal(updated.hosts.length, 1);
const entry = updated.hosts[0]!;
assert.equal(entry.direction, 'push');
assert.equal(entry.addedAtMs, 1000);
assert.equal(entry.lastSyncAtMs, 2000);
assert.equal(entry.lastSyncStatus, 'success');
assert.equal(entry.lastSyncDetail, 'Sessions merged: 3');
});
test('upsertSyncHost trims host and throws on invalid host', () => {
const state = upsertSyncHost(createDefaultSyncHostsState(), { host: ' desktop ' }, 1);
assert.equal(state.hosts[0]!.host, 'desktop');
assert.throws(() => upsertSyncHost(state, { host: '-bad' }, 2));
});
test('removeSyncHost removes only the matching host', () => {
let state = createDefaultSyncHostsState();
state = upsertSyncHost(state, { host: 'a' }, 1);
state = upsertSyncHost(state, { host: 'b' }, 2);
const removed = removeSyncHost(state, 'a');
assert.deepEqual(
removed.hosts.map((entry) => entry.host),
['b'],
);
assert.equal(removeSyncHost(removed, 'missing').hosts.length, 1);
});
test('recordSyncResult upserts unknown hosts so CLI syncs are remembered', () => {
const state = recordSyncResult(createDefaultSyncHostsState(), 'user@server', {
atMs: 5000,
status: 'error',
detail: 'Remote merge failed',
});
assert.equal(state.hosts.length, 1);
const entry = state.hosts[0]!;
assert.equal(entry.host, 'user@server');
assert.equal(entry.addedAtMs, 5000);
assert.equal(entry.lastSyncAtMs, 5000);
assert.equal(entry.lastSyncStatus, 'error');
assert.equal(entry.lastSyncDetail, 'Remote merge failed');
});
test('normalizeSyncHostsState drops invalid entries and falls back to defaults', () => {
assert.deepEqual(normalizeSyncHostsState(null), createDefaultSyncHostsState());
assert.deepEqual(normalizeSyncHostsState('junk'), createDefaultSyncHostsState());
assert.deepEqual(normalizeSyncHostsState({ version: 99 }), createDefaultSyncHostsState());
const normalized = normalizeSyncHostsState({
version: 1,
autoSyncIntervalMinutes: 15,
hosts: [
{
host: 'desktop',
label: 'Desktop PC',
direction: 'pull',
autoSync: true,
addedAtMs: 10,
lastSyncAtMs: 20,
lastSyncStatus: 'success',
lastSyncDetail: 'ok',
},
{ host: '-bad', direction: 'both', autoSync: false, addedAtMs: 1 },
{ host: 'dupe', direction: 'sideways', autoSync: false, addedAtMs: 1 },
'not-an-object',
],
});
assert.equal(normalized.autoSyncIntervalMinutes, 15);
assert.deepEqual(normalized.hosts, [
{
host: 'desktop',
label: 'Desktop PC',
direction: 'pull',
autoSync: true,
addedAtMs: 10,
lastSyncAtMs: 20,
lastSyncStatus: 'success',
lastSyncDetail: 'ok',
},
{
host: 'dupe',
label: null,
direction: 'both',
autoSync: false,
addedAtMs: 1,
lastSyncAtMs: null,
lastSyncStatus: null,
lastSyncDetail: null,
},
]);
});
test('read/write round-trips state and tolerates missing or corrupt files', () => {
withTempDir((root) => {
const filePath = getSyncHostsPath(root);
assert.equal(filePath, path.join(root, 'sync-hosts.json'));
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
fs.writeFileSync(filePath, '{corrupt');
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
let state = createDefaultSyncHostsState();
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
writeSyncHostsState(filePath, state);
assert.deepEqual(readSyncHostsState(filePath), state);
});
});
test('writeSyncHostsState creates parent directories', () => {
withTempDir((root) => {
const filePath = path.join(root, 'nested', 'dir', 'sync-hosts.json');
writeSyncHostsState(filePath, createDefaultSyncHostsState());
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
});
});
test('writeSyncHostsState leaves the previous file intact when the write fails', () => {
withTempDir((root) => {
const filePath = getSyncHostsPath(root);
let state = createDefaultSyncHostsState();
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
writeSyncHostsState(filePath, state);
// A crash mid-write must not truncate the live file: readSyncHostsState
// falls back to defaults on a corrupt file, which would drop every host.
let next = upsertSyncHost(state, { host: 'user@desktop' }, 43);
assert.throws(() =>
writeSyncHostsState(filePath, next, {
renameSync: () => {
throw new Error('disk full');
},
}),
);
assert.deepEqual(readSyncHostsState(filePath), state);
assert.equal(fs.existsSync(`${filePath}.tmp`), false);
});
});
+213
View File
@@ -0,0 +1,213 @@
import fs from 'node:fs';
import path from 'node:path';
export type SyncDirection = 'both' | 'push' | 'pull';
export type SyncResultStatus = 'success' | 'error';
export interface SyncHostEntry {
host: string;
label: string | null;
direction: SyncDirection;
autoSync: boolean;
addedAtMs: number;
lastSyncAtMs: number | null;
lastSyncStatus: SyncResultStatus | null;
lastSyncDetail: string | null;
}
export interface SyncHostsState {
version: 1;
autoSyncIntervalMinutes: number;
hosts: SyncHostEntry[];
}
export interface SyncHostUpdate {
host: string;
label?: string | null;
direction?: SyncDirection;
autoSync?: boolean;
}
export interface SyncResultUpdate {
atMs: number;
status: SyncResultStatus;
detail: string | null;
}
const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES = 60;
export function createDefaultSyncHostsState(): SyncHostsState {
return {
version: 1,
autoSyncIntervalMinutes: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES,
hosts: [],
};
}
// Hosts are passed as a single spawn argument to ssh, so the only dangerous
// shapes are option injection (leading "-") and multi-token values.
export function isValidSyncHost(host: string): boolean {
const trimmed = host.trim();
return trimmed.length > 0 && !trimmed.startsWith('-') && !/\s/.test(trimmed);
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isDirection(value: unknown): value is SyncDirection {
return value === 'both' || value === 'push' || value === 'pull';
}
function normalizeHostEntry(value: unknown): SyncHostEntry | null {
const record = asObject(value);
if (!record) return null;
const host = typeof record.host === 'string' ? record.host.trim() : '';
if (!isValidSyncHost(host)) return null;
return {
host,
label: typeof record.label === 'string' && record.label.trim() ? record.label.trim() : null,
direction: isDirection(record.direction) ? record.direction : 'both',
autoSync: record.autoSync === true,
addedAtMs:
typeof record.addedAtMs === 'number' && Number.isFinite(record.addedAtMs)
? record.addedAtMs
: 0,
lastSyncAtMs:
typeof record.lastSyncAtMs === 'number' && Number.isFinite(record.lastSyncAtMs)
? record.lastSyncAtMs
: null,
lastSyncStatus:
record.lastSyncStatus === 'success' || record.lastSyncStatus === 'error'
? record.lastSyncStatus
: null,
lastSyncDetail: typeof record.lastSyncDetail === 'string' ? record.lastSyncDetail : null,
};
}
export function normalizeSyncHostsState(value: unknown): SyncHostsState {
const record = asObject(value);
if (!record || record.version !== 1) return createDefaultSyncHostsState();
const hosts = Array.isArray(record.hosts)
? record.hosts.map(normalizeHostEntry).filter((entry): entry is SyncHostEntry => entry !== null)
: [];
const interval = record.autoSyncIntervalMinutes;
return {
version: 1,
autoSyncIntervalMinutes:
typeof interval === 'number' && Number.isFinite(interval) && interval >= 1
? Math.floor(interval)
: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES,
hosts,
};
}
export function upsertSyncHost(
state: SyncHostsState,
update: SyncHostUpdate,
nowMs: number,
): SyncHostsState {
const host = update.host.trim();
if (!isValidSyncHost(host)) {
throw new Error(`Invalid sync host: ${JSON.stringify(update.host)}`);
}
const existing = state.hosts.find((entry) => entry.host === host);
const base: SyncHostEntry = existing ?? {
host,
label: null,
direction: 'both',
autoSync: false,
addedAtMs: nowMs,
lastSyncAtMs: null,
lastSyncStatus: null,
lastSyncDetail: null,
};
const next: SyncHostEntry = {
...base,
label: update.label !== undefined ? update.label : base.label,
direction: update.direction ?? base.direction,
autoSync: update.autoSync ?? base.autoSync,
};
const hosts = existing
? state.hosts.map((entry) => (entry.host === host ? next : entry))
: [...state.hosts, next];
return { ...state, hosts };
}
export function removeSyncHost(state: SyncHostsState, host: string): SyncHostsState {
return { ...state, hosts: state.hosts.filter((entry) => entry.host !== host.trim()) };
}
export function recordSyncResult(
state: SyncHostsState,
host: string,
result: SyncResultUpdate,
): SyncHostsState {
const upserted = upsertSyncHost(state, { host }, result.atMs);
const hosts = upserted.hosts.map((entry) =>
entry.host === host.trim()
? {
...entry,
lastSyncAtMs: result.atMs,
lastSyncStatus: result.status,
lastSyncDetail: result.detail,
}
: entry,
);
return { ...upserted, hosts };
}
export function getSyncHostsPath(configDir: string): string {
return path.join(configDir, 'sync-hosts.json');
}
export function readSyncHostsState(
filePath: string,
deps?: {
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
},
): SyncHostsState {
const existsSync = deps?.existsSync ?? fs.existsSync;
const readFileSync = deps?.readFileSync ?? fs.readFileSync;
if (!existsSync(filePath)) return createDefaultSyncHostsState();
try {
return normalizeSyncHostsState(JSON.parse(readFileSync(filePath, 'utf8')));
} catch {
return createDefaultSyncHostsState();
}
}
// Written via a sibling temp file + rename so an interrupted write cannot leave
// a truncated sync-hosts.json behind (readSyncHostsState would silently fall
// back to defaults, dropping every configured host).
export function writeSyncHostsState(
filePath: string,
state: SyncHostsState,
deps?: {
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
renameSync?: (from: string, to: string) => void;
rmSync?: (target: string, options: { force: true }) => void;
},
): void {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
const renameSync = deps?.renameSync ?? fs.renameSync;
const rmSync = deps?.rmSync ?? fs.rmSync;
mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
try {
renameSync(tempPath, filePath);
} catch (error) {
try {
rmSync(tempPath, { force: true });
} catch {
// best effort: the temp file is disposable
}
throw error;
}
}
+120
View File
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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'; object-src 'none'; base-uri 'self';"
/>
<title>SubMiner Sync</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="sync-shell">
<header class="sync-header">
<div class="brand-block">
<div class="brand-title">SubMiner</div>
<div class="brand-subtitle">Immersion Sync</div>
</div>
<div class="header-meta">
<div id="runPill" class="run-pill idle" role="status">Idle</div>
<div id="dbPath" class="db-path" title="Local immersion database"></div>
</div>
</header>
<div class="sync-scroll">
<section class="panel" aria-labelledby="devicesTitle">
<div class="panel-head">
<h2 id="devicesTitle">Devices</h2>
<div class="auto-interval">
<label for="autoIntervalInput">Auto-sync every</label>
<input id="autoIntervalInput" type="number" min="1" max="1440" step="1" />
<span>min</span>
</div>
</div>
<div id="hostList" class="host-list"></div>
<div id="hostListEmpty" class="empty-note hidden">
No saved devices yet. Hosts you sync from the command line show up here too.
</div>
</section>
<section class="panel" aria-labelledby="addDeviceTitle">
<div class="panel-head">
<h2 id="addDeviceTitle">Add a device</h2>
</div>
<div class="add-host-row">
<input
id="newHostInput"
class="text-input mono"
type="text"
placeholder="user@hostname or ssh alias"
spellcheck="false"
/>
<input
id="newLabelInput"
class="text-input"
type="text"
placeholder="Label (optional)"
/>
<button id="testHostButton" class="ghost-button" type="button">Test connection</button>
<button id="addHostButton" class="primary-button" type="button">Add device</button>
</div>
<div id="checkResult" class="check-result hidden"></div>
<details class="setup-help">
<summary>Setup checklist</summary>
<ol>
<li>
Both machines need SSH key access:
<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
<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.
</li>
</ol>
</details>
</section>
<section id="activityPanel" class="panel hidden" aria-labelledby="activityTitle">
<div class="panel-head">
<h2 id="activityTitle">Activity</h2>
<button id="cancelButton" class="danger-button hidden" type="button">Cancel</button>
</div>
<div id="stageList" class="stage-list"></div>
<pre id="remoteOutput" class="remote-output hidden"></pre>
<div id="resultCard" class="result-card hidden"></div>
</section>
<section class="panel" aria-labelledby="snapshotsTitle">
<div class="panel-head">
<h2 id="snapshotsTitle">Snapshots</h2>
<div class="panel-actions">
<button id="mergeFileButton" class="ghost-button" type="button">
Merge a snapshot file…
</button>
<button id="createSnapshotButton" class="primary-button" type="button">
Create snapshot
</button>
</div>
</div>
<p class="panel-hint">
A snapshot is a consistent point-in-time copy of the immersion database, handy as a
manual backup before big merges. Stored in
<span id="snapshotsDir" class="mono"></span>
</p>
<div id="snapshotList" class="snapshot-list"></div>
<div id="snapshotListEmpty" class="empty-note hidden">No snapshots yet.</div>
</section>
</div>
</main>
<script type="module" src="syncui.js"></script>
</body>
</html>
+794
View File
@@ -0,0 +1,794 @@
@font-face {
font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
}
:root {
/* Catppuccin Macchiato */
--ctp-rosewater: #f4dbd6;
--ctp-flamingo: #f0c6c6;
--ctp-pink: #f5bde6;
--ctp-mauve: #c6a0f6;
--ctp-red: #ed8796;
--ctp-maroon: #ee99a0;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-teal: #8bd5ca;
--ctp-sky: #91d7e3;
--ctp-sapphire: #7dc4e4;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext1: #b8c0e0;
--ctp-subtext0: #a5adcb;
--ctp-overlay2: #939ab7;
--ctp-overlay1: #8087a2;
--ctp-overlay0: #6e738d;
--ctp-surface2: #5b6078;
--ctp-surface1: #494d64;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
/* Semantic */
--bg: var(--ctp-base);
--panel: rgba(36, 39, 58, 0.85);
--panel-elevated: rgba(54, 58, 79, 0.55);
--line: rgba(110, 115, 141, 0.28);
--line-soft: rgba(110, 115, 141, 0.14);
--text: var(--ctp-text);
--muted: var(--ctp-subtext0);
--faint: var(--ctp-overlay1);
--accent: var(--ctp-blue);
--accent-strong: var(--ctp-lavender);
--highlight: var(--ctp-mauve);
--danger: var(--ctp-red);
--ok: var(--ctp-green);
--warn: var(--ctp-peach);
--shadow: rgba(0, 0, 0, 0.42);
--mono:
'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', ui-monospace, 'DejaVu Sans Mono',
monospace;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--ctp-base);
color: var(--text);
font-family:
'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button,
input {
font: inherit;
color: inherit;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
background-color: rgba(110, 115, 141, 0.35);
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(138, 173, 244, 0.45);
}
.mono {
font-family: var(--mono);
font-size: 0.92em;
}
.hidden {
display: none !important;
}
/* ------------------------------------------------------------------ shell */
.sync-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.sync-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 26px 14px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(30, 32, 48, 0.92), rgba(30, 32, 48, 0.6));
}
.brand-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-strong);
}
.brand-subtitle {
font-size: 22px;
font-weight: 650;
}
.header-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
min-width: 0;
}
.db-path {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
}
.run-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--muted);
}
.run-pill::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ctp-overlay0);
}
.run-pill.running {
color: var(--ctp-sky);
border-color: rgba(145, 215, 227, 0.45);
}
.run-pill.running::before {
background: var(--ctp-sky);
box-shadow: 0 0 8px rgba(145, 215, 227, 0.8);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.sync-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 26px 30px;
}
/* ------------------------------------------------------------------ panels */
.panel {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel);
box-shadow: 0 10px 28px -18px var(--shadow);
padding: 16px 18px 18px;
animation: panel-in 0.35s ease both;
}
.panel:nth-child(2) {
animation-delay: 0.04s;
}
.panel:nth-child(3) {
animation-delay: 0.08s;
}
.panel:nth-child(4) {
animation-delay: 0.12s;
}
.panel:nth-child(5) {
animation-delay: 0.16s;
}
@keyframes panel-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.panel-head h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ctp-subtext1);
}
.panel-actions {
display: flex;
gap: 8px;
}
.panel-hint {
margin: 0 0 12px;
font-size: 12.5px;
color: var(--muted);
}
.empty-note {
padding: 14px 4px 4px;
font-size: 13px;
color: var(--faint);
}
/* ------------------------------------------------------------------ buttons */
.primary-button,
.ghost-button,
.danger-button,
.mini-button {
border-radius: 9px;
border: 1px solid transparent;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease,
transform 0.05s ease;
}
.primary-button {
background: var(--accent);
color: var(--ctp-crust);
}
.primary-button:hover:not(:disabled) {
background: var(--accent-strong);
}
.ghost-button {
background: transparent;
border-color: var(--line);
color: var(--ctp-subtext1);
}
.ghost-button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.danger-button {
background: transparent;
border-color: rgba(237, 135, 150, 0.5);
color: var(--danger);
}
.danger-button:hover:not(:disabled) {
background: rgba(237, 135, 150, 0.12);
}
.mini-button {
padding: 4px 10px;
font-size: 12px;
background: transparent;
border-color: var(--line);
color: var(--muted);
}
.mini-button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.mini-button.danger:hover:not(:disabled) {
border-color: var(--danger);
color: var(--danger);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ------------------------------------------------------------------ inputs */
.text-input {
border: 1px solid var(--line);
border-radius: 9px;
background: rgba(24, 25, 38, 0.6);
padding: 8px 12px;
font-size: 13.5px;
color: var(--text);
min-width: 0;
}
.text-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(138, 173, 244, 0.18);
}
.auto-interval {
display: flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
color: var(--muted);
}
.auto-interval input {
width: 64px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(24, 25, 38, 0.6);
padding: 4px 8px;
font-size: 13px;
text-align: right;
}
.auto-interval input:focus {
outline: none;
border-color: var(--accent);
}
/* ------------------------------------------------------------------ hosts */
.host-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.host-card {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid var(--line-soft);
border-radius: 12px;
background: var(--panel-elevated);
padding: 12px 14px;
}
.host-card.syncing {
border-color: rgba(145, 215, 227, 0.45);
}
.host-top {
display: flex;
align-items: center;
gap: 12px;
}
.host-id {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: 10px;
}
.host-name {
font-family: var(--mono);
font-size: 14.5px;
font-weight: 600;
color: var(--ctp-sapphire);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-label {
font-size: 12.5px;
color: var(--muted);
white-space: nowrap;
}
.host-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--faint);
min-width: 0;
}
.host-status .detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
background: var(--ctp-overlay0);
}
.status-dot.success {
background: var(--ok);
box-shadow: 0 0 6px rgba(166, 218, 149, 0.7);
}
.status-dot.error {
background: var(--danger);
box-shadow: 0 0 6px rgba(237, 135, 150, 0.7);
}
.host-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.direction-toggle {
display: inline-flex;
border: 1px solid var(--line);
border-radius: 9px;
overflow: hidden;
}
.direction-toggle button {
border: none;
background: transparent;
padding: 5px 11px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
}
.direction-toggle button + button {
border-left: 1px solid var(--line-soft);
}
.direction-toggle button.active {
background: rgba(138, 173, 244, 0.18);
color: var(--accent);
}
.auto-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.auto-toggle input {
accent-color: var(--ctp-mauve);
width: 15px;
height: 15px;
cursor: pointer;
}
.host-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
/* ------------------------------------------------------------------ add host */
.add-host-row {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
}
.check-result {
margin-top: 12px;
border-radius: 10px;
border: 1px solid var(--line);
padding: 10px 14px;
font-size: 13px;
}
.check-result.ok {
border-color: rgba(166, 218, 149, 0.4);
background: rgba(166, 218, 149, 0.07);
color: var(--ok);
}
.check-result.fail {
border-color: rgba(237, 135, 150, 0.4);
background: rgba(237, 135, 150, 0.07);
color: var(--danger);
}
.check-result .sub {
margin-top: 3px;
font-size: 12px;
color: var(--muted);
}
.setup-help {
margin-top: 14px;
font-size: 12.5px;
color: var(--muted);
}
.setup-help summary {
cursor: pointer;
font-weight: 600;
color: var(--ctp-subtext1);
}
.setup-help ol {
margin: 8px 0 0;
padding-left: 20px;
line-height: 1.65;
}
.setup-help code {
font-family: var(--mono);
font-size: 11.5px;
background: rgba(24, 25, 38, 0.7);
border: 1px solid var(--line-soft);
border-radius: 5px;
padding: 1px 6px;
color: var(--ctp-peach);
}
/* ------------------------------------------------------------------ activity */
.stage-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.stage-item {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 4px;
font-size: 13.5px;
position: relative;
}
.stage-item + .stage-item::before {
content: '';
position: absolute;
left: 11px;
top: -8px;
width: 2px;
height: 12px;
background: var(--line);
}
.stage-icon {
width: 16px;
height: 16px;
flex: none;
border-radius: 50%;
border: 2px solid var(--line);
display: grid;
place-items: center;
font-size: 10px;
color: var(--ctp-crust);
}
.stage-item.active .stage-icon {
border-color: var(--ctp-sky);
border-top-color: transparent;
animation: spin 0.9s linear infinite;
}
.stage-item.done .stage-icon {
border-color: var(--ok);
background: var(--ok);
}
.stage-item.done .stage-icon::after {
content: '✓';
font-weight: 900;
}
.stage-item.failed .stage-icon {
border-color: var(--danger);
background: var(--danger);
}
.stage-item.failed .stage-icon::after {
content: '✕';
font-weight: 900;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stage-item.active {
color: var(--ctp-sky);
}
.stage-item.done {
color: var(--muted);
}
.remote-output {
margin: 12px 0 0;
max-height: 160px;
overflow: auto;
border: 1px solid var(--line-soft);
border-radius: 10px;
background: var(--ctp-crust);
padding: 10px 14px;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.55;
color: var(--ctp-subtext1);
white-space: pre-wrap;
}
.result-card {
margin-top: 14px;
border-radius: 12px;
padding: 14px 16px;
border: 1px solid rgba(166, 218, 149, 0.35);
background: rgba(166, 218, 149, 0.06);
}
.result-card.error {
border-color: rgba(237, 135, 150, 0.4);
background: rgba(237, 135, 150, 0.06);
}
.result-title {
font-size: 14px;
font-weight: 700;
color: var(--ok);
margin-bottom: 8px;
}
.result-card.error .result-title {
color: var(--danger);
}
.result-error-detail {
font-family: var(--mono);
font-size: 12px;
color: var(--ctp-subtext1);
white-space: pre-wrap;
}
.count-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px 16px;
}
.count-cell {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 12.5px;
color: var(--muted);
border-bottom: 1px dashed var(--line-soft);
padding-bottom: 3px;
}
.count-cell b {
font-family: var(--mono);
font-size: 14px;
color: var(--text);
}
/* ------------------------------------------------------------------ snapshots */
.snapshot-list {
display: flex;
flex-direction: column;
}
.snapshot-row {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 4px;
border-bottom: 1px solid var(--line-soft);
font-size: 13px;
}
.snapshot-row:last-child {
border-bottom: none;
}
.snapshot-name {
flex: 1;
min-width: 0;
font-family: var(--mono);
font-size: 12.5px;
color: var(--ctp-teal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snapshot-meta {
flex: none;
font-size: 12px;
color: var(--faint);
font-family: var(--mono);
}
.snapshot-actions {
display: flex;
gap: 6px;
}
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
formatBytes,
formatMergeTargetHeading,
formatRelativeTime,
summarizeMergeCounts,
} from './syncui-format';
test('formatBytes renders human readable sizes', () => {
assert.equal(formatBytes(0), '0 B');
assert.equal(formatBytes(532), '532 B');
assert.equal(formatBytes(2048), '2.0 KB');
assert.equal(formatBytes(5 * 1024 * 1024), '5.0 MB');
assert.equal(formatBytes(1.5 * 1024 * 1024 * 1024), '1.5 GB');
});
test('formatRelativeTime renders past timestamps', () => {
const now = 1_700_000_000_000;
assert.equal(formatRelativeTime(null, now), 'never');
assert.equal(formatRelativeTime(now - 20_000, now), 'just now');
assert.equal(formatRelativeTime(now - 5 * 60_000, now), '5 min ago');
assert.equal(formatRelativeTime(now - 3 * 3_600_000, now), '3 h ago');
assert.equal(formatRelativeTime(now - 2 * 86_400_000, now), '2 d ago');
});
test('formatMergeTargetHeading names the local and remote machines', () => {
assert.equal(formatMergeTargetHeading('local'), 'Merged into this machine');
assert.equal(formatMergeTargetHeading('remote'), 'Merged into remote machine');
});
test('summarizeMergeCounts lists only non-zero counts', () => {
const summary = {
sessionsMerged: 3,
sessionsAlreadyPresent: 12,
activeSessionsSkipped: 0,
animeAdded: 1,
videosAdded: 2,
wordsAdded: 0,
kanjiAdded: 0,
subtitleLinesAdded: 450,
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 },
]);
const empty = summarizeMergeCounts({
...summary,
sessionsMerged: 0,
sessionsAlreadyPresent: 0,
animeAdded: 0,
videosAdded: 0,
subtitleLinesAdded: 0,
telemetryRowsAdded: 0,
eventsAdded: 0,
rollupGroupsRecomputed: 0,
});
assert.deepEqual(empty, [{ label: 'Sessions merged', value: 0 }]);
});
+56
View File
@@ -0,0 +1,56 @@
import type { SyncMergeSummary } from '../shared/sync/sync-events';
export function formatMergeTargetHeading(target: 'local' | 'remote'): string {
return target === 'local' ? 'Merged into this machine' : 'Merged into remote machine';
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
export function formatRelativeTime(atMs: number | null, nowMs: number): string {
if (atMs === null) return 'never';
const elapsed = Math.max(0, nowMs - atMs);
if (elapsed < 60_000) return 'just now';
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} min ago`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} h ago`;
return `${Math.floor(elapsed / 86_400_000)} d ago`;
}
export interface MergeCountLine {
label: string;
value: number;
}
const MERGE_COUNT_FIELDS: Array<{ key: keyof SyncMergeSummary; label: string }> = [
{ key: 'sessionsMerged', label: 'Sessions merged' },
{ key: 'sessionsAlreadyPresent', label: 'Already present' },
{ key: 'activeSessionsSkipped', label: 'Active skipped' },
{ key: 'animeAdded', label: 'Series added' },
{ key: 'videosAdded', label: 'Videos added' },
{ 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' },
{ key: 'rollupGroupsRecomputed', label: 'Rollups recomputed' },
];
// "Sessions merged" always shows (even at 0, it is the headline number);
// everything else appears only when non-zero.
export function summarizeMergeCounts(summary: SyncMergeSummary): MergeCountLine[] {
return MERGE_COUNT_FIELDS.filter(
(field) => field.key === 'sessionsMerged' || (summary[field.key] ?? 0) > 0,
).map((field) => ({ label: field.label, value: summary[field.key] ?? 0 }));
}
+478
View File
@@ -0,0 +1,478 @@
import type {
SyncDirection,
SyncHostEntry,
SyncUiAPI,
SyncUiCheckResult,
SyncUiProgressPayload,
SyncUiSnapshot,
SyncUiStartResult,
} from '../types/sync-ui';
import {
formatBytes,
formatMergeTargetHeading,
formatRelativeTime,
summarizeMergeCounts,
} from './syncui-format';
declare global {
interface Window {
syncUiAPI: SyncUiAPI;
}
}
const api = window.syncUiAPI;
function el<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Missing element #${id}`);
return node as T;
}
const runPill = el<HTMLDivElement>('runPill');
const dbPathEl = el<HTMLDivElement>('dbPath');
const hostList = el<HTMLDivElement>('hostList');
const hostListEmpty = el<HTMLDivElement>('hostListEmpty');
const autoIntervalInput = el<HTMLInputElement>('autoIntervalInput');
const newHostInput = el<HTMLInputElement>('newHostInput');
const newLabelInput = el<HTMLInputElement>('newLabelInput');
const testHostButton = el<HTMLButtonElement>('testHostButton');
const addHostButton = el<HTMLButtonElement>('addHostButton');
const checkResultEl = el<HTMLDivElement>('checkResult');
const activityPanel = el<HTMLElement>('activityPanel');
const cancelButton = el<HTMLButtonElement>('cancelButton');
const stageList = el<HTMLDivElement>('stageList');
const remoteOutput = el<HTMLPreElement>('remoteOutput');
const resultCard = el<HTMLDivElement>('resultCard');
const snapshotsDirEl = el<HTMLSpanElement>('snapshotsDir');
const snapshotList = el<HTMLDivElement>('snapshotList');
const snapshotListEmpty = el<HTMLDivElement>('snapshotListEmpty');
const createSnapshotButton = el<HTMLButtonElement>('createSnapshotButton');
const mergeFileButton = el<HTMLButtonElement>('mergeFileButton');
let snapshot: SyncUiSnapshot | null = null;
let activeRunId: number | null = null;
let retryWithForce: (() => void) | null = null;
const DIRECTIONS: Array<{ id: SyncDirection; label: string; title: string }> = [
{ id: 'both', label: '⇄ Two-way', title: 'Merge both databases into each other' },
{ id: 'push', label: '⇡ Push', title: 'Only merge this machine into the device' },
{ id: 'pull', label: '⇣ Pull', title: 'Only merge the device into this machine' },
];
function describeRun(kind: string, host: string | null): string {
if (kind === 'host-sync') return `Syncing with ${host ?? 'device'}`;
if (kind === 'merge') return 'Merging snapshot';
if (kind === 'snapshot') return 'Creating snapshot';
return 'Working';
}
function setRunPill(): void {
const running = snapshot?.run.running ?? false;
runPill.classList.toggle('running', running);
runPill.classList.toggle('idle', !running);
runPill.textContent = running
? describeRun(snapshot!.run.kind ?? '', snapshot!.run.host)
: 'Idle';
cancelButton.classList.toggle('hidden', !running);
}
function clearActivity(): void {
stageList.replaceChildren();
remoteOutput.textContent = '';
remoteOutput.classList.add('hidden');
resultCard.replaceChildren();
resultCard.classList.add('hidden');
}
function markStageStates(state: 'done' | 'failed'): void {
for (const item of stageList.querySelectorAll('.stage-item.active')) {
item.classList.remove('active');
item.classList.add(state);
}
}
function addStage(message: string): void {
markStageStates('done');
const item = document.createElement('div');
item.className = 'stage-item active';
const icon = document.createElement('span');
icon.className = 'stage-icon';
const text = document.createElement('span');
text.textContent = message;
item.append(icon, text);
stageList.appendChild(item);
}
function showResult(ok: boolean, error: string | null): void {
markStageStates(ok ? 'done' : 'failed');
resultCard.classList.remove('hidden');
resultCard.classList.toggle('error', !ok);
const title = document.createElement('div');
title.className = 'result-title';
title.textContent = ok ? 'Sync complete' : 'Sync failed';
resultCard.prepend(title);
if (!ok && error) {
const detail = document.createElement('div');
detail.className = 'result-error-detail';
detail.textContent = error;
resultCard.appendChild(detail);
if (retryWithForce && /--force/.test(error)) {
const retry = document.createElement('button');
retry.className = 'ghost-button';
retry.style.marginTop = '10px';
retry.type = 'button';
retry.textContent = 'Retry with --force (skip the running-app check)';
retry.addEventListener('click', () => {
const action = retryWithForce;
retryWithForce = null;
action?.();
});
resultCard.appendChild(retry);
}
}
}
function appendMergeSummary(
target: 'local' | 'remote',
lines: ReturnType<typeof summarizeMergeCounts>,
): void {
resultCard.classList.remove('hidden');
const heading = document.createElement('div');
heading.className = 'result-title';
heading.textContent = formatMergeTargetHeading(target);
const grid = document.createElement('div');
grid.className = 'count-grid';
for (const line of lines) {
const cell = document.createElement('div');
cell.className = 'count-cell';
const label = document.createElement('span');
label.textContent = line.label;
const value = document.createElement('b');
value.textContent = String(line.value);
cell.append(label, value);
grid.appendChild(cell);
}
resultCard.append(heading, grid);
}
function handleProgress(payload: SyncUiProgressPayload): void {
if (payload.runId !== activeRunId) {
activeRunId = payload.runId;
clearActivity();
activityPanel.classList.remove('hidden');
}
const event = payload.event;
if (event.type === 'stage') {
addStage(event.message);
} else if (event.type === 'snapshot-created') {
addStage(`Snapshot written to ${event.path}`);
} else if (event.type === 'remote-output') {
remoteOutput.classList.remove('hidden');
remoteOutput.textContent += event.text;
} else if (event.type === 'merge-summary') {
appendMergeSummary(event.target, summarizeMergeCounts(event.summary));
} else if (event.type === 'result') {
// The run's completion handler broadcasts state-changed, which refreshes.
showResult(event.ok, event.error);
}
}
async function startRun(action: () => Promise<SyncUiStartResult>): Promise<void> {
const result = await action();
if (!result.started) {
activityPanel.classList.remove('hidden');
clearActivity();
activeRunId = null;
showResult(false, result.reason ?? 'Could not start the sync.');
return;
}
await refresh();
}
function runHostSync(entry: SyncHostEntry): Promise<SyncUiStartResult> {
retryWithForce = () => {
void startRun(() => api.runSync({ host: entry.host, direction: entry.direction, force: true }));
};
return api.runSync({ host: entry.host, direction: entry.direction });
}
function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
const running = snapshot?.run.running ?? false;
const syncingThis = running && snapshot?.run.host === entry.host;
const card = document.createElement('div');
card.className = `host-card${syncingThis ? ' syncing' : ''}`;
const top = document.createElement('div');
top.className = 'host-top';
const id = document.createElement('div');
id.className = 'host-id';
const name = document.createElement('span');
name.className = 'host-name';
name.textContent = entry.host;
id.appendChild(name);
if (entry.label) {
const label = document.createElement('span');
label.className = 'host-label';
label.textContent = entry.label;
id.appendChild(label);
}
const status = document.createElement('div');
status.className = 'host-status';
const dot = document.createElement('span');
dot.className = `status-dot${entry.lastSyncStatus ? ` ${entry.lastSyncStatus}` : ''}`;
const statusText = document.createElement('span');
statusText.className = 'detail';
const when = formatRelativeTime(entry.lastSyncAtMs, Date.now());
statusText.textContent =
entry.lastSyncStatus === null
? 'never synced'
: `${when}${entry.lastSyncDetail ? ` · ${entry.lastSyncDetail}` : ''}`;
statusText.title = entry.lastSyncDetail ?? '';
status.append(dot, statusText);
top.append(id, status);
const controls = document.createElement('div');
controls.className = 'host-controls';
const toggle = document.createElement('div');
toggle.className = 'direction-toggle';
toggle.setAttribute('role', 'group');
toggle.setAttribute('aria-label', `Sync direction for ${entry.host}`);
for (const direction of DIRECTIONS) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = direction.label;
button.title = direction.title;
button.classList.toggle('active', entry.direction === direction.id);
button.addEventListener('click', () => {
// Every mutation triggers a state-changed broadcast, which refreshes.
void api.saveHost({ host: entry.host, direction: direction.id });
});
toggle.appendChild(button);
}
const auto = document.createElement('label');
auto.className = 'auto-toggle';
const autoBox = document.createElement('input');
autoBox.type = 'checkbox';
autoBox.checked = entry.autoSync;
autoBox.addEventListener('change', () => {
void api.saveHost({ host: entry.host, autoSync: autoBox.checked });
});
const autoText = document.createElement('span');
autoText.textContent = 'Auto-sync';
auto.append(autoBox, autoText);
const actions = document.createElement('div');
actions.className = 'host-actions';
const syncButton = document.createElement('button');
syncButton.type = 'button';
syncButton.className = 'primary-button';
syncButton.textContent = syncingThis ? 'Syncing…' : 'Sync now';
syncButton.disabled = running;
syncButton.addEventListener('click', () => {
void startRun(() => runHostSync(entry));
});
const testButton = document.createElement('button');
testButton.type = 'button';
testButton.className = 'mini-button';
testButton.textContent = 'Test';
testButton.addEventListener('click', () => {
testButton.disabled = true;
testButton.textContent = 'Testing…';
void api
.checkHost(entry.host)
.then((result) => renderCheckResult(result))
.finally(() => {
testButton.disabled = false;
testButton.textContent = 'Test';
});
});
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'mini-button danger';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
if (!window.confirm(`Remove ${entry.host} from saved devices?`)) return;
void api.removeHost(entry.host);
});
actions.append(syncButton, testButton, removeButton);
controls.append(toggle, auto, actions);
card.append(top, controls);
return card;
}
function renderCheckResult(result: SyncUiCheckResult): void {
checkResultEl.classList.remove('hidden', 'ok', 'fail');
checkResultEl.classList.add(result.ok ? 'ok' : 'fail');
checkResultEl.replaceChildren();
const headline = document.createElement('div');
headline.textContent = result.ok
? `${result.host} is ready to sync`
: `${result.host} is not ready`;
checkResultEl.appendChild(headline);
const sub = document.createElement('div');
sub.className = 'sub';
if (result.ok) {
sub.textContent = `SSH ok · remote launcher: ${result.remoteCommand ?? '?'}${
result.remoteVersion ? ` (${result.remoteVersion})` : ''
}`;
} else {
sub.textContent = result.error ?? (result.sshOk ? 'Remote launcher missing.' : 'SSH failed.');
}
checkResultEl.appendChild(sub);
}
function renderHosts(): void {
const hosts = snapshot?.hosts.hosts ?? [];
hostList.replaceChildren(...hosts.map(renderHostCard));
hostListEmpty.classList.toggle('hidden', hosts.length > 0);
}
function renderSnapshots(): void {
const files = snapshot?.snapshots ?? [];
snapshotsDirEl.textContent = snapshot?.snapshotsDir ?? '';
snapshotList.replaceChildren(
...files.map((file) => {
const row = document.createElement('div');
row.className = 'snapshot-row';
const name = document.createElement('span');
name.className = 'snapshot-name';
name.textContent = file.name;
name.title = file.path;
const meta = document.createElement('span');
meta.className = 'snapshot-meta';
meta.textContent = `${formatBytes(file.sizeBytes)} · ${new Date(
file.modifiedAtMs,
).toLocaleString()}`;
const actions = document.createElement('div');
actions.className = 'snapshot-actions';
const merge = document.createElement('button');
merge.type = 'button';
merge.className = 'mini-button';
merge.textContent = 'Merge';
merge.title = 'Merge this snapshot into the local database';
merge.disabled = snapshot?.run.running ?? false;
merge.addEventListener('click', () => {
retryWithForce = () => {
void startRun(() => api.mergeSnapshotFile(file.path, true));
};
void startRun(() => api.mergeSnapshotFile(file.path));
});
const reveal = document.createElement('button');
reveal.type = 'button';
reveal.className = 'mini-button';
reveal.textContent = 'Reveal';
reveal.addEventListener('click', () => {
void api.revealSnapshot(file.path);
});
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'mini-button danger';
remove.textContent = 'Delete';
remove.addEventListener('click', () => {
if (!window.confirm(`Delete snapshot ${file.name}?`)) return;
void api.deleteSnapshot(file.path);
});
actions.append(merge, reveal, remove);
row.append(name, meta, actions);
return row;
}),
);
snapshotListEmpty.classList.toggle('hidden', files.length > 0);
}
async function refresh(): Promise<void> {
snapshot = await api.getSnapshot();
dbPathEl.textContent = snapshot.dbPath;
dbPathEl.title = snapshot.dbPath;
if (document.activeElement !== autoIntervalInput) {
autoIntervalInput.value = String(snapshot.hosts.autoSyncIntervalMinutes);
}
setRunPill();
renderHosts();
renderSnapshots();
createSnapshotButton.disabled = snapshot.run.running;
mergeFileButton.disabled = snapshot.run.running;
}
function refreshFromState(): void {
void refresh();
}
addHostButton.addEventListener('click', () => {
const host = newHostInput.value.trim();
if (!host) {
newHostInput.focus();
return;
}
const label = newLabelInput.value.trim();
void api
.saveHost({ host, label: label || null })
.then(() => {
newHostInput.value = '';
newLabelInput.value = '';
checkResultEl.classList.add('hidden');
})
.catch((error: unknown) => {
renderCheckResult({
host,
sshOk: false,
remoteCommand: null,
remoteVersion: null,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
});
});
newHostInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') addHostButton.click();
});
testHostButton.addEventListener('click', () => {
const host = newHostInput.value.trim();
if (!host) {
newHostInput.focus();
return;
}
testHostButton.disabled = true;
testHostButton.textContent = 'Testing…';
void api
.checkHost(host)
.then((result) => renderCheckResult(result))
.finally(() => {
testHostButton.disabled = false;
testHostButton.textContent = 'Test connection';
});
});
autoIntervalInput.addEventListener('change', () => {
const minutes = Number(autoIntervalInput.value);
if (!Number.isFinite(minutes) || minutes < 1) return;
void api.setAutoSyncInterval(Math.floor(minutes));
});
cancelButton.addEventListener('click', () => {
void api.cancelRun();
});
createSnapshotButton.addEventListener('click', () => {
retryWithForce = null;
void startRun(() => api.createSnapshot());
});
mergeFileButton.addEventListener('click', () => {
void api.pickSnapshotFile().then((path) => {
if (!path) return;
retryWithForce = () => {
void startRun(() => api.mergeSnapshotFile(path, true));
};
void startRun(() => api.mergeSnapshotFile(path));
});
});
api.onProgress(handleProgress);
api.onStateChanged(refreshFromState);
void refresh();
+81
View File
@@ -0,0 +1,81 @@
import type { SyncProgressEvent } from '../shared/sync/sync-events';
import type { SyncDirection, SyncHostsState } from '../shared/sync/sync-hosts-store';
export type { SyncProgressEvent } from '../shared/sync/sync-events';
export type { SyncDirection, SyncHostEntry, SyncHostsState } from '../shared/sync/sync-hosts-store';
export interface SyncUiSnapshotFile {
path: string;
name: string;
sizeBytes: number;
modifiedAtMs: number;
}
export type SyncUiRunKind = 'host-sync' | 'merge' | 'check' | 'snapshot';
export interface SyncUiRunState {
running: boolean;
runId: number | null;
kind: SyncUiRunKind | null;
host: string | null;
}
export interface SyncUiSnapshot {
dbPath: string;
hosts: SyncHostsState;
snapshotsDir: string;
snapshots: SyncUiSnapshotFile[];
run: SyncUiRunState;
}
export interface SyncUiRunRequest {
host: string;
direction?: SyncDirection;
force?: boolean;
}
export interface SyncUiStartResult {
started: boolean;
runId: number | null;
reason: string | null;
}
export interface SyncUiCheckResult {
host: string;
sshOk: boolean;
remoteCommand: string | null;
remoteVersion: string | null;
ok: boolean;
error: string | null;
}
export interface SyncUiProgressPayload {
runId: number;
kind: SyncUiRunKind;
host: string | null;
event: SyncProgressEvent;
}
export interface SyncUiHostUpdateRequest {
host: string;
label?: string | null;
direction?: SyncDirection;
autoSync?: boolean;
}
export interface SyncUiAPI {
getSnapshot: () => Promise<SyncUiSnapshot>;
saveHost: (update: SyncUiHostUpdateRequest) => Promise<void>;
removeHost: (host: string) => Promise<void>;
setAutoSyncInterval: (minutes: number) => Promise<void>;
runSync: (request: SyncUiRunRequest) => Promise<SyncUiStartResult>;
cancelRun: () => Promise<boolean>;
checkHost: (host: string) => Promise<SyncUiCheckResult>;
createSnapshot: () => Promise<SyncUiStartResult>;
mergeSnapshotFile: (path: string, force?: boolean) => Promise<SyncUiStartResult>;
deleteSnapshot: (path: string) => Promise<void>;
revealSnapshot: (path: string) => Promise<boolean>;
pickSnapshotFile: () => Promise<string | null>;
onProgress: (listener: (payload: SyncUiProgressPayload) => void) => () => void;
onStateChanged: (listener: () => void) => () => void;
}