refactor(sync): collapse 11 sync args into forwarded token array

- Replace syncHost/syncSnapshotPath/… with syncCliTokens: string[] passed verbatim to --sync-cli sync
- Move sync CLI validation from launcher to parseSyncCliTokens (app-side single owner)
- Extract resolveImmersionDbPath to shared db-path.ts importable by both launcher and app
- Delete driver.ts; inline types into libsql-driver.ts and hard-wire openLibsqlSyncDb
- Drop OpenSyncDb injection from mergeSnapshotIntoDb and createDbSnapshot
This commit is contained in:
2026-07-12 22:31:59 -07:00
parent 344a8b44c0
commit 806c56a99b
38 changed files with 473 additions and 798 deletions
+1 -11
View File
@@ -37,17 +37,7 @@ function createContext(): LauncherCommandContext {
useRofi: false, useRofi: false,
history: false, history: false,
sync: false, sync: false,
syncHost: '', syncCliTokens: [],
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false, syncUi: false,
logLevel: 'info', logLevel: 'info',
logRotation: 7, logRotation: 7,
+31 -71
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { Args } from '../types.js'; import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
import { buildSyncCliArgv, runSyncCommand, type SyncCommandDeps } from './sync-command.js'; import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
function makeContext( function makeContext(
overrides: Partial<Args>, overrides: Partial<Args>,
@@ -11,17 +11,7 @@ function makeContext(
return { return {
args: { args: {
sync: true, sync: true,
syncHost: '', syncCliTokens: [],
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn', logLevel: 'warn',
...overrides, ...overrides,
} as Args, } as Args,
@@ -35,24 +25,6 @@ function makeContext(
} as unknown as LauncherCommandContext; } as unknown as LauncherCommandContext;
} }
function makeArgs(overrides: Partial<Parameters<typeof buildSyncCliArgv>[0]>) {
return {
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both' as const,
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn' as const,
...overrides,
};
}
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => { test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
const spawned: Array<{ appPath: string; appArgs: string[] }> = []; const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
const deps: Partial<SyncCommandDeps> = { const deps: Partial<SyncCommandDeps> = {
@@ -62,7 +34,7 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
}; };
assert.equal( assert.equal(
await runSyncCommand(makeContext({ syncHost: 'media-box', syncJson: true }), deps), await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
true, true,
); );
assert.deepEqual(spawned, [ assert.deepEqual(spawned, [
@@ -76,19 +48,32 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
assert.equal(spawned.length, 1); assert.equal(spawned.length, 1);
}); });
test('buildSyncCliArgv forwards every sync option', () => { test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
assert.deepEqual( const spawned: string[][] = [];
buildSyncCliArgv( const deps: Partial<SyncCommandDeps> = {
makeArgs({ runAppCommand: (_appPath, appArgs) => {
syncHost: 'media-box', spawned.push(appArgs);
syncDirection: 'pull', },
syncRemoteCmd: '/opt/SubMiner.AppImage', };
syncDbPath: '/tmp/db.sqlite',
syncForce: true, await runSyncCommand(
syncJson: true, makeContext({
logLevel: 'debug', syncCliTokens: [
}), 'media-box',
), '--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
],
logLevel: 'debug',
}),
deps,
);
assert.deepEqual(spawned, [
[ [
'--sync-cli', '--sync-cli',
'sync', 'sync',
@@ -103,32 +88,7 @@ test('buildSyncCliArgv forwards every sync option', () => {
'--log-level', '--log-level',
'debug', 'debug',
], ],
); ]);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncSnapshotPath: '/tmp/out.sqlite' })),
['--sync-cli', 'sync', '--snapshot', '/tmp/out.sqlite', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncHost: 'media-box', syncCheck: true })),
['--sync-cli', 'sync', 'media-box', '--check', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncMakeTemp: true })),
['--sync-cli', 'sync', '--make-temp', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncRemoveTempPath: '/tmp/subminer-sync-x' })),
['--sync-cli', 'sync', '--remove-temp', '/tmp/subminer-sync-x', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncMergePath: '/tmp/in.sqlite', syncForce: true })),
['--sync-cli', 'sync', '--merge', '/tmp/in.sqlite', '--force', '--log-level', 'warn'],
);
}); });
test('runSyncCommand fails with a clear message when the app binary is missing', async () => { test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
@@ -142,7 +102,7 @@ test('runSyncCommand fails with a clear message when the app binary is missing',
}; };
await assert.rejects( await assert.rejects(
() => runSyncCommand(makeContext({ syncHost: 'media-box' }, null), deps), () => runSyncCommand(makeContext({ syncCliTokens: ['media-box'] }, null), deps),
/SubMiner app binary not found \(sync runs inside the app\)/, /SubMiner app binary not found \(sync runs inside the app\)/,
); );
}); });
+12 -40
View File
@@ -1,6 +1,6 @@
import { SYNC_CLI_FLAG } from '../../src/core/services/stats-sync/cli-args.js';
import { fail } from '../log.js'; import { fail } from '../log.js';
import { runAppCommandInteractive } from '../mpv.js'; import { runAppCommandInteractive } from '../mpv.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
export interface SyncCommandDeps { export interface SyncCommandDeps {
@@ -13,46 +13,12 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
fail, fail,
}; };
type SyncArgs = Pick<
Args,
| 'syncHost'
| 'syncSnapshotPath'
| 'syncMergePath'
| 'syncDirection'
| 'syncRemoteCmd'
| 'syncDbPath'
| 'syncForce'
| 'syncJson'
| 'syncCheck'
| 'syncMakeTemp'
| 'syncRemoveTempPath'
| 'logLevel'
>;
/** Rebuild the app's --sync-cli argv from the launcher's parsed sync args. */
export function buildSyncCliArgv(args: SyncArgs): string[] {
const argv = ['--sync-cli', 'sync'];
if (args.syncHost) argv.push(args.syncHost);
if (args.syncSnapshotPath) argv.push('--snapshot', args.syncSnapshotPath);
if (args.syncMergePath) argv.push('--merge', args.syncMergePath);
if (args.syncMakeTemp) argv.push('--make-temp');
if (args.syncRemoveTempPath) argv.push('--remove-temp', args.syncRemoveTempPath);
if (args.syncDirection === 'push') argv.push('--push');
if (args.syncDirection === 'pull') argv.push('--pull');
if (args.syncCheck) argv.push('--check');
if (args.syncRemoteCmd) argv.push('--remote-cmd', args.syncRemoteCmd);
if (args.syncDbPath) argv.push('--db', args.syncDbPath);
if (args.syncForce) argv.push('--force');
if (args.syncJson) argv.push('--json');
argv.push('--log-level', args.logLevel);
return argv;
}
/** /**
* `subminer sync` is a thin proxy: the sync engine only executes inside the * `subminer sync` is a thin proxy: the sync engine only executes inside the
* SubMiner app (--sync-cli mode, libsql), so the launcher and the app cannot * SubMiner app (--sync-cli mode, libsql). The launcher contributes its
* drift apart. The launcher contributes its parser/help and app discovery; * parser/help and app discovery; the app's parseSyncCliTokens owns validation,
* the child owns the terminal and its exit code becomes the launcher's. * so its errors reach the terminal through the child's inherited stdio. The
* child owns the terminal and its exit code becomes the launcher's.
*/ */
export async function runSyncCommand( export async function runSyncCommand(
context: LauncherCommandContext, context: LauncherCommandContext,
@@ -68,6 +34,12 @@ export async function runSyncCommand(
); );
return true; // fail() never returns; this only satisfies control-flow analysis return true; // fail() never returns; this only satisfies control-flow analysis
} }
deps.runAppCommand(context.appPath, buildSyncCliArgv(context.args)); deps.runAppCommand(context.appPath, [
SYNC_CLI_FLAG,
'sync',
...context.args.syncCliTokens,
'--log-level',
context.args.logLevel,
]);
return true; return true;
} }
+4 -44
View File
@@ -136,17 +136,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsLogLevel: null, statsLogLevel: null,
syncTriggered: false, syncTriggered: false,
syncHost: null, syncCliTokens: [],
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncLogLevel: null, syncLogLevel: null,
syncUiTriggered: false, syncUiTriggered: false,
syncUiLogLevel: null, syncUiLogLevel: null,
@@ -197,17 +187,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsLogLevel: null, statsLogLevel: null,
syncTriggered: false, syncTriggered: false,
syncHost: null, syncCliTokens: [],
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncLogLevel: null, syncLogLevel: null,
syncUiTriggered: false, syncUiTriggered: false,
syncUiLogLevel: null, syncUiLogLevel: null,
@@ -251,17 +231,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsLogLevel: null, statsLogLevel: null,
syncTriggered: false, syncTriggered: false,
syncHost: null, syncCliTokens: [],
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncLogLevel: null, syncLogLevel: null,
syncUiTriggered: false, syncUiTriggered: false,
syncUiLogLevel: null, syncUiLogLevel: null,
@@ -303,17 +273,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsLogLevel: null, statsLogLevel: null,
syncTriggered: false, syncTriggered: false,
syncHost: null, syncCliTokens: [],
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncLogLevel: null, syncLogLevel: null,
syncUiTriggered: false, syncUiTriggered: false,
syncUiLogLevel: null, syncUiLogLevel: null,
+2 -22
View File
@@ -200,17 +200,7 @@ export function createDefaultArgs(
useRofi: false, useRofi: false,
history: false, history: false,
sync: false, sync: false,
syncHost: '', syncCliTokens: [],
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false, syncUi: false,
logLevel: loggingConfig.level ?? 'warn', logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7, logRotation: loggingConfig.rotation ?? 7,
@@ -279,17 +269,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
} }
if (invocations.syncTriggered) { if (invocations.syncTriggered) {
parsed.sync = true; parsed.sync = true;
parsed.syncHost = invocations.syncHost ?? ''; parsed.syncCliTokens = invocations.syncCliTokens;
parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? '';
parsed.syncMergePath = invocations.syncMergePath ?? '';
parsed.syncDirection = invocations.syncDirection;
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
parsed.syncDbPath = invocations.syncDbPath ?? '';
parsed.syncForce = invocations.syncForce;
parsed.syncJson = invocations.syncJson;
parsed.syncCheck = invocations.syncCheck;
parsed.syncMakeTemp = invocations.syncMakeTemp;
parsed.syncRemoveTempPath = invocations.syncRemoveTempPath ?? '';
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel); if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
} }
if (invocations.syncUiTriggered) { if (invocations.syncUiTriggered) {
+51 -30
View File
@@ -43,42 +43,63 @@ test('parseCliPrograms captures texthooker browser-open flag', () => {
assert.equal(result.invocations.texthookerOpenBrowser, true); assert.equal(result.invocations.texthookerOpenBrowser, true);
}); });
test('parseCliPrograms captures one-way sync directions', () => { test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => {
const push = parseCliPrograms(['sync', 'media-box', '--push'], 'subminer'); const push = parseCliPrograms(['sync', 'media-box', '--push'], 'subminer');
assert.equal(push.invocations.syncTriggered, true);
assert.deepEqual(push.invocations.syncCliTokens, ['media-box', '--push']);
const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer'); const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer');
assert.deepEqual(pull.invocations.syncCliTokens, ['media-box', '--pull']);
assert.equal(push.invocations.syncDirection, 'push');
assert.equal(pull.invocations.syncDirection, 'pull');
});
test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => {
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'),
/--push and --pull cannot be combined/,
);
assert.throws(
() => parseCliPrograms(['sync', '--snapshot', '/tmp/stats.sqlite', '--push'], 'subminer'),
/--push and --pull require a host/,
);
});
test('parseCliPrograms captures sync --json and --check flags', () => {
const json = parseCliPrograms(['sync', 'media-box', '--json'], 'subminer');
assert.equal(json.invocations.syncJson, true);
assert.equal(json.invocations.syncCheck, false);
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer'); const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
assert.equal(check.invocations.syncCheck, true); assert.deepEqual(check.invocations.syncCliTokens, ['media-box', '--check', '--json']);
assert.equal(check.invocations.syncJson, true);
assert.equal(check.invocations.syncHost, 'media-box'); const full = parseCliPrograms(
[
'sync',
'media-box',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--log-level',
'debug',
],
'subminer',
);
assert.deepEqual(full.invocations.syncCliTokens, [
'media-box',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
]);
assert.equal(full.invocations.syncLogLevel, 'debug');
const snapshot = parseCliPrograms(['sync', '--snapshot', '/tmp/out.sqlite'], 'subminer');
assert.deepEqual(snapshot.invocations.syncCliTokens, ['--snapshot', '/tmp/out.sqlite']);
const merge = parseCliPrograms(['sync', '--merge', '/tmp/in.sqlite'], 'subminer');
assert.deepEqual(merge.invocations.syncCliTokens, ['--merge', '/tmp/in.sqlite']);
const makeTemp = parseCliPrograms(['sync', '--make-temp'], 'subminer');
assert.deepEqual(makeTemp.invocations.syncCliTokens, ['--make-temp']);
const removeTemp = parseCliPrograms(['sync', '--remove-temp', '/tmp/subminer-sync-x'], 'subminer');
assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']);
}); });
test('parseCliPrograms rejects invalid sync --check combinations', () => { test('parseCliPrograms leaves sync validation to the app parser', () => {
assert.throws(() => parseCliPrograms(['sync', '--check'], 'subminer'), /--check requires a host/); // Invalid combinations are forwarded; the app's parseSyncCliTokens rejects them.
assert.throws( const invalid = parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer');
() => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'), assert.equal(invalid.invocations.syncTriggered, true);
/--check cannot be combined/, assert.deepEqual(invalid.invocations.syncCliTokens, ['media-box', '--push', '--pull']);
);
const empty = parseCliPrograms(['sync'], 'subminer');
assert.equal(empty.invocations.syncTriggered, true);
assert.deepEqual(empty.invocations.syncCliTokens, []);
}); });
test('parseCliPrograms captures sync --ui', () => { test('parseCliPrograms captures sync --ui', () => {
+21 -75
View File
@@ -39,17 +39,7 @@ export interface CliInvocations {
statsCleanupLifetime: boolean; statsCleanupLifetime: boolean;
statsLogLevel: string | null; statsLogLevel: string | null;
syncTriggered: boolean; syncTriggered: boolean;
syncHost: string | null; syncCliTokens: string[];
syncSnapshotPath: string | null;
syncMergePath: string | null;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string | null;
syncDbPath: string | null;
syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncMakeTemp: boolean;
syncRemoveTempPath: string | null;
syncLogLevel: string | null; syncLogLevel: string | null;
syncUiTriggered: boolean; syncUiTriggered: boolean;
syncUiLogLevel: string | null; syncUiLogLevel: string | null;
@@ -181,17 +171,7 @@ export function parseCliPrograms(
let statsCleanupLifetime = false; let statsCleanupLifetime = false;
let statsLogLevel: string | null = null; let statsLogLevel: string | null = null;
let syncTriggered = false; let syncTriggered = false;
let syncHost: string | null = null; let syncCliTokens: string[] = [];
let syncSnapshotPath: string | null = null;
let syncMergePath: string | null = null;
let syncDirection: 'both' | 'push' | 'pull' = 'both';
let syncRemoteCmd: string | null = null;
let syncDbPath: string | null = null;
let syncForce = false;
let syncJson = false;
let syncCheck = false;
let syncMakeTemp = false;
let syncRemoveTempPath: string | null = null;
let syncLogLevel: string | null = null; let syncLogLevel: string | null = null;
let syncUiTriggered = false; let syncUiTriggered = false;
let syncUiLogLevel: string | null = null; let syncUiLogLevel: string | null = null;
@@ -369,49 +349,25 @@ export function parseCliPrograms(
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
return; return;
} }
if (push && pull) { // No validation here: the app's parseSyncCliTokens owns the sync rules
throw new Error('Sync --push and --pull cannot be combined.'); // and its error text reaches the terminal through the child's stdio.
} const remoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() : '';
if ((push || pull) && !host) { const dbPath = typeof options.db === 'string' ? options.db.trim() : '';
throw new Error('Sync --push and --pull require a host.'); const tokens: string[] = [];
} if (host) tokens.push(host);
if (check && !host) { if (snapshot) tokens.push('--snapshot', snapshot);
throw new Error('Sync --check requires a host.'); if (merge) tokens.push('--merge', merge);
} if (makeTemp) tokens.push('--make-temp');
if (check && (push || pull || snapshot || merge)) { if (removeTemp) tokens.push('--remove-temp', removeTemp);
throw new Error( if (push) tokens.push('--push');
'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.', if (pull) tokens.push('--pull');
); if (check) tokens.push('--check');
} if (remoteCmd) tokens.push('--remote-cmd', remoteCmd);
if ((makeTemp || removeTemp) && (push || pull || check)) { if (dbPath) tokens.push('--db', dbPath);
throw new Error('Sync --make-temp/--remove-temp cannot be combined with other sync options.'); if (options.force === true) tokens.push('--force');
} if (options.json === true) tokens.push('--json');
const modes = [
Boolean(host),
Boolean(snapshot),
Boolean(merge),
makeTemp,
Boolean(removeTemp),
].filter(Boolean).length;
if (modes === 0) {
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
}
if (modes > 1) {
throw new Error('Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.');
}
syncTriggered = true; syncTriggered = true;
syncHost = host || null; syncCliTokens = tokens;
syncSnapshotPath = snapshot || null;
syncMergePath = merge || null;
syncDirection = push ? 'push' : pull ? 'pull' : 'both';
syncRemoteCmd =
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
syncForce = options.force === true;
syncJson = options.json === true;
syncCheck = check;
syncMakeTemp = makeTemp;
syncRemoveTempPath = removeTemp || null;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
}); });
@@ -527,17 +483,7 @@ export function parseCliPrograms(
statsCleanupLifetime, statsCleanupLifetime,
statsLogLevel, statsLogLevel,
syncTriggered, syncTriggered,
syncHost, syncCliTokens,
syncSnapshotPath,
syncMergePath,
syncDirection,
syncRemoteCmd,
syncDbPath,
syncForce,
syncJson,
syncCheck,
syncMakeTemp,
syncRemoveTempPath,
syncLogLevel, syncLogLevel,
syncUiTriggered, syncUiTriggered,
syncUiLogLevel, syncUiLogLevel,
+2 -28
View File
@@ -1,38 +1,12 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js'; import type { HistoryVideoRow } from './history-types.js';
import { resolvePathMaybe } from './util.js'; import { resolveImmersionDbPath } from '../src/core/services/stats-sync/db-path.js';
import { import {
isReadonlyWalRetryError, isReadonlyWalRetryError,
withReadonlyWalRetry, withReadonlyWalRetry,
} from '../src/core/services/stats-sync/wal-retry.js'; } from '../src/core/services/stats-sync/wal-retry.js';
export { isReadonlyWalRetryError, withReadonlyWalRetry }; export { isReadonlyWalRetryError, resolveImmersionDbPath, withReadonlyWalRetry };
export function resolveImmersionDbPath(): string {
const root = readLauncherMainConfigObject();
const tracking =
root?.immersionTracking &&
typeof root.immersionTracking === 'object' &&
!Array.isArray(root.immersionTracking)
? (root.immersionTracking as Record<string, unknown>)
: null;
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
if (configured) return resolvePathMaybe(configured);
const configDir = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
return path.join(configDir, 'immersion.sqlite');
}
interface RawHistoryRow { interface RawHistoryRow {
video_id: number; video_id: number;
+1 -11
View File
@@ -31,17 +31,7 @@ function createArgs(): Args {
useRofi: false, useRofi: false,
history: false, history: false,
sync: false, sync: false,
syncHost: '', syncCliTokens: [],
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false, syncUi: false,
logLevel: 'info', logLevel: 'info',
logRotation: 7, logRotation: 7,
+1 -11
View File
@@ -603,17 +603,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
useRofi: false, useRofi: false,
history: false, history: false,
sync: false, sync: false,
syncHost: '', syncCliTokens: [],
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false, syncUi: false,
logLevel: 'error', logLevel: 'error',
logRotation: 7, logRotation: 7,
+5 -11
View File
@@ -4,17 +4,11 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js'; // The engine executes on libsql in production; these merge tests run through
import { createDbSnapshot as createDbSnapshotWith } from '../../src/core/services/stats-sync/shared.js'; // that same driver. bun:sqlite is used only to build fixtures and inspect
import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js'; // results.
import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js';
// The engine only executes inside the app (libsql driver) in production, so import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js';
// these merge tests run through that same binding; bun:sqlite is used only to
// build fixtures and inspect results.
const createDbSnapshot = (dbPath: string, outPath: string) =>
createDbSnapshotWith(openLibsqlSyncDb, dbPath, outPath);
const mergeSnapshotIntoDb = (localDbPath: string, snapshotPath: string) =>
mergeSnapshotIntoDbWith(openLibsqlSyncDb, localDbPath, snapshotPath);
import { import {
createImmersionDbFixture, createImmersionDbFixture,
insertFixtureSession, insertFixtureSession,
+2 -11
View File
@@ -114,17 +114,8 @@ export interface Args {
useRofi: boolean; useRofi: boolean;
history: boolean; history: boolean;
sync: boolean; sync: boolean;
syncHost: string; /** App-owned sync argv tokens forwarded verbatim to `--sync-cli sync`. */
syncSnapshotPath: string; syncCliTokens: string[];
syncMergePath: string;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string;
syncDbPath: string;
syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncMakeTemp: boolean;
syncRemoveTempPath: string;
syncUi: boolean; syncUi: boolean;
logLevel: LogLevel; logLevel: LogLevel;
logRotation: LogRotation; logRotation: LogRotation;
@@ -51,10 +51,11 @@ test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error'); assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error');
}); });
test('parseSyncCliTokens mirrors launcher sync validation', () => { test('parseSyncCliTokens owns the sync CLI validation rules', () => {
assert.equal(parseSyncCliTokens([]).kind, 'error'); assert.equal(parseSyncCliTokens([]).kind, 'error');
assert.equal(parseSyncCliTokens(['sync']).kind, 'error'); assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).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']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--check', '--snapshot', '/tmp/x', 'h']).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', '--snapshot', '/tmp/x']).kind, 'error');
+3 -3
View File
@@ -16,8 +16,9 @@ export function extractSyncCliTokens(argv: readonly string[]): string[] | null {
/** /**
* Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the * Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the
* app's --sync-cli mode. Mirrors the launcher's `subminer sync` validation so * app's --sync-cli mode. This is the single owner of sync CLI validation:
* both entry points accept the same command lines and fail the same way. * 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 { export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' }; if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' };
@@ -120,7 +121,6 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
return { return {
kind: 'run', kind: 'run',
args: { args: {
sync: true,
syncHost: host, syncHost: host,
syncSnapshotPath: snapshot, syncSnapshotPath: snapshot,
syncMergePath: merge, syncMergePath: merge,
+42
View File
@@ -0,0 +1,42 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveConfigFilePath } from '../../../config/path-resolution';
import { parseConfigContent } from '../../../config/parse';
import { getDefaultConfigDir } from '../../../shared/setup-state';
/**
* Default immersion stats database location, shared by the launcher's history
* command and the app's --sync-cli mode: honor a configured
* immersionTracking.dbPath (raw main-config read, tolerant of comments), else
* <configDir>/immersion.sqlite. Electron- and libsql-free on purpose so the
* bun launcher can import it.
*/
export function resolveImmersionDbPath(): string {
const configPath = resolveConfigFilePath({
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
let configured = '';
try {
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf8'));
const tracking =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>).immersionTracking
: null;
if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) {
const dbPath = (tracking as Record<string, unknown>).dbPath;
if (typeof dbPath === 'string') configured = dbPath.trim();
}
} catch {
// no config or unreadable config → default location
}
if (configured) {
return configured.startsWith('~')
? path.join(os.homedir(), configured.slice(1))
: configured;
}
return path.join(getDefaultConfigDir(), 'immersion.sqlite');
}
-42
View File
@@ -1,42 +0,0 @@
// Minimal SQLite driver surface the stats-sync engine runs against. The
// launcher binds it to bun:sqlite; the Electron app binds it to libsql. Both
// implementations must cache prepared statements per SQL string in query():
// the merge runs one insert per copied row, so re-preparing would dominate
// merge time.
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 SyncDbOpenOptions {
readonly?: boolean;
readwrite?: boolean;
create?: boolean;
}
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 OpenSyncDb = (dbPath: string, options: SyncDbOpenOptions) => SyncDb;
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;
}
+38 -25
View File
@@ -1,25 +1,48 @@
import { Database } from '../immersion-tracker/sqlite'; import { Database } from '../immersion-tracker/sqlite';
import type { OpenSyncDb, SyncDb, SyncDbRunResult, SyncDbStatement } from './driver'; import type { SyncDbOpenOptions } from './wal-retry';
interface LibsqlStatement { export interface SyncDbRunResult {
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint }; changes: number;
lastInsertRowid: number | bigint;
}
export interface SyncDbStatement {
run(...params: unknown[]): SyncDbRunResult;
get(...params: unknown[]): unknown; get(...params: unknown[]): unknown;
all(...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 { interface LibsqlDatabase {
prepare(sql: string): LibsqlStatement; prepare(sql: string): SyncDbStatement;
exec(sql: string): unknown; exec(sql: string): unknown;
close(): unknown; close(): unknown;
} }
/** /**
* libsql (better-sqlite3 API) binding of the SyncDb driver for the Electron * libsql (better-sqlite3 API) SQLite connection for the stats-sync engine.
* app. prepare() is not cached by libsql, so query() keeps a per-connection * prepare() is not cached by libsql, so query() keeps a per-connection
* statement cache — the merge prepares a handful of statements and runs them * statement cache — the merge prepares a handful of statements and runs them
* once per copied row. * once per copied row, so re-preparing would dominate merge time.
*/ */
export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => { export function openLibsqlSyncDb(dbPath: string, options: SyncDbOpenOptions): SyncDb {
const db = new Database(dbPath, { const db = new Database(dbPath, {
readonly: options.readonly === true, readonly: options.readonly === true,
fileMustExist: options.create !== true, fileMustExist: options.create !== true,
@@ -27,22 +50,12 @@ export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => {
const statements = new Map<string, SyncDbStatement>(); const statements = new Map<string, SyncDbStatement>();
return { return {
query(sql: string): SyncDbStatement { query(sql: string): SyncDbStatement {
const cached = statements.get(sql); let statement = statements.get(sql);
if (cached) return cached; if (!statement) {
const statement = db.prepare(sql); statement = db.prepare(sql);
const wrapped: SyncDbStatement = { statements.set(sql, statement);
run(...params: unknown[]): SyncDbRunResult { }
return statement.run(...params); return statement;
},
get(...params: unknown[]): unknown {
return statement.get(...params);
},
all(...params: unknown[]): unknown[] {
return statement.all(...params);
},
};
statements.set(sql, wrapped);
return wrapped;
}, },
exec(sql: string): void { exec(sql: string): void {
db.exec(sql); db.exec(sql);
@@ -52,4 +65,4 @@ export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => {
db.close(); db.close();
}, },
}; };
}; }
@@ -1,4 +1,4 @@
import { selectAll, selectOne, type SqlRow, type SyncDb } from './driver'; import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, tableExists, type SyncMergeSummary } from './shared'; import { insertRow, tableExists, type SyncMergeSummary } from './shared';
const ANIME_COPY_COLUMNS = [ const ANIME_COPY_COLUMNS = [
@@ -1,4 +1,4 @@
import { selectAll, type SqlRow, type SyncDb } from './driver'; import { selectAll, type SqlRow, type SyncDb } from './libsql-driver';
import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './shared'; 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_DAY_EXPR = `CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)`;
@@ -1,5 +1,5 @@
import type { LexiconResolver } from './merge-catalog'; import type { LexiconResolver } from './merge-catalog';
import { selectAll, selectOne, type SqlRow, type SyncDb } from './driver'; import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './shared'; import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './shared';
const SESSION_COPY_COLUMNS = [ const SESSION_COPY_COLUMNS = [
+4 -8
View File
@@ -13,7 +13,7 @@ import {
createEmptyMergeSummary, createEmptyMergeSummary,
type SyncMergeSummary, type SyncMergeSummary,
} from './shared'; } from './shared';
import type { OpenSyncDb, SyncDb } from './driver'; import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
export type { SyncMergeSummary } from './shared'; export type { SyncMergeSummary } from './shared';
@@ -25,11 +25,7 @@ export type { SyncMergeSummary } from './shared';
* window is preserved on both sides. Idempotent: re-merging the same * window is preserved on both sides. Idempotent: re-merging the same
* snapshot is a no-op. * snapshot is a no-op.
*/ */
export function mergeSnapshotIntoDb( export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string): SyncMergeSummary {
open: OpenSyncDb,
localDbPath: string,
snapshotPath: string,
): SyncMergeSummary {
if (!fs.existsSync(localDbPath)) { if (!fs.existsSync(localDbPath)) {
throw new Error(`Local stats database not found: ${localDbPath}`); throw new Error(`Local stats database not found: ${localDbPath}`);
} }
@@ -37,10 +33,10 @@ export function mergeSnapshotIntoDb(
throw new Error(`Snapshot database not found: ${snapshotPath}`); throw new Error(`Snapshot database not found: ${snapshotPath}`);
} }
const remote = open(snapshotPath, { readonly: true }); const remote = openLibsqlSyncDb(snapshotPath, { readonly: true });
let local: SyncDb; let local: SyncDb;
try { try {
local = open(localDbPath, { readwrite: true, create: false }); local = openLibsqlSyncDb(localDbPath, { create: false });
} catch (error) { } catch (error) {
remote.close(); remote.close();
throw error; throw error;
+6 -13
View File
@@ -2,9 +2,9 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { SCHEMA_VERSION } from '../immersion-tracker/types'; import { SCHEMA_VERSION } from '../immersion-tracker/types';
import { resolveConfigDir } from '../../../config/path-resolution'; import { getDefaultConfigDir } from '../../../shared/setup-state';
import { withReadonlyWalRetry } from './wal-retry'; import { withReadonlyWalRetry } from './wal-retry';
import { selectOne, type OpenSyncDb, type SyncDb } from './driver'; import { openLibsqlSyncDb, selectOne, type SyncDb } from './libsql-driver';
export { SCHEMA_VERSION }; export { SCHEMA_VERSION };
@@ -40,7 +40,7 @@ export function tableExists(db: SyncDb, tableName: string): boolean {
); );
} }
export function readSchemaVersion(db: SyncDb): number | null { function readSchemaVersion(db: SyncDb): number | null {
if (!tableExists(db, 'imm_schema_version')) return null; if (!tableExists(db, 'imm_schema_version')) return null;
const row = selectOne(db, 'SELECT MAX(schema_version) AS schema_version FROM imm_schema_version'); 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; return typeof row?.schema_version === 'number' ? row.schema_version : null;
@@ -78,14 +78,14 @@ export function insertRow(
return Number(result.lastInsertRowid); return Number(result.lastInsertRowid);
} }
export function createDbSnapshot(open: OpenSyncDb, dbPath: string, outPath: string): void { export function createDbSnapshot(dbPath: string, outPath: string): void {
if (!fs.existsSync(dbPath)) { if (!fs.existsSync(dbPath)) {
throw new Error(`Stats database not found: ${dbPath}`); throw new Error(`Stats database not found: ${dbPath}`);
} }
fs.rmSync(outPath, { force: true }); fs.rmSync(outPath, { force: true });
fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.mkdirSync(path.dirname(outPath), { recursive: true });
withReadonlyWalRetry(dbPath, (options) => { withReadonlyWalRetry(dbPath, (options) => {
const db = open(dbPath, options); const db = openLibsqlSyncDb(dbPath, options);
try { try {
assertMergeableSchema(db, 'Local'); assertMergeableSchema(db, 'Local');
db.query('VACUUM INTO ?').run(outPath); db.query('VACUUM INTO ?').run(outPath);
@@ -113,14 +113,7 @@ function isProcessAlive(pid: number): boolean {
function statsDaemonStateCandidates(dbPath: string): string[] { function statsDaemonStateCandidates(dbPath: string): string[] {
const homeDir = os.homedir(); const homeDir = os.homedir();
const candidates = new Set<string>([path.join(path.dirname(dbPath), 'stats-daemon.json')]); const candidates = new Set<string>([path.join(path.dirname(dbPath), 'stats-daemon.json')]);
const configDir = resolveConfigDir({ candidates.add(path.join(getDefaultConfigDir(), 'stats-daemon.json'));
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir,
existsSync: fs.existsSync,
});
candidates.add(path.join(configDir, 'stats-daemon.json'));
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
candidates.add( candidates.add(
path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'), path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'),
+18 -23
View File
@@ -1,4 +1,5 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { SYNC_CLI_FLAG } from './cli-args';
export interface RemoteRunResult { export interface RemoteRunResult {
status: number; status: number;
@@ -104,7 +105,7 @@ export type RemoteShellFlavor = 'posix' | 'windows-cmd' | 'windows-powershell';
*/ */
export function detectRemoteShellFlavor( export function detectRemoteShellFlavor(
host: string, host: string,
runRemote: typeof runSsh = runSsh, runRemote: (host: string, remoteCommand: string) => RemoteRunResult,
): RemoteShellFlavor { ): RemoteShellFlavor {
const posixProbe = runRemote(host, 'uname -s'); const posixProbe = runRemote(host, 'uname -s');
if (posixProbe.status === 0 && posixProbe.stdout.trim().length > 0) return 'posix'; if (posixProbe.status === 0 && posixProbe.stdout.trim().length > 0) return 'posix';
@@ -144,15 +145,10 @@ const REMOTE_RUNTIME_PATH =
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$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 // The Electron app answers the same launcher-style `sync ...` argv when
// invoked with --sync-cli, so a remote machine only needs the app installed; // invoked with --sync-cli (SYNC_CLI_FLAG), so a remote machine only needs the
// the command-line launcher is one candidate, not a requirement. // app installed; the command-line launcher is one candidate, not a
const APP_SYNC_CLI_FLAG = '--sync-cli'; // requirement. Each candidate is the remote invocation to probe, in order.
function defaultRemoteCandidates(flavor: RemoteShellFlavor): string[] {
interface RemoteCandidate {
invocation: string;
}
function defaultRemoteCandidates(flavor: RemoteShellFlavor): RemoteCandidate[] {
if (flavor === 'windows-cmd' || flavor === 'windows-powershell') { if (flavor === 'windows-cmd' || flavor === 'windows-powershell') {
// cmd.exe expands %VAR% inside double quotes; PowerShell needs $env: and // cmd.exe expands %VAR% inside double quotes; PowerShell needs $env: and
// the & call operator to run a quoted path. // the & call operator to run a quoted path.
@@ -166,30 +162,30 @@ function defaultRemoteCandidates(flavor: RemoteShellFlavor): RemoteCandidate[] {
: `& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe"`; : `& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe"`;
return [ return [
// Command-line launcher shim on PATH or in its default install dir. // Command-line launcher shim on PATH or in its default install dir.
{ invocation: 'subminer' }, 'subminer',
{ invocation: launcherShim }, launcherShim,
// The app binary itself in sync-CLI mode (default NSIS install dir). // The app binary itself in sync-CLI mode (default NSIS install dir).
{ invocation: `${appInstall} ${APP_SYNC_CLI_FLAG}` }, `${appInstall} ${SYNC_CLI_FLAG}`,
{ invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` }, `SubMiner ${SYNC_CLI_FLAG}`,
]; ];
} }
return [ return [
// Command-line launcher (bun script) on PATH or in its default install dir. // Command-line launcher (bun script) on PATH or in its default install dir.
{ invocation: 'subminer' }, 'subminer',
{ invocation: '~/.local/bin/subminer' }, '~/.local/bin/subminer',
// The app binary itself in sync-CLI mode. // The app binary itself in sync-CLI mode.
{ invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` }, `SubMiner ${SYNC_CLI_FLAG}`,
{ invocation: `/Applications/SubMiner.app/Contents/MacOS/SubMiner ${APP_SYNC_CLI_FLAG}` }, `/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
{ invocation: `~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${APP_SYNC_CLI_FLAG}` }, `~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
]; ];
} }
function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): RemoteCandidate[] { function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): string[] {
const quoted = quoteForRemoteShell(flavor, preferred); const quoted = quoteForRemoteShell(flavor, preferred);
const invocation = flavor === 'windows-powershell' ? `& ${quoted}` : quoted; const invocation = flavor === 'windows-powershell' ? `& ${quoted}` : quoted;
// App binaries also answer plain --help by opening the GUI-oriented help // App binaries also answer plain --help by opening the GUI-oriented help
// path, so probe the sync-CLI shape first. // path, so probe the sync-CLI shape first.
return [{ invocation: `${invocation} ${APP_SYNC_CLI_FLAG}` }, { invocation }]; return [`${invocation} ${SYNC_CLI_FLAG}`, invocation];
} }
/** /**
@@ -210,8 +206,7 @@ export function resolveRemoteSubminerCommand(
? preferredCandidates(flavor, preferred) ? preferredCandidates(flavor, preferred)
: defaultRemoteCandidates(flavor); : defaultRemoteCandidates(flavor);
for (const candidate of candidates) { for (const candidate of candidates) {
const command = const command = flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate}` : candidate;
flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate.invocation}` : candidate.invocation;
const probe = runRemote( const probe = runRemote(
host, host,
flavor === 'posix' ? `${command} --help >/dev/null 2>&1` : `${command} --help`, flavor === 'posix' ? `${command} --help >/dev/null 2>&1` : `${command} --help`,
+3 -15
View File
@@ -14,7 +14,6 @@ import {
function makeContext(overrides: Partial<SyncFlowContext['args']> = {}): SyncFlowContext { function makeContext(overrides: Partial<SyncFlowContext['args']> = {}): SyncFlowContext {
return { return {
args: { args: {
sync: true,
syncHost: '', syncHost: '',
syncSnapshotPath: '', syncSnapshotPath: '',
syncMergePath: '', syncMergePath: '',
@@ -43,17 +42,12 @@ function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return { return {
createDbSnapshot: () => {}, createDbSnapshot: () => {},
mergeSnapshotIntoDb: () => createEmptyMergeSummary(), mergeSnapshotIntoDb: () => createEmptyMergeSummary(),
formatMergeSummary: () => 'summary',
findLiveStatsDaemonPid: () => null, findLiveStatsDaemonPid: () => null,
assertSafeSshHost: () => {}, assertSafeSshHost: () => {},
detectRemoteShellFlavor: () => 'posix', detectRemoteShellFlavor: () => 'posix',
resolveRemoteSubminerCommand: () => 'subminer', resolveRemoteSubminerCommand: () => 'subminer',
runScp: () => {}, runScp: () => {},
runSsh: () => ok(), runSsh: () => ok(),
fail: (message: string): never => {
throw new Error(message);
},
log: () => {},
canConnectUnixSocket: async () => false, canConnectUnixSocket: async () => false,
realpathSync: (candidate) => candidate, realpathSync: (candidate) => candidate,
mkdtempSync: (prefix) => fs.mkdtempSync(prefix), mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
@@ -64,7 +58,6 @@ function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
emitEvent: () => {}, emitEvent: () => {},
recordHostSyncResult: () => {}, recordHostSyncResult: () => {},
resolveDefaultDbPath: () => '/tracker.sqlite', resolveDefaultDbPath: () => '/tracker.sqlite',
resolvePath: (value) => value,
...overrides, ...overrides,
}; };
} }
@@ -128,12 +121,9 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
}, },
}); });
assert.equal( await runSyncFlow(
await runSyncFlow( makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }), deps,
deps,
),
true,
); );
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite')); assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
assert.ok( assert.ok(
@@ -154,8 +144,6 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps), () => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/, /sync requires a host, --snapshot <file>, or --merge <file>/,
); );
assert.equal(await runSyncFlow(makeContext({ sync: false }), deps), false);
}); });
function makeHostDeps(calls: string[], overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps { function makeHostDeps(calls: string[], overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
+43 -45
View File
@@ -1,12 +1,12 @@
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { formatMergeSummary } from './merge';
import { quoteForRemoteShell } from './ssh'; import { quoteForRemoteShell } from './ssh';
import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh'; import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh';
import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events'; import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events';
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store'; import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
export interface SyncFlowArgs { export interface SyncFlowArgs {
sync: boolean;
syncHost: string; syncHost: string;
syncSnapshotPath: string; syncSnapshotPath: string;
syncMergePath: string; syncMergePath: string;
@@ -27,14 +27,14 @@ export interface SyncFlowContext {
} }
/** /**
* Everything the sync flow touches outside plain computation. The launcher * Process/IO seams the sync flow needs stubbed in tests: SSH/scp, the DB
* binds these to bun:sqlite-backed engine functions and its own logger; the * snapshot/merge engine, filesystem, and progress/bookkeeping output. The
* app's --sync-cli mode binds libsql and console output. Tests stub freely. * app's --sync-cli mode (src/main/sync-cli.ts) provides the only production
* binding; pure helpers are imported directly.
*/ */
export interface SyncFlowDeps { export interface SyncFlowDeps {
createDbSnapshot: (dbPath: string, outPath: string) => void; createDbSnapshot: (dbPath: string, outPath: string) => void;
mergeSnapshotIntoDb: (localDbPath: string, snapshotPath: string) => SyncMergeSummary; mergeSnapshotIntoDb: (localDbPath: string, snapshotPath: string) => SyncMergeSummary;
formatMergeSummary: (summary: SyncMergeSummary) => string;
findLiveStatsDaemonPid: (dbPath: string) => number | null; findLiveStatsDaemonPid: (dbPath: string) => number | null;
assertSafeSshHost: (host: string) => void; assertSafeSshHost: (host: string) => void;
detectRemoteShellFlavor: ( detectRemoteShellFlavor: (
@@ -49,8 +49,6 @@ export interface SyncFlowDeps {
) => string; ) => string;
runScp: (from: string, to: string) => void; runScp: (from: string, to: string) => void;
runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult; runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult;
fail: (message: string) => never;
log: (level: string, configuredLevel: string, message: string) => void;
canConnectUnixSocket: (socketPath: string) => Promise<boolean>; canConnectUnixSocket: (socketPath: string) => Promise<boolean>;
realpathSync: (candidate: string) => string; realpathSync: (candidate: string) => string;
mkdtempSync: (prefix: string) => string; mkdtempSync: (prefix: string) => string;
@@ -61,7 +59,11 @@ export interface SyncFlowDeps {
emitEvent: (event: SyncProgressEvent) => void; emitEvent: (event: SyncProgressEvent) => void;
recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void; recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void;
resolveDefaultDbPath: () => string; resolveDefaultDbPath: () => string;
resolvePath: (value: string) => 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);
} }
/** /**
@@ -70,14 +72,14 @@ export interface SyncFlowDeps {
* --remove-temp) so the flow never depends on mktemp/rm existing in the * --remove-temp) so the flow never depends on mktemp/rm existing in the
* remote shell — that is what makes Windows remotes work. * remote shell — that is what makes Windows remotes work.
*/ */
export const SYNC_TEMP_PREFIX = 'subminer-sync-'; const SYNC_TEMP_PREFIX = 'subminer-sync-';
export function makeSyncTempDir(mkdtempSync: SyncFlowDeps['mkdtempSync']): string { function makeSyncTempDir(mkdtempSync: SyncFlowDeps['mkdtempSync']): string {
return mkdtempSync(path.join(os.tmpdir(), SYNC_TEMP_PREFIX)); return mkdtempSync(path.join(os.tmpdir(), SYNC_TEMP_PREFIX));
} }
/** Only dirs directly under os.tmpdir() with the sync prefix may be removed. */ /** Only dirs directly under os.tmpdir() with the sync prefix may be removed. */
export function assertRemovableSyncTempDir(target: string): string { function assertRemovableSyncTempDir(target: string): string {
const resolved = path.resolve(target.trim()); const resolved = path.resolve(target.trim());
const normalizeCase = (value: string) => const normalizeCase = (value: string) =>
process.platform === 'win32' ? value.toLowerCase() : value; process.platform === 'win32' ? value.toLowerCase() : value;
@@ -89,18 +91,18 @@ export function assertRemovableSyncTempDir(target: string): string {
return resolved; return resolved;
} }
export function runMakeTempMode(deps: SyncFlowDeps): void { function runMakeTempMode(deps: SyncFlowDeps): void {
deps.consoleLog(makeSyncTempDir(deps.mkdtempSync)); deps.consoleLog(makeSyncTempDir(deps.mkdtempSync));
} }
export function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void { function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void {
const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath); const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath);
deps.rmSync(target, { recursive: true, force: true }); deps.rmSync(target, { recursive: true, force: true });
} }
export function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string { function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string {
const override = context.args.syncDbPath.trim(); const override = context.args.syncDbPath.trim();
return override ? deps.resolvePath(override) : deps.resolveDefaultDbPath(); return override ? resolveCliPath(override) : deps.resolveDefaultDbPath();
} }
function isTrackerDb(dbPath: string, deps: SyncFlowDeps): boolean { function isTrackerDb(dbPath: string, deps: SyncFlowDeps): boolean {
@@ -123,12 +125,12 @@ export async function ensureTrackerQuiescentFlow(
if (!isTrackerDb(dbPath, deps)) return; if (!isTrackerDb(dbPath, deps)) return;
const daemonPid = deps.findLiveStatsDaemonPid(dbPath); const daemonPid = deps.findLiveStatsDaemonPid(dbPath);
if (daemonPid !== null) { if (daemonPid !== null) {
deps.fail( 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.`, `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))) { if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
deps.fail( throw new Error(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`, `An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
); );
} }
@@ -136,7 +138,7 @@ export async function ensureTrackerQuiescentFlow(
// In --json mode every line on stdout is an NDJSON event: human console output // 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. // is silenced and events are written through the original console logger.
export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps { function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
const writeLine = deps.consoleLog; const writeLine = deps.consoleLog;
return { return {
...deps, ...deps,
@@ -146,13 +148,13 @@ export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
}; };
} }
export async function runSnapshotMode( async function runSnapshotMode(
context: SyncFlowContext, context: SyncFlowContext,
dbPath: string, dbPath: string,
deps: SyncFlowDeps, deps: SyncFlowDeps,
): Promise<void> { ): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath); await deps.ensureTrackerQuiescent(context, dbPath);
const outPath = deps.resolvePath(context.args.syncSnapshotPath); const outPath = resolveCliPath(context.args.syncSnapshotPath);
deps.emitEvent({ deps.emitEvent({
type: 'stage', type: 'stage',
stage: 'snapshot-local', stage: 'snapshot-local',
@@ -163,13 +165,13 @@ export async function runSnapshotMode(
deps.consoleLog(outPath); deps.consoleLog(outPath);
} }
export async function runMergeMode( async function runMergeMode(
context: SyncFlowContext, context: SyncFlowContext,
dbPath: string, dbPath: string,
deps: SyncFlowDeps, deps: SyncFlowDeps,
): Promise<void> { ): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath); await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = deps.resolvePath(context.args.syncMergePath); const snapshotPath = resolveCliPath(context.args.syncMergePath);
deps.emitEvent({ deps.emitEvent({
type: 'stage', type: 'stage',
stage: 'merge-local', stage: 'merge-local',
@@ -177,7 +179,7 @@ export async function runMergeMode(
}); });
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath); const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.emitEvent({ type: 'merge-summary', target: 'local', summary }); deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(deps.formatMergeSummary(summary)); deps.consoleLog(formatMergeSummary(summary));
} }
function formatHostSyncDetail( function formatHostSyncDetail(
@@ -296,7 +298,9 @@ export async function runHostSync(
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh); const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor); const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor);
const quote = (value: string) => quoteForRemoteShell(flavor, value); const quote = (value: string) => quoteForRemoteShell(flavor, value);
deps.log('debug', args.logLevel, `Remote subminer command (${flavor}): ${remoteCmd}`); if (args.logLevel === 'debug') {
console.error(`Remote subminer command (${flavor}): ${remoteCmd}`);
}
const localTmpDir = makeSyncTempDir(deps.mkdtempSync); const localTmpDir = makeSyncTempDir(deps.mkdtempSync);
let remoteTmpDir = ''; let remoteTmpDir = '';
@@ -365,7 +369,7 @@ export async function runHostSync(
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot); const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
pulledSummary = summary; pulledSummary = summary;
deps.emitEvent({ type: 'merge-summary', target: 'local', summary }); deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(deps.formatMergeSummary(summary)); deps.consoleLog(formatMergeSummary(summary));
} }
if (shouldPush) { if (shouldPush) {
@@ -425,34 +429,29 @@ export async function runHostSync(
export async function runSyncFlow( export async function runSyncFlow(
context: SyncFlowContext, context: SyncFlowContext,
inputDeps: SyncFlowDeps, inputDeps: SyncFlowDeps,
): Promise<boolean> { ): Promise<void> {
let deps = inputDeps; let deps = inputDeps;
const { args } = context; const { args } = context;
if (!args.sync) return false;
if (args.syncJson) deps = withJsonEvents(deps); if (args.syncJson) deps = withJsonEvents(deps);
try { try {
if (args.syncMakeTemp) { if (args.syncMakeTemp) {
runMakeTempMode(deps); runMakeTempMode(deps);
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null }); } else if (args.syncRemoveTempPath) {
return true;
}
if (args.syncRemoveTempPath) {
runRemoveTempMode(context, deps); runRemoveTempMode(context, deps);
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
return true;
}
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 { } else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.'); 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) { } catch (error) {
if (args.syncJson) { if (args.syncJson) {
@@ -465,5 +464,4 @@ export async function runSyncFlow(
throw error; throw error;
} }
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null }); if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
return true;
} }
+12 -1
View File
@@ -1,5 +1,16 @@
import fs from 'node:fs'; import fs from 'node:fs';
import type { SyncDbOpenOptions } from './driver';
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 * Opening a WAL-mode SQLite database strictly read-only fails when the -shm
+2 -1
View File
@@ -536,6 +536,7 @@ import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-set
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime'; import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
import { createSyncAutoScheduler } from './main/runtime/sync-auto-scheduler'; import { createSyncAutoScheduler } from './main/runtime/sync-auto-scheduler';
import { resolveSyncLauncherCommand, runSyncLauncher } from './main/runtime/sync-launcher-client'; 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 { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
import { import {
isSameYoutubeMediaPath, isSameYoutubeMediaPath,
@@ -2254,7 +2255,7 @@ const openSyncUiWindow = () => openSyncUiWindowHandler();
const syncUiRuntime = createSyncUiRuntime({ const syncUiRuntime = createSyncUiRuntime({
ipcMain, ipcMain,
hostsFilePath: path.join(USER_DATA_PATH, 'sync-hosts.json'), hostsFilePath: getSyncHostsPath(USER_DATA_PATH),
snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'), snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'),
getDbPath: () => { getDbPath: () => {
const configured = getResolvedConfig().immersionTracking?.dbPath?.trim(); const configured = getResolvedConfig().immersionTracking?.dbPath?.trim();
+21 -6
View File
@@ -143,10 +143,20 @@ test('runSyncLauncher cancel kills the child and resolves as cancelled', async (
onEvent: () => {}, onEvent: () => {},
spawn, spawn,
}); });
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
handle.cancel(); handle.cancel();
assert.equal(children[0]!.killed, true); assert.equal(child.killed, true);
const result = await handle.done; const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.ok(result);
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.match(result.error ?? '', /cancel/i); assert.match(result.error ?? '', /cancel/i);
}); });
@@ -160,12 +170,18 @@ test('runSyncLauncher times out a child that never completes', async () => {
spawn, spawn,
timeoutMs: 1, timeoutMs: 1,
}); });
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
const result = await Promise.race([ const result = await Promise.race([
handle.done, handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)), new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]); ]);
assert.equal(children[0]!.killed, true); assert.equal(child.killed, true);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' }); assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
}); });
@@ -190,12 +206,11 @@ test('resolveSyncLauncherCommand self-spawns the app in --sync-cli mode', async
execPath: '/opt/SubMiner/subminer-app', execPath: '/opt/SubMiner/subminer-app',
appPath: null, appPath: null,
}); });
assert.deepEqual(packaged.command, ['/opt/SubMiner/subminer-app', '--sync-cli']); assert.deepEqual(packaged, ['/opt/SubMiner/subminer-app', '--sync-cli']);
assert.equal(packaged.error, null);
const dev = resolveSyncLauncherCommand({ const dev = resolveSyncLauncherCommand({
execPath: '/usr/bin/electron', execPath: '/usr/bin/electron',
appPath: '/home/u/SubMiner', appPath: '/home/u/SubMiner',
}); });
assert.deepEqual(dev.command, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']); assert.deepEqual(dev, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']);
}); });
+3 -11
View File
@@ -26,11 +26,6 @@ export interface SyncLauncherRunHandle {
done: Promise<SyncLauncherRunResult>; done: Promise<SyncLauncherRunResult>;
} }
export interface SyncLauncherResolution {
command: string[] | null;
error: string | null;
}
// Sync runs in a child copy of this app in headless --sync-cli mode: same // 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 // 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 // bun or an installed command-line launcher. In dev runs process.execPath is
@@ -40,13 +35,10 @@ export function resolveSyncLauncherCommand(
execPath?: string; execPath?: string;
appPath?: string | null; appPath?: string | null;
} = {}, } = {},
): SyncLauncherResolution { ): string[] {
const execPath = deps.execPath ?? process.execPath; const execPath = deps.execPath ?? process.execPath;
const appPath = deps.appPath ?? null; const appPath = deps.appPath ?? null;
return { return appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG];
command: appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG],
error: null,
};
} }
export function runSyncLauncher(options: { export function runSyncLauncher(options: {
@@ -142,7 +134,7 @@ export function runSyncLauncher(options: {
child.on('exit', (code) => { child.on('exit', (code) => {
exitObserved = true; exitObserved = true;
exitCode = code; exitCode = code;
if (resultEvent) settleFromExit(code); if (resultEvent || terminationError) settleFromExit(code);
}); });
child.on('close', settleFromExit); child.on('close', settleFromExit);
}); });
+20 -14
View File
@@ -35,7 +35,7 @@ function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
hostsFilePath: path.join(root, 'sync-hosts.json'), hostsFilePath: path.join(root, 'sync-hosts.json'),
snapshotsDir: path.join(root, 'snapshots'), snapshotsDir: path.join(root, 'snapshots'),
getDbPath: () => path.join(root, 'immersion.sqlite'), getDbPath: () => path.join(root, 'immersion.sqlite'),
resolveLauncherCommand: () => ({ command: ['subminer'], error: null }), resolveLauncherCommand: () => ['subminer'],
runLauncher: (options): SyncLauncherRunHandle => { runLauncher: (options): SyncLauncherRunHandle => {
let finish: (result: { ok: boolean; error: string | null }) => void = () => {}; let finish: (result: { ok: boolean; error: string | null }) => void = () => {};
const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => { const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => {
@@ -106,21 +106,24 @@ test('registerHandlers registers every sync-ui request channel', () =>
test('save/remove host round-trips through the hosts file', () => test('save/remove host round-trips through the hosts file', () =>
withTempDir(async (root) => { withTempDir(async (root) => {
const { invoke } = makeTestRig(root); const { invoke, sent } = makeTestRig(root);
const saved = (await invoke('sync-ui:save-host', { await invoke('sync-ui:save-host', {
host: 'user@laptop', host: 'user@laptop',
label: 'Laptop', label: 'Laptop',
direction: 'pull', direction: 'pull',
autoSync: true, autoSync: true,
})) as { hosts: Array<{ host: string; direction: string; autoSync: boolean }> }; });
assert.equal(saved.hosts.length, 1); assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
assert.equal(saved.hosts[0]!.direction, 'pull'); const saved = (await invoke('sync-ui:get-snapshot')) as {
assert.equal(saved.hosts[0]!.autoSync, true); hosts: { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
const removed = (await invoke('sync-ui:remove-host', 'user@laptop')) as {
hosts: unknown[];
}; };
assert.equal(removed.hosts.length, 0); 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', () => test('save-host rejects invalid hosts', () =>
@@ -195,7 +198,7 @@ test('create-snapshot runs the launcher with a timestamped path under the snapsh
test('delete-snapshot refuses paths outside the snapshots dir', () => test('delete-snapshot refuses paths outside the snapshots dir', () =>
withTempDir(async (root) => { withTempDir(async (root) => {
const { invoke } = makeTestRig(root); const { invoke, sent } = makeTestRig(root);
fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true }); fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true });
const inside = path.join(root, 'snapshots', 'immersion-1.sqlite'); const inside = path.join(root, 'snapshots', 'immersion-1.sqlite');
fs.writeFileSync(inside, 'x'); fs.writeFileSync(inside, 'x');
@@ -203,10 +206,13 @@ test('delete-snapshot refuses paths outside the snapshots dir', () =>
fs.writeFileSync(outside, 'x'); fs.writeFileSync(outside, 'x');
await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside)); await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside));
const remaining = (await invoke('sync-ui:delete-snapshot', inside)) as unknown[]; await invoke('sync-ui:delete-snapshot', inside);
assert.equal(remaining.length, 0);
assert.equal(fs.existsSync(inside), false); assert.equal(fs.existsSync(inside), false);
assert.equal(fs.existsSync(outside), true); 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', () => test('check-host resolves with the check-result event', () =>
+67 -86
View File
@@ -21,14 +21,10 @@ import type {
SyncUiSnapshotFile, SyncUiSnapshotFile,
SyncUiStartResult, SyncUiStartResult,
} from '../../types/sync-ui'; } from '../../types/sync-ui';
import type { import type { SyncLauncherRunHandle, SyncLauncherRunResult } from './sync-launcher-client';
SyncLauncherResolution,
SyncLauncherRunHandle,
SyncLauncherRunResult,
} from './sync-launcher-client';
import { runSyncLauncher } from './sync-launcher-client'; import { runSyncLauncher } from './sync-launcher-client';
export interface SyncUiWindowLike { interface SyncUiWindowLike {
isDestroyed(): boolean; isDestroyed(): boolean;
webContents: { send(channel: string, payload?: unknown): void }; webContents: { send(channel: string, payload?: unknown): void };
} }
@@ -40,7 +36,7 @@ export interface SyncUiRuntimeDeps {
hostsFilePath: string; hostsFilePath: string;
snapshotsDir: string; snapshotsDir: string;
getDbPath: () => string; getDbPath: () => string;
resolveLauncherCommand: () => SyncLauncherResolution; resolveLauncherCommand: () => string[];
runLauncher: typeof runSyncLauncher; runLauncher: typeof runSyncLauncher;
getWindow: () => SyncUiWindowLike | null; getWindow: () => SyncUiWindowLike | null;
pickSnapshotFile: () => Promise<string | null>; pickSnapshotFile: () => Promise<string | null>;
@@ -61,7 +57,7 @@ interface ActiveRun {
// Milliseconds are part of the stamp so two snapshots taken in the same second // Milliseconds are part of the stamp so two snapshots taken in the same second
// do not land on the same path and silently overwrite each other. // do not land on the same path and silently overwrite each other.
export function formatSnapshotName(nowMs: number): string { function formatSnapshotName(nowMs: number): string {
const iso = new Date(nowMs).toISOString(); const iso = new Date(nowMs).toISOString();
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-'); const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`; return `immersion-${stamp}.sqlite`;
@@ -85,10 +81,9 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
return readSyncHostsState(deps.hostsFilePath); return readSyncHostsState(deps.hostsFilePath);
} }
function writeState(state: SyncHostsState): SyncHostsState { function writeState(state: SyncHostsState): void {
writeSyncHostsState(deps.hostsFilePath, state); writeSyncHostsState(deps.hostsFilePath, state);
broadcastStateChanged(); broadcastStateChanged();
return state;
} }
function listSnapshots(): SyncUiSnapshotFile[] { function listSnapshots(): SyncUiSnapshotFile[] {
@@ -135,23 +130,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
}; };
} }
function startRun( // Shared run lifecycle: single-run mutex, launcher spawn, cleanup + state
// broadcast on completion. `emitProgress: false` runs silently (no renderer
// progress events); `onEvent` taps every NDJSON event either way.
function launchRun(
kind: SyncUiRunKind, kind: SyncUiRunKind,
host: string | null, host: string | null,
args: string[], args: string[],
options: { notify?: boolean } = {}, options: {
): SyncUiStartResult { notify?: boolean;
timeoutMs?: number;
emitProgress?: boolean;
onEvent?: (event: SyncProgressEvent) => void;
} = {},
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
if (currentRun) { if (currentRun) {
return { return {
started: false, start: { started: false, runId: null, reason: 'A sync operation is already running.' },
runId: null, done: null,
reason: 'A sync operation is already running.',
}; };
} }
const resolution = deps.resolveLauncherCommand(); const emitProgress = options.emitProgress ?? true;
if (!resolution.command) {
return { started: false, runId: null, reason: resolution.error };
}
runCounter += 1; runCounter += 1;
const runId = runCounter; const runId = runCounter;
const run: ActiveRun = { const run: ActiveRun = {
@@ -160,11 +159,15 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
host, host,
resultSeen: false, resultSeen: false,
handle: deps.runLauncher({ handle: deps.runLauncher({
command: resolution.command, command: deps.resolveLauncherCommand(),
args, args,
timeoutMs: options.timeoutMs,
onEvent: (event: SyncProgressEvent) => { onEvent: (event: SyncProgressEvent) => {
if (event.type === 'result') run.resultSeen = true; if (event.type === 'result') run.resultSeen = true;
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event }); options.onEvent?.(event);
if (emitProgress) {
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
}
}, },
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`), onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
}), }),
@@ -175,7 +178,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
if (currentRun?.id === runId) currentRun = null; if (currentRun?.id === runId) currentRun = null;
// If the launcher died without emitting a result event (spawn failure, // If the launcher died without emitting a result event (spawn failure,
// kill), synthesize one so the renderer can settle its progress view. // kill), synthesize one so the renderer can settle its progress view.
if (!run.resultSeen) { if (emitProgress && !run.resultSeen) {
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
runId, runId,
kind, kind,
@@ -202,7 +205,16 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`, `[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
); );
}); });
return { started: true, runId, reason: null }; 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 runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) { function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) {
@@ -238,57 +250,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
if (!isValidSyncHost(trimmed)) { if (!isValidSyncHost(trimmed)) {
return Promise.resolve(failed(`Invalid sync host: ${host}`)); return Promise.resolve(failed(`Invalid sync host: ${host}`));
} }
if (currentRun) { let checkResult: SyncUiCheckResult | null = null;
return Promise.resolve(failed('A sync operation is already running.')); const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], {
} timeoutMs: 30_000,
const resolution = deps.resolveLauncherCommand(); emitProgress: false,
if (!resolution.command) { onEvent: (event) => {
return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.')); if (event.type === 'check-result') {
} checkResult = {
return new Promise((resolve) => { host: event.host,
let checkResult: SyncUiCheckResult | null = null; sshOk: event.sshOk,
runCounter += 1; remoteCommand: event.remoteCommand,
const runId = runCounter; remoteVersion: event.remoteVersion,
const handle = deps.runLauncher({ ok: event.ok,
command: resolution.command!, error: event.error,
args: ['sync', trimmed, '--check', '--json'], };
timeoutMs: 30_000, }
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,
};
}
},
onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`),
});
const run: ActiveRun = {
id: runId,
kind: 'check',
host: trimmed,
handle,
resultSeen: false,
};
currentRun = run;
run.completion = handle.done
.then((result) => {
if (currentRun?.id === runId) currentRun = null;
resolve(checkResult ?? failed(result.error ?? 'Connection check failed.'));
broadcastStateChanged();
})
.catch((error) => {
if (currentRun?.id === runId) currentRun = null;
resolve(failed(error instanceof Error ? error.message : String(error)));
deps.log?.(
`[sync-ui check] Cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
);
});
}); });
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 createSnapshot(): SyncUiStartResult { function createSnapshot(): SyncUiStartResult {
@@ -306,7 +288,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
return startRun('merge', null, args); return startRun('merge', null, args);
} }
function deleteSnapshot(filePath: string): SyncUiSnapshotFile[] { function deleteSnapshot(filePath: string): void {
const resolved = path.resolve(filePath); const resolved = path.resolve(filePath);
const dir = path.resolve(deps.snapshotsDir); const dir = path.resolve(deps.snapshotsDir);
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) { if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
@@ -314,7 +296,6 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
} }
fs.rmSync(resolved, { force: true }); fs.rmSync(resolved, { force: true });
broadcastStateChanged(); broadcastStateChanged();
return listSnapshots();
} }
function cancelRun(): boolean { function cancelRun(): boolean {
@@ -333,18 +314,18 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
function registerHandlers(): void { function registerHandlers(): void {
const channels = IPC_CHANNELS.request; const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot()); deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs())), writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs()));
); });
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
writeState(removeSyncHost(readState(), String(host))), writeState(removeSyncHost(readState(), String(host)));
); });
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => { deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
const value = Number(minutes); const value = Number(minutes);
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) { if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.'); throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
} }
return writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) }); writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
}); });
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) => deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
runHostSync(request as SyncUiRunRequest), runHostSync(request as SyncUiRunRequest),
@@ -355,9 +336,9 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) => deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
mergeSnapshotFile(String(filePath), force === true), mergeSnapshotFile(String(filePath), force === true),
); );
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
deleteSnapshot(String(filePath)), deleteSnapshot(String(filePath));
); });
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => { deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
deps.revealPath(String(filePath)); deps.revealPath(String(filePath));
return true; return true;
+1 -23
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files'; import { isLogFileEnabled } from '../../shared/log-files';
import { canConnectSocket } from '../../shared/socket-probe';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import { buildMpvMsgLevel } from '../../shared/mpv-logging-args'; import { buildMpvMsgLevel } from '../../shared/mpv-logging-args';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts'; 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)); 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> { async function waitForSocketReady(socketPath: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
+9 -84
View File
@@ -1,17 +1,15 @@
import fs from 'node:fs'; import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { parse as parseJsonc } from 'jsonc-parser';
import { resolveConfigDir, resolveConfigFilePath } from '../config/path-resolution';
import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path'; import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path';
import { canConnectSocket } from '../shared/socket-probe';
import { getDefaultConfigDir } from '../shared/setup-state';
import { import {
extractSyncCliTokens, extractSyncCliTokens,
parseSyncCliTokens, parseSyncCliTokens,
syncCliUsage, syncCliUsage,
} from '../core/services/stats-sync/cli-args'; } 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 { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared';
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge'; import { mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
import { import {
assertSafeSshHost, assertSafeSshHost,
detectRemoteShellFlavor, detectRemoteShellFlavor,
@@ -19,7 +17,6 @@ import {
runScp, runScp,
runSsh, runSsh,
} from '../core/services/stats-sync/ssh'; } from '../core/services/stats-sync/ssh';
import { openLibsqlSyncDb } from '../core/services/stats-sync/libsql-driver';
import { import {
ensureTrackerQuiescentFlow, ensureTrackerQuiescentFlow,
runSyncFlow, runSyncFlow,
@@ -40,75 +37,13 @@ export function shouldHandleSyncCliAtEntry(
return extractSyncCliTokens(argv) !== null; return extractSyncCliTokens(argv) !== null;
} }
function resolveAppConfigDir(): string {
return resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
}
function resolveTildePath(input: string): string {
return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input);
}
// Same resolution as the launcher: honor a configured immersionTracking.dbPath
// (raw config read, tolerant of comments), else <configDir>/immersion.sqlite.
function resolveDefaultDbPath(): string {
const configPath = resolveConfigFilePath({
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
let configured = '';
try {
const data = fs.readFileSync(configPath, 'utf8');
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
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 resolveTildePath(configured);
return path.join(resolveAppConfigDir(), 'immersion.sqlite');
}
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean) => {
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));
});
}
function recordHostSyncResultToDisk( function recordHostSyncResultToDisk(
host: string, host: string,
status: 'success' | 'error', status: 'success' | 'error',
detail: string | null, detail: string | null,
): void { ): void {
try { try {
const filePath = getSyncHostsPath(resolveAppConfigDir()); const filePath = getSyncHostsPath(getDefaultConfigDir());
const state = recordSyncResult(readSyncHostsState(filePath), host, { const state = recordSyncResult(readSyncHostsState(filePath), host, {
atMs: Date.now(), atMs: Date.now(),
status, status,
@@ -122,24 +57,15 @@ function recordHostSyncResultToDisk(
function buildSyncCliDeps(): SyncFlowDeps { function buildSyncCliDeps(): SyncFlowDeps {
const deps: SyncFlowDeps = { const deps: SyncFlowDeps = {
createDbSnapshot: (dbPath, outPath) => createDbSnapshot(openLibsqlSyncDb, dbPath, outPath), createDbSnapshot,
mergeSnapshotIntoDb: (localDbPath, snapshotPath) => mergeSnapshotIntoDb,
mergeSnapshotIntoDb(openLibsqlSyncDb, localDbPath, snapshotPath),
formatMergeSummary,
findLiveStatsDaemonPid, findLiveStatsDaemonPid,
assertSafeSshHost, assertSafeSshHost,
detectRemoteShellFlavor, detectRemoteShellFlavor,
resolveRemoteSubminerCommand, resolveRemoteSubminerCommand,
runScp, runScp,
runSsh, runSsh,
fail: (message: string): never => { canConnectUnixSocket: canConnectSocket,
throw new Error(message);
},
log: (level, configured, message) => {
if (level === 'debug' && configured !== 'debug') return;
console.error(message);
},
canConnectUnixSocket,
realpathSync: (candidate) => fs.realpathSync(candidate), realpathSync: (candidate) => fs.realpathSync(candidate),
mkdtempSync: (prefix) => fs.mkdtempSync(prefix), mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options), rmSync: (target, options) => fs.rmSync(target, options),
@@ -149,8 +75,7 @@ function buildSyncCliDeps(): SyncFlowDeps {
ensureTrackerQuiescentFlow(context, dbPath, deps), ensureTrackerQuiescentFlow(context, dbPath, deps),
emitEvent: () => {}, emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk, recordHostSyncResult: recordHostSyncResultToDisk,
resolveDefaultDbPath, resolveDefaultDbPath: resolveImmersionDbPath,
resolvePath: resolveTildePath,
}; };
return deps; return deps;
} }
+4 -6
View File
@@ -1,25 +1,23 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts'; import { IPC_CHANNELS } from './shared/ipc/contracts';
import type { import type {
SyncHostsState,
SyncUiAPI, SyncUiAPI,
SyncUiCheckResult, SyncUiCheckResult,
SyncUiHostUpdateRequest, SyncUiHostUpdateRequest,
SyncUiProgressPayload, SyncUiProgressPayload,
SyncUiRunRequest, SyncUiRunRequest,
SyncUiSnapshot, SyncUiSnapshot,
SyncUiSnapshotFile,
SyncUiStartResult, SyncUiStartResult,
} from './types/sync-ui'; } from './types/sync-ui';
const syncUiAPI: SyncUiAPI = { const syncUiAPI: SyncUiAPI = {
getSnapshot: (): Promise<SyncUiSnapshot> => getSnapshot: (): Promise<SyncUiSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot),
saveHost: (update: SyncUiHostUpdateRequest): Promise<SyncHostsState> => saveHost: (update: SyncUiHostUpdateRequest): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update),
removeHost: (host: string): Promise<SyncHostsState> => removeHost: (host: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host),
setAutoSyncInterval: (minutes: number): Promise<SyncHostsState> => setAutoSyncInterval: (minutes: number): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSetAutoSyncInterval, minutes), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSetAutoSyncInterval, minutes),
runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> => runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request),
@@ -30,7 +28,7 @@ const syncUiAPI: SyncUiAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot),
mergeSnapshotFile: (path: string, force?: boolean): Promise<SyncUiStartResult> => mergeSnapshotFile: (path: string, force?: boolean): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiMergeSnapshotFile, path, force === true), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiMergeSnapshotFile, path, force === true),
deleteSnapshot: (path: string): Promise<SyncUiSnapshotFile[]> => deleteSnapshot: (path: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiDeleteSnapshot, path), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiDeleteSnapshot, path),
revealSnapshot: (path: string): Promise<boolean> => revealSnapshot: (path: string): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRevealSnapshot, path), ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRevealSnapshot, path),
+28
View File
@@ -0,0 +1,28 @@
import net from 'node:net';
/**
* True when something is accepting connections on the unix socket / Windows
* named pipe (400 ms probe). Electron-free; shared by the sync CLI's
* running-app guard and the Windows mpv launch attach wait.
*/
export async function canConnectSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}
-12
View File
@@ -278,18 +278,6 @@ input {
color: var(--muted); color: var(--muted);
} }
.banner {
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
}
.banner-warn {
border: 1px solid rgba(245, 169, 127, 0.4);
background: rgba(245, 169, 127, 0.08);
color: var(--warn);
}
.empty-note { .empty-note {
padding: 14px 4px 4px; padding: 14px 4px 4px;
font-size: 13px; font-size: 13px;
+7 -7
View File
@@ -167,8 +167,8 @@ function handleProgress(payload: SyncUiProgressPayload): void {
} else if (event.type === 'merge-summary') { } else if (event.type === 'merge-summary') {
appendMergeSummary(event.target, summarizeMergeCounts(event.summary)); appendMergeSummary(event.target, summarizeMergeCounts(event.summary));
} else if (event.type === 'result') { } else if (event.type === 'result') {
// The run's completion handler broadcasts state-changed, which refreshes.
showResult(event.ok, event.error); showResult(event.ok, event.error);
void refresh();
} }
} }
@@ -240,7 +240,8 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
button.title = direction.title; button.title = direction.title;
button.classList.toggle('active', entry.direction === direction.id); button.classList.toggle('active', entry.direction === direction.id);
button.addEventListener('click', () => { button.addEventListener('click', () => {
void api.saveHost({ host: entry.host, direction: direction.id }).then(refreshFromState); // Every mutation triggers a state-changed broadcast, which refreshes.
void api.saveHost({ host: entry.host, direction: direction.id });
}); });
toggle.appendChild(button); toggle.appendChild(button);
} }
@@ -251,7 +252,7 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
autoBox.type = 'checkbox'; autoBox.type = 'checkbox';
autoBox.checked = entry.autoSync; autoBox.checked = entry.autoSync;
autoBox.addEventListener('change', () => { autoBox.addEventListener('change', () => {
void api.saveHost({ host: entry.host, autoSync: autoBox.checked }).then(refreshFromState); void api.saveHost({ host: entry.host, autoSync: autoBox.checked });
}); });
const autoText = document.createElement('span'); const autoText = document.createElement('span');
autoText.textContent = 'Auto-sync'; autoText.textContent = 'Auto-sync';
@@ -288,7 +289,7 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
removeButton.textContent = 'Remove'; removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => { removeButton.addEventListener('click', () => {
if (!window.confirm(`Remove ${entry.host} from saved devices?`)) return; if (!window.confirm(`Remove ${entry.host} from saved devices?`)) return;
void api.removeHost(entry.host).then(refreshFromState); void api.removeHost(entry.host);
}); });
actions.append(syncButton, testButton, removeButton); actions.append(syncButton, testButton, removeButton);
@@ -367,7 +368,7 @@ function renderSnapshots(): void {
remove.textContent = 'Delete'; remove.textContent = 'Delete';
remove.addEventListener('click', () => { remove.addEventListener('click', () => {
if (!window.confirm(`Delete snapshot ${file.name}?`)) return; if (!window.confirm(`Delete snapshot ${file.name}?`)) return;
void api.deleteSnapshot(file.path).then(refreshFromState); void api.deleteSnapshot(file.path);
}); });
actions.append(merge, reveal, remove); actions.append(merge, reveal, remove);
row.append(name, meta, actions); row.append(name, meta, actions);
@@ -408,7 +409,6 @@ addHostButton.addEventListener('click', () => {
newHostInput.value = ''; newHostInput.value = '';
newLabelInput.value = ''; newLabelInput.value = '';
checkResultEl.classList.add('hidden'); checkResultEl.classList.add('hidden');
return refresh();
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
renderCheckResult({ renderCheckResult({
@@ -446,7 +446,7 @@ testHostButton.addEventListener('click', () => {
autoIntervalInput.addEventListener('change', () => { autoIntervalInput.addEventListener('change', () => {
const minutes = Number(autoIntervalInput.value); const minutes = Number(autoIntervalInput.value);
if (!Number.isFinite(minutes) || minutes < 1) return; if (!Number.isFinite(minutes) || minutes < 1) return;
void api.setAutoSyncInterval(Math.floor(minutes)).then(refreshFromState); void api.setAutoSyncInterval(Math.floor(minutes));
}); });
cancelButton.addEventListener('click', () => { cancelButton.addEventListener('click', () => {
+4 -4
View File
@@ -65,15 +65,15 @@ export interface SyncUiHostUpdateRequest {
export interface SyncUiAPI { export interface SyncUiAPI {
getSnapshot: () => Promise<SyncUiSnapshot>; getSnapshot: () => Promise<SyncUiSnapshot>;
saveHost: (update: SyncUiHostUpdateRequest) => Promise<SyncHostsState>; saveHost: (update: SyncUiHostUpdateRequest) => Promise<void>;
removeHost: (host: string) => Promise<SyncHostsState>; removeHost: (host: string) => Promise<void>;
setAutoSyncInterval: (minutes: number) => Promise<SyncHostsState>; setAutoSyncInterval: (minutes: number) => Promise<void>;
runSync: (request: SyncUiRunRequest) => Promise<SyncUiStartResult>; runSync: (request: SyncUiRunRequest) => Promise<SyncUiStartResult>;
cancelRun: () => Promise<boolean>; cancelRun: () => Promise<boolean>;
checkHost: (host: string) => Promise<SyncUiCheckResult>; checkHost: (host: string) => Promise<SyncUiCheckResult>;
createSnapshot: () => Promise<SyncUiStartResult>; createSnapshot: () => Promise<SyncUiStartResult>;
mergeSnapshotFile: (path: string, force?: boolean) => Promise<SyncUiStartResult>; mergeSnapshotFile: (path: string, force?: boolean) => Promise<SyncUiStartResult>;
deleteSnapshot: (path: string) => Promise<SyncUiSnapshotFile[]>; deleteSnapshot: (path: string) => Promise<void>;
revealSnapshot: (path: string) => Promise<boolean>; revealSnapshot: (path: string) => Promise<boolean>;
pickSnapshotFile: () => Promise<string | null>; pickSnapshotFile: () => Promise<string | null>;
onProgress: (listener: (payload: SyncUiProgressPayload) => void) => () => void; onProgress: (listener: (payload: SyncUiProgressPayload) => void) => () => void;