mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'`,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}));
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }]);
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user