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,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
+31 -71
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import type { Args } from '../types.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(
overrides: Partial<Args>,
@@ -11,17 +11,7 @@ function makeContext(
return {
args: {
sync: true,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
logLevel: 'warn',
...overrides,
} as Args,
@@ -35,24 +25,6 @@ function makeContext(
} 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 () => {
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
const deps: Partial<SyncCommandDeps> = {
@@ -62,7 +34,7 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
};
assert.equal(
await runSyncCommand(makeContext({ syncHost: 'media-box', syncJson: true }), deps),
await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
true,
);
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);
});
test('buildSyncCliArgv forwards every sync option', () => {
assert.deepEqual(
buildSyncCliArgv(
makeArgs({
syncHost: 'media-box',
syncDirection: 'pull',
syncRemoteCmd: '/opt/SubMiner.AppImage',
syncDbPath: '/tmp/db.sqlite',
syncForce: true,
syncJson: true,
logLevel: 'debug',
}),
),
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
const spawned: string[][] = [];
const deps: Partial<SyncCommandDeps> = {
runAppCommand: (_appPath, appArgs) => {
spawned.push(appArgs);
},
};
await runSyncCommand(
makeContext({
syncCliTokens: [
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
],
logLevel: 'debug',
}),
deps,
);
assert.deepEqual(spawned, [
[
'--sync-cli',
'sync',
@@ -103,32 +88,7 @@ test('buildSyncCliArgv forwards every sync option', () => {
'--log-level',
'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 () => {
@@ -142,7 +102,7 @@ test('runSyncCommand fails with a clear message when the app binary is missing',
};
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\)/,
);
});
+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 { runAppCommandInteractive } from '../mpv.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
export interface SyncCommandDeps {
@@ -13,46 +13,12 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
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 app (--sync-cli mode, libsql), so the launcher and the app cannot
* drift apart. The launcher contributes its parser/help and app discovery;
* the child owns the terminal and its exit code becomes the launcher's.
* SubMiner app (--sync-cli mode, libsql). The launcher contributes its
* parser/help and app discovery; the app's parseSyncCliTokens owns validation,
* 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(
context: LauncherCommandContext,
@@ -68,6 +34,12 @@ export async function runSyncCommand(
);
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;
}
+4 -44
View File
@@ -136,17 +136,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
@@ -197,17 +187,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
@@ -251,17 +231,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
@@ -303,17 +273,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
+2 -22
View File
@@ -200,17 +200,7 @@ export function createDefaultArgs(
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
syncUi: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
@@ -279,17 +269,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
}
if (invocations.syncTriggered) {
parsed.sync = true;
parsed.syncHost = invocations.syncHost ?? '';
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 ?? '';
parsed.syncCliTokens = invocations.syncCliTokens;
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
}
if (invocations.syncUiTriggered) {
+51 -30
View File
@@ -43,42 +43,63 @@ test('parseCliPrograms captures texthooker browser-open flag', () => {
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');
assert.equal(push.invocations.syncTriggered, true);
assert.deepEqual(push.invocations.syncCliTokens, ['media-box', '--push']);
const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer');
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);
assert.deepEqual(pull.invocations.syncCliTokens, ['media-box', '--pull']);
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
assert.equal(check.invocations.syncCheck, true);
assert.equal(check.invocations.syncJson, true);
assert.equal(check.invocations.syncHost, 'media-box');
assert.deepEqual(check.invocations.syncCliTokens, ['media-box', '--check', '--json']);
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', () => {
assert.throws(() => parseCliPrograms(['sync', '--check'], 'subminer'), /--check requires a host/);
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'),
/--check cannot be combined/,
);
test('parseCliPrograms leaves sync validation to the app parser', () => {
// Invalid combinations are forwarded; the app's parseSyncCliTokens rejects them.
const invalid = parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer');
assert.equal(invalid.invocations.syncTriggered, true);
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', () => {
+21 -75
View File
@@ -39,17 +39,7 @@ export interface CliInvocations {
statsCleanupLifetime: boolean;
statsLogLevel: string | null;
syncTriggered: boolean;
syncHost: string | null;
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;
syncCliTokens: string[];
syncLogLevel: string | null;
syncUiTriggered: boolean;
syncUiLogLevel: string | null;
@@ -181,17 +171,7 @@ export function parseCliPrograms(
let statsCleanupLifetime = false;
let statsLogLevel: string | null = null;
let syncTriggered = false;
let syncHost: string | null = null;
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 syncCliTokens: string[] = [];
let syncLogLevel: string | null = null;
let syncUiTriggered = false;
let syncUiLogLevel: string | null = null;
@@ -369,49 +349,25 @@ export function parseCliPrograms(
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
return;
}
if (push && pull) {
throw new Error('Sync --push and --pull cannot be combined.');
}
if ((push || pull) && !host) {
throw new Error('Sync --push and --pull require a host.');
}
if (check && !host) {
throw new Error('Sync --check requires a host.');
}
if (check && (push || pull || snapshot || merge)) {
throw new Error(
'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
);
}
if ((makeTemp || removeTemp) && (push || pull || check)) {
throw new Error('Sync --make-temp/--remove-temp cannot be combined with other sync options.');
}
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.');
}
// No validation here: the app's parseSyncCliTokens owns the sync rules
// and its error text reaches the terminal through the child's stdio.
const remoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() : '';
const dbPath = typeof options.db === 'string' ? options.db.trim() : '';
const tokens: string[] = [];
if (host) tokens.push(host);
if (snapshot) tokens.push('--snapshot', snapshot);
if (merge) tokens.push('--merge', merge);
if (makeTemp) tokens.push('--make-temp');
if (removeTemp) tokens.push('--remove-temp', removeTemp);
if (push) tokens.push('--push');
if (pull) tokens.push('--pull');
if (check) tokens.push('--check');
if (remoteCmd) tokens.push('--remote-cmd', remoteCmd);
if (dbPath) tokens.push('--db', dbPath);
if (options.force === true) tokens.push('--force');
if (options.json === true) tokens.push('--json');
syncTriggered = true;
syncHost = host || null;
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;
syncCliTokens = tokens;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
@@ -527,17 +483,7 @@ export function parseCliPrograms(
statsCleanupLifetime,
statsLogLevel,
syncTriggered,
syncHost,
syncSnapshotPath,
syncMergePath,
syncDirection,
syncRemoteCmd,
syncDbPath,
syncForce,
syncJson,
syncCheck,
syncMakeTemp,
syncRemoveTempPath,
syncCliTokens,
syncLogLevel,
syncUiTriggered,
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 { resolveConfigDir } from '../src/config/path-resolution.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js';
import { resolvePathMaybe } from './util.js';
import { resolveImmersionDbPath } from '../src/core/services/stats-sync/db-path.js';
import {
isReadonlyWalRetryError,
withReadonlyWalRetry,
} from '../src/core/services/stats-sync/wal-retry.js';
export { isReadonlyWalRetryError, 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');
}
export { isReadonlyWalRetryError, resolveImmersionDbPath, withReadonlyWalRetry };
interface RawHistoryRow {
video_id: number;
+1 -11
View File
@@ -31,17 +31,7 @@ function createArgs(): Args {
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
+1 -11
View File
@@ -603,17 +603,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncCliTokens: [],
syncUi: false,
logLevel: 'error',
logRotation: 7,
+5 -11
View File
@@ -4,17 +4,11 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js';
import { createDbSnapshot as createDbSnapshotWith } from '../../src/core/services/stats-sync/shared.js';
import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js';
// The engine only executes inside the app (libsql driver) in production, so
// 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);
// The engine executes on libsql in production; these merge tests run through
// that same driver. bun:sqlite is used only to build fixtures and inspect
// results.
import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js';
import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js';
import {
createImmersionDbFixture,
insertFixtureSession,
+2 -11
View File
@@ -114,17 +114,8 @@ export interface Args {
useRofi: boolean;
history: boolean;
sync: boolean;
syncHost: string;
syncSnapshotPath: string;
syncMergePath: string;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string;
syncDbPath: string;
syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncMakeTemp: boolean;
syncRemoveTempPath: string;
/** App-owned sync argv tokens forwarded verbatim to `--sync-cli sync`. */
syncCliTokens: string[];
syncUi: boolean;
logLevel: LogLevel;
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');
});
test('parseSyncCliTokens mirrors launcher sync validation', () => {
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
assert.equal(parseSyncCliTokens([]).kind, 'error');
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--snapshot', '/tmp/x', '--push']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--check']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--check', '--snapshot', '/tmp/x', 'h']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--snapshot', '/tmp/x']).kind, 'error');
+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
* app's --sync-cli mode. Mirrors the launcher's `subminer sync` validation so
* both entry points accept the same command lines and fail the same way.
* app's --sync-cli mode. This is the single owner of sync CLI validation:
* the launcher forwards `subminer sync` tokens verbatim, so both entry
* points accept the same command lines and fail the same way.
*/
export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' };
@@ -120,7 +121,6 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
return {
kind: 'run',
args: {
sync: true,
syncHost: host,
syncSnapshotPath: snapshot,
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 type { OpenSyncDb, SyncDb, SyncDbRunResult, SyncDbStatement } from './driver';
import type { SyncDbOpenOptions } from './wal-retry';
interface LibsqlStatement {
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
export interface SyncDbRunResult {
changes: number;
lastInsertRowid: number | bigint;
}
export interface SyncDbStatement {
run(...params: unknown[]): SyncDbRunResult;
get(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
}
export interface SyncDb {
/** Prepare (or reuse a cached prepared statement for) the given SQL. */
query(sql: string): SyncDbStatement;
/** Execute SQL that returns no rows (pragmas, transaction control). */
exec(sql: string): void;
close(): void;
}
export type SqlRow = Record<string, unknown>;
export function selectAll(db: SyncDb, sql: string, params: unknown[] = []): SqlRow[] {
return db.query(sql).all(...params) as SqlRow[];
}
export function selectOne(db: SyncDb, sql: string, params: unknown[] = []): SqlRow | undefined {
return (db.query(sql).get(...params) ?? undefined) as SqlRow | undefined;
}
interface LibsqlDatabase {
prepare(sql: string): LibsqlStatement;
prepare(sql: string): SyncDbStatement;
exec(sql: string): unknown;
close(): unknown;
}
/**
* libsql (better-sqlite3 API) binding of the SyncDb driver for the Electron
* app. prepare() is not cached by libsql, so query() keeps a per-connection
* libsql (better-sqlite3 API) SQLite connection for the stats-sync engine.
* prepare() is not cached by libsql, so query() keeps a per-connection
* statement cache — 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, {
readonly: options.readonly === true,
fileMustExist: options.create !== true,
@@ -27,22 +50,12 @@ export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => {
const statements = new Map<string, SyncDbStatement>();
return {
query(sql: string): SyncDbStatement {
const cached = statements.get(sql);
if (cached) return cached;
const statement = db.prepare(sql);
const wrapped: SyncDbStatement = {
run(...params: unknown[]): SyncDbRunResult {
return statement.run(...params);
},
get(...params: unknown[]): unknown {
return statement.get(...params);
},
all(...params: unknown[]): unknown[] {
return statement.all(...params);
},
};
statements.set(sql, wrapped);
return wrapped;
let statement = statements.get(sql);
if (!statement) {
statement = db.prepare(sql);
statements.set(sql, statement);
}
return statement;
},
exec(sql: string): void {
db.exec(sql);
@@ -52,4 +65,4 @@ export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => {
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';
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';
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 { 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';
const SESSION_COPY_COLUMNS = [
+4 -8
View File
@@ -13,7 +13,7 @@ import {
createEmptyMergeSummary,
type SyncMergeSummary,
} from './shared';
import type { OpenSyncDb, SyncDb } from './driver';
import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
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
* snapshot is a no-op.
*/
export function mergeSnapshotIntoDb(
open: OpenSyncDb,
localDbPath: string,
snapshotPath: string,
): SyncMergeSummary {
export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string): SyncMergeSummary {
if (!fs.existsSync(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}`);
}
const remote = open(snapshotPath, { readonly: true });
const remote = openLibsqlSyncDb(snapshotPath, { readonly: true });
let local: SyncDb;
try {
local = open(localDbPath, { readwrite: true, create: false });
local = openLibsqlSyncDb(localDbPath, { create: false });
} catch (error) {
remote.close();
throw error;
+6 -13
View File
@@ -2,9 +2,9 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
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 { selectOne, type OpenSyncDb, type SyncDb } from './driver';
import { openLibsqlSyncDb, selectOne, type SyncDb } from './libsql-driver';
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;
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;
@@ -78,14 +78,14 @@ export function insertRow(
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)) {
throw new Error(`Stats database not found: ${dbPath}`);
}
fs.rmSync(outPath, { force: true });
fs.mkdirSync(path.dirname(outPath), { recursive: true });
withReadonlyWalRetry(dbPath, (options) => {
const db = open(dbPath, options);
const db = openLibsqlSyncDb(dbPath, options);
try {
assertMergeableSchema(db, 'Local');
db.query('VACUUM INTO ?').run(outPath);
@@ -113,14 +113,7 @@ function isProcessAlive(pid: number): boolean {
function statsDaemonStateCandidates(dbPath: string): string[] {
const homeDir = os.homedir();
const candidates = new Set<string>([path.join(path.dirname(dbPath), 'stats-daemon.json')]);
const configDir = resolveConfigDir({
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'));
candidates.add(path.join(getDefaultConfigDir(), 'stats-daemon.json'));
if (process.platform === 'darwin') {
candidates.add(
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 { SYNC_CLI_FLAG } from './cli-args';
export interface RemoteRunResult {
status: number;
@@ -104,7 +105,7 @@ export type RemoteShellFlavor = 'posix' | 'windows-cmd' | 'windows-powershell';
*/
export function detectRemoteShellFlavor(
host: string,
runRemote: typeof runSsh = runSsh,
runRemote: (host: string, remoteCommand: string) => RemoteRunResult,
): RemoteShellFlavor {
const posixProbe = runRemote(host, 'uname -s');
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"';
// The Electron app answers the same launcher-style `sync ...` argv when
// invoked with --sync-cli, so a remote machine only needs the app installed;
// the command-line launcher is one candidate, not a requirement.
const APP_SYNC_CLI_FLAG = '--sync-cli';
interface RemoteCandidate {
invocation: string;
}
function defaultRemoteCandidates(flavor: RemoteShellFlavor): RemoteCandidate[] {
// invoked with --sync-cli (SYNC_CLI_FLAG), so a remote machine only needs the
// app installed; the command-line launcher is one candidate, not a
// requirement. Each candidate is the remote invocation to probe, in order.
function defaultRemoteCandidates(flavor: RemoteShellFlavor): string[] {
if (flavor === 'windows-cmd' || flavor === 'windows-powershell') {
// cmd.exe expands %VAR% inside double quotes; PowerShell needs $env: and
// the & call operator to run a quoted path.
@@ -166,30 +162,30 @@ function defaultRemoteCandidates(flavor: RemoteShellFlavor): RemoteCandidate[] {
: `& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe"`;
return [
// Command-line launcher shim on PATH or in its default install dir.
{ invocation: 'subminer' },
{ invocation: launcherShim },
'subminer',
launcherShim,
// The app binary itself in sync-CLI mode (default NSIS install dir).
{ invocation: `${appInstall} ${APP_SYNC_CLI_FLAG}` },
{ invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` },
`${appInstall} ${SYNC_CLI_FLAG}`,
`SubMiner ${SYNC_CLI_FLAG}`,
];
}
return [
// Command-line launcher (bun script) on PATH or in its default install dir.
{ invocation: 'subminer' },
{ invocation: '~/.local/bin/subminer' },
'subminer',
'~/.local/bin/subminer',
// The app binary itself in sync-CLI mode.
{ invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` },
{ invocation: `/Applications/SubMiner.app/Contents/MacOS/SubMiner ${APP_SYNC_CLI_FLAG}` },
{ invocation: `~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${APP_SYNC_CLI_FLAG}` },
`SubMiner ${SYNC_CLI_FLAG}`,
`/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
`~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
];
}
function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): RemoteCandidate[] {
function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): string[] {
const quoted = quoteForRemoteShell(flavor, preferred);
const invocation = flavor === 'windows-powershell' ? `& ${quoted}` : quoted;
// App binaries also answer plain --help by opening the GUI-oriented help
// path, so probe the sync-CLI shape first.
return [{ invocation: `${invocation} ${APP_SYNC_CLI_FLAG}` }, { invocation }];
return [`${invocation} ${SYNC_CLI_FLAG}`, invocation];
}
/**
@@ -210,8 +206,7 @@ export function resolveRemoteSubminerCommand(
? preferredCandidates(flavor, preferred)
: defaultRemoteCandidates(flavor);
for (const candidate of candidates) {
const command =
flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate.invocation}` : candidate.invocation;
const command = flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate}` : candidate;
const probe = runRemote(
host,
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 {
return {
args: {
sync: true,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
@@ -43,17 +42,12 @@ function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return {
createDbSnapshot: () => {},
mergeSnapshotIntoDb: () => createEmptyMergeSummary(),
formatMergeSummary: () => 'summary',
findLiveStatsDaemonPid: () => null,
assertSafeSshHost: () => {},
detectRemoteShellFlavor: () => 'posix',
resolveRemoteSubminerCommand: () => 'subminer',
runScp: () => {},
runSsh: () => ok(),
fail: (message: string): never => {
throw new Error(message);
},
log: () => {},
canConnectUnixSocket: async () => false,
realpathSync: (candidate) => candidate,
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
@@ -64,7 +58,6 @@ function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
emitEvent: () => {},
recordHostSyncResult: () => {},
resolveDefaultDbPath: () => '/tracker.sqlite',
resolvePath: (value) => value,
...overrides,
};
}
@@ -128,12 +121,9 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
},
});
assert.equal(
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
),
true,
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
assert.ok(
@@ -154,8 +144,6 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/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 {
+43 -45
View File
@@ -1,12 +1,12 @@
import os from 'node:os';
import path from 'node:path';
import { formatMergeSummary } from './merge';
import { quoteForRemoteShell } from './ssh';
import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh';
import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events';
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
export interface SyncFlowArgs {
sync: boolean;
syncHost: string;
syncSnapshotPath: string;
syncMergePath: string;
@@ -27,14 +27,14 @@ export interface SyncFlowContext {
}
/**
* Everything the sync flow touches outside plain computation. The launcher
* binds these to bun:sqlite-backed engine functions and its own logger; the
* app's --sync-cli mode binds libsql and console output. Tests stub freely.
* Process/IO seams the sync flow needs stubbed in tests: SSH/scp, the DB
* snapshot/merge engine, filesystem, and progress/bookkeeping output. The
* app's --sync-cli mode (src/main/sync-cli.ts) provides the only production
* binding; pure helpers are imported directly.
*/
export interface SyncFlowDeps {
createDbSnapshot: (dbPath: string, outPath: string) => void;
mergeSnapshotIntoDb: (localDbPath: string, snapshotPath: string) => SyncMergeSummary;
formatMergeSummary: (summary: SyncMergeSummary) => string;
findLiveStatsDaemonPid: (dbPath: string) => number | null;
assertSafeSshHost: (host: string) => void;
detectRemoteShellFlavor: (
@@ -49,8 +49,6 @@ export interface SyncFlowDeps {
) => string;
runScp: (from: string, to: string) => void;
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>;
realpathSync: (candidate: string) => string;
mkdtempSync: (prefix: string) => string;
@@ -61,7 +59,11 @@ export interface SyncFlowDeps {
emitEvent: (event: SyncProgressEvent) => void;
recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void;
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
* 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));
}
/** 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 normalizeCase = (value: string) =>
process.platform === 'win32' ? value.toLowerCase() : value;
@@ -89,18 +91,18 @@ export function assertRemovableSyncTempDir(target: string): string {
return resolved;
}
export function runMakeTempMode(deps: SyncFlowDeps): void {
function runMakeTempMode(deps: SyncFlowDeps): void {
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);
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();
return override ? deps.resolvePath(override) : deps.resolveDefaultDbPath();
return override ? resolveCliPath(override) : deps.resolveDefaultDbPath();
}
function isTrackerDb(dbPath: string, deps: SyncFlowDeps): boolean {
@@ -123,12 +125,12 @@ export async function ensureTrackerQuiescentFlow(
if (!isTrackerDb(dbPath, deps)) return;
const daemonPid = deps.findLiveStatsDaemonPid(dbPath);
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.`,
);
}
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.`,
);
}
@@ -136,7 +138,7 @@ export async function ensureTrackerQuiescentFlow(
// 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.
export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
const writeLine = deps.consoleLog;
return {
...deps,
@@ -146,13 +148,13 @@ export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
};
}
export async function runSnapshotMode(
async function runSnapshotMode(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const outPath = deps.resolvePath(context.args.syncSnapshotPath);
const outPath = resolveCliPath(context.args.syncSnapshotPath);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
@@ -163,13 +165,13 @@ export async function runSnapshotMode(
deps.consoleLog(outPath);
}
export async function runMergeMode(
async function runMergeMode(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = deps.resolvePath(context.args.syncMergePath);
const snapshotPath = resolveCliPath(context.args.syncMergePath);
deps.emitEvent({
type: 'stage',
stage: 'merge-local',
@@ -177,7 +179,7 @@ export async function runMergeMode(
});
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(deps.formatMergeSummary(summary));
deps.consoleLog(formatMergeSummary(summary));
}
function formatHostSyncDetail(
@@ -296,7 +298,9 @@ export async function runHostSync(
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor);
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);
let remoteTmpDir = '';
@@ -365,7 +369,7 @@ export async function runHostSync(
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
pulledSummary = summary;
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(deps.formatMergeSummary(summary));
deps.consoleLog(formatMergeSummary(summary));
}
if (shouldPush) {
@@ -425,34 +429,29 @@ export async function runHostSync(
export async function runSyncFlow(
context: SyncFlowContext,
inputDeps: SyncFlowDeps,
): Promise<boolean> {
): Promise<void> {
let deps = inputDeps;
const { args } = context;
if (!args.sync) return false;
if (args.syncJson) deps = withJsonEvents(deps);
try {
if (args.syncMakeTemp) {
runMakeTempMode(deps);
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
return true;
}
if (args.syncRemoveTempPath) {
} else if (args.syncRemoveTempPath) {
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 {
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) {
if (args.syncJson) {
@@ -465,5 +464,4 @@ export async function runSyncFlow(
throw error;
}
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 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
+2 -1
View File
@@ -536,6 +536,7 @@ import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-set
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
import { createSyncAutoScheduler } from './main/runtime/sync-auto-scheduler';
import { resolveSyncLauncherCommand, runSyncLauncher } from './main/runtime/sync-launcher-client';
import { getSyncHostsPath } from './shared/sync/sync-hosts-store';
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
import {
isSameYoutubeMediaPath,
@@ -2254,7 +2255,7 @@ const openSyncUiWindow = () => openSyncUiWindowHandler();
const syncUiRuntime = createSyncUiRuntime({
ipcMain,
hostsFilePath: path.join(USER_DATA_PATH, 'sync-hosts.json'),
hostsFilePath: getSyncHostsPath(USER_DATA_PATH),
snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'),
getDbPath: () => {
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: () => {},
spawn,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
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.match(result.error ?? '', /cancel/i);
});
@@ -160,12 +170,18 @@ test('runSyncLauncher times out a child that never completes', async () => {
spawn,
timeoutMs: 1,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.equal(children[0]!.killed, true);
assert.equal(child.killed, true);
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',
appPath: null,
});
assert.deepEqual(packaged.command, ['/opt/SubMiner/subminer-app', '--sync-cli']);
assert.equal(packaged.error, null);
assert.deepEqual(packaged, ['/opt/SubMiner/subminer-app', '--sync-cli']);
const dev = resolveSyncLauncherCommand({
execPath: '/usr/bin/electron',
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>;
}
export interface SyncLauncherResolution {
command: string[] | null;
error: string | null;
}
// Sync runs in a child copy of this app in headless --sync-cli mode: same
// engine and NDJSON protocol as `subminer sync --json`, with no dependency on
// bun or an installed command-line launcher. In dev runs process.execPath is
@@ -40,13 +35,10 @@ export function resolveSyncLauncherCommand(
execPath?: string;
appPath?: string | null;
} = {},
): SyncLauncherResolution {
): string[] {
const execPath = deps.execPath ?? process.execPath;
const appPath = deps.appPath ?? null;
return {
command: appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG],
error: null,
};
return appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG];
}
export function runSyncLauncher(options: {
@@ -142,7 +134,7 @@ export function runSyncLauncher(options: {
child.on('exit', (code) => {
exitObserved = true;
exitCode = code;
if (resultEvent) settleFromExit(code);
if (resultEvent || terminationError) settleFromExit(code);
});
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'),
snapshotsDir: path.join(root, 'snapshots'),
getDbPath: () => path.join(root, 'immersion.sqlite'),
resolveLauncherCommand: () => ({ command: ['subminer'], error: null }),
resolveLauncherCommand: () => ['subminer'],
runLauncher: (options): SyncLauncherRunHandle => {
let finish: (result: { ok: boolean; error: string | null }) => void = () => {};
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', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
const saved = (await invoke('sync-ui:save-host', {
const { invoke, sent } = makeTestRig(root);
await invoke('sync-ui:save-host', {
host: 'user@laptop',
label: 'Laptop',
direction: 'pull',
autoSync: true,
})) as { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
assert.equal(saved.hosts.length, 1);
assert.equal(saved.hosts[0]!.direction, 'pull');
assert.equal(saved.hosts[0]!.autoSync, true);
const removed = (await invoke('sync-ui:remove-host', 'user@laptop')) as {
hosts: unknown[];
});
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const saved = (await invoke('sync-ui:get-snapshot')) as {
hosts: { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
};
assert.equal(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', () =>
@@ -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', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
const { invoke, sent } = makeTestRig(root);
fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true });
const inside = path.join(root, 'snapshots', 'immersion-1.sqlite');
fs.writeFileSync(inside, 'x');
@@ -203,10 +206,13 @@ test('delete-snapshot refuses paths outside the snapshots dir', () =>
fs.writeFileSync(outside, 'x');
await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside));
const remaining = (await invoke('sync-ui:delete-snapshot', inside)) as unknown[];
assert.equal(remaining.length, 0);
await invoke('sync-ui:delete-snapshot', inside);
assert.equal(fs.existsSync(inside), false);
assert.equal(fs.existsSync(outside), true);
// The renderer relies on the state-changed broadcast to refresh its list.
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const snapshot = (await invoke('sync-ui:get-snapshot')) as { snapshots: unknown[] };
assert.equal(snapshot.snapshots.length, 0);
}));
test('check-host resolves with the check-result event', () =>
+67 -86
View File
@@ -21,14 +21,10 @@ import type {
SyncUiSnapshotFile,
SyncUiStartResult,
} from '../../types/sync-ui';
import type {
SyncLauncherResolution,
SyncLauncherRunHandle,
SyncLauncherRunResult,
} from './sync-launcher-client';
import type { SyncLauncherRunHandle, SyncLauncherRunResult } from './sync-launcher-client';
import { runSyncLauncher } from './sync-launcher-client';
export interface SyncUiWindowLike {
interface SyncUiWindowLike {
isDestroyed(): boolean;
webContents: { send(channel: string, payload?: unknown): void };
}
@@ -40,7 +36,7 @@ export interface SyncUiRuntimeDeps {
hostsFilePath: string;
snapshotsDir: string;
getDbPath: () => string;
resolveLauncherCommand: () => SyncLauncherResolution;
resolveLauncherCommand: () => string[];
runLauncher: typeof runSyncLauncher;
getWindow: () => SyncUiWindowLike | 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
// 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 stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`;
@@ -85,10 +81,9 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
return readSyncHostsState(deps.hostsFilePath);
}
function writeState(state: SyncHostsState): SyncHostsState {
function writeState(state: SyncHostsState): void {
writeSyncHostsState(deps.hostsFilePath, state);
broadcastStateChanged();
return state;
}
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,
host: string | null,
args: string[],
options: { notify?: boolean } = {},
): SyncUiStartResult {
options: {
notify?: boolean;
timeoutMs?: number;
emitProgress?: boolean;
onEvent?: (event: SyncProgressEvent) => void;
} = {},
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
if (currentRun) {
return {
started: false,
runId: null,
reason: 'A sync operation is already running.',
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
done: null,
};
}
const resolution = deps.resolveLauncherCommand();
if (!resolution.command) {
return { started: false, runId: null, reason: resolution.error };
}
const emitProgress = options.emitProgress ?? true;
runCounter += 1;
const runId = runCounter;
const run: ActiveRun = {
@@ -160,11 +159,15 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
host,
resultSeen: false,
handle: deps.runLauncher({
command: resolution.command,
command: deps.resolveLauncherCommand(),
args,
timeoutMs: options.timeoutMs,
onEvent: (event: SyncProgressEvent) => {
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()}`),
}),
@@ -175,7 +178,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
if (currentRun?.id === runId) currentRun = null;
// If the launcher died without emitting a result event (spawn failure,
// kill), synthesize one so the renderer can settle its progress view.
if (!run.resultSeen) {
if (emitProgress && !run.resultSeen) {
sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
runId,
kind,
@@ -202,7 +205,16 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
`[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 } = {}) {
@@ -238,57 +250,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
if (!isValidSyncHost(trimmed)) {
return Promise.resolve(failed(`Invalid sync host: ${host}`));
}
if (currentRun) {
return Promise.resolve(failed('A sync operation is already running.'));
}
const resolution = deps.resolveLauncherCommand();
if (!resolution.command) {
return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.'));
}
return new Promise((resolve) => {
let checkResult: SyncUiCheckResult | null = null;
runCounter += 1;
const runId = runCounter;
const handle = deps.runLauncher({
command: resolution.command!,
args: ['sync', trimmed, '--check', '--json'],
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)}`,
);
});
let checkResult: SyncUiCheckResult | null = null;
const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], {
timeoutMs: 30_000,
emitProgress: false,
onEvent: (event) => {
if (event.type === 'check-result') {
checkResult = {
host: event.host,
sshOk: event.sshOk,
remoteCommand: event.remoteCommand,
remoteVersion: event.remoteVersion,
ok: event.ok,
error: event.error,
};
}
},
});
if (!start.started || !done) {
return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.'));
}
return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.'));
}
function createSnapshot(): SyncUiStartResult {
@@ -306,7 +288,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
return startRun('merge', null, args);
}
function deleteSnapshot(filePath: string): SyncUiSnapshotFile[] {
function deleteSnapshot(filePath: string): void {
const resolved = path.resolve(filePath);
const dir = path.resolve(deps.snapshotsDir);
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
@@ -314,7 +296,6 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
}
fs.rmSync(resolved, { force: true });
broadcastStateChanged();
return listSnapshots();
}
function cancelRun(): boolean {
@@ -333,18 +314,18 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
function registerHandlers(): void {
const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) =>
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs())),
);
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) =>
writeState(removeSyncHost(readState(), String(host))),
);
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs()));
});
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
writeState(removeSyncHost(readState(), String(host)));
});
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
const value = Number(minutes);
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
}
return writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
});
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
runHostSync(request as SyncUiRunRequest),
@@ -355,9 +336,9 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
mergeSnapshotFile(String(filePath), force === true),
);
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) =>
deleteSnapshot(String(filePath)),
);
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
deleteSnapshot(String(filePath));
});
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
deps.revealPath(String(filePath));
return true;
+1 -23
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files';
import { canConnectSocket } from '../../shared/socket-probe';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import { buildMpvMsgLevel } from '../../shared/mpv-logging-args';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
@@ -93,28 +93,6 @@ async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}
async function waitForSocketReady(socketPath: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
+9 -84
View File
@@ -1,17 +1,15 @@
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 { canConnectSocket } from '../shared/socket-probe';
import { getDefaultConfigDir } from '../shared/setup-state';
import {
extractSyncCliTokens,
parseSyncCliTokens,
syncCliUsage,
} from '../core/services/stats-sync/cli-args';
import { resolveImmersionDbPath } from '../core/services/stats-sync/db-path';
import { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared';
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
import { mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
import {
assertSafeSshHost,
detectRemoteShellFlavor,
@@ -19,7 +17,6 @@ import {
runScp,
runSsh,
} from '../core/services/stats-sync/ssh';
import { openLibsqlSyncDb } from '../core/services/stats-sync/libsql-driver';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
@@ -40,75 +37,13 @@ export function shouldHandleSyncCliAtEntry(
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(
host: string,
status: 'success' | 'error',
detail: string | null,
): void {
try {
const filePath = getSyncHostsPath(resolveAppConfigDir());
const filePath = getSyncHostsPath(getDefaultConfigDir());
const state = recordSyncResult(readSyncHostsState(filePath), host, {
atMs: Date.now(),
status,
@@ -122,24 +57,15 @@ function recordHostSyncResultToDisk(
function buildSyncCliDeps(): SyncFlowDeps {
const deps: SyncFlowDeps = {
createDbSnapshot: (dbPath, outPath) => createDbSnapshot(openLibsqlSyncDb, dbPath, outPath),
mergeSnapshotIntoDb: (localDbPath, snapshotPath) =>
mergeSnapshotIntoDb(openLibsqlSyncDb, localDbPath, snapshotPath),
formatMergeSummary,
createDbSnapshot,
mergeSnapshotIntoDb,
findLiveStatsDaemonPid,
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
fail: (message: string): never => {
throw new Error(message);
},
log: (level, configured, message) => {
if (level === 'debug' && configured !== 'debug') return;
console.error(message);
},
canConnectUnixSocket,
canConnectUnixSocket: canConnectSocket,
realpathSync: (candidate) => fs.realpathSync(candidate),
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options),
@@ -149,8 +75,7 @@ function buildSyncCliDeps(): SyncFlowDeps {
ensureTrackerQuiescentFlow(context, dbPath, deps),
emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk,
resolveDefaultDbPath,
resolvePath: resolveTildePath,
resolveDefaultDbPath: resolveImmersionDbPath,
};
return deps;
}
+4 -6
View File
@@ -1,25 +1,23 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import type {
SyncHostsState,
SyncUiAPI,
SyncUiCheckResult,
SyncUiHostUpdateRequest,
SyncUiProgressPayload,
SyncUiRunRequest,
SyncUiSnapshot,
SyncUiSnapshotFile,
SyncUiStartResult,
} from './types/sync-ui';
const syncUiAPI: SyncUiAPI = {
getSnapshot: (): Promise<SyncUiSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot),
saveHost: (update: SyncUiHostUpdateRequest): Promise<SyncHostsState> =>
saveHost: (update: SyncUiHostUpdateRequest): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update),
removeHost: (host: string): Promise<SyncHostsState> =>
removeHost: (host: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host),
setAutoSyncInterval: (minutes: number): Promise<SyncHostsState> =>
setAutoSyncInterval: (minutes: number): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSetAutoSyncInterval, minutes),
runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request),
@@ -30,7 +28,7 @@ const syncUiAPI: SyncUiAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot),
mergeSnapshotFile: (path: string, force?: boolean): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiMergeSnapshotFile, path, force === true),
deleteSnapshot: (path: string): Promise<SyncUiSnapshotFile[]> =>
deleteSnapshot: (path: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiDeleteSnapshot, path),
revealSnapshot: (path: string): Promise<boolean> =>
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);
}
.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 {
padding: 14px 4px 4px;
font-size: 13px;
+7 -7
View File
@@ -167,8 +167,8 @@ function handleProgress(payload: SyncUiProgressPayload): void {
} else if (event.type === 'merge-summary') {
appendMergeSummary(event.target, summarizeMergeCounts(event.summary));
} else if (event.type === 'result') {
// The run's completion handler broadcasts state-changed, which refreshes.
showResult(event.ok, event.error);
void refresh();
}
}
@@ -240,7 +240,8 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
button.title = direction.title;
button.classList.toggle('active', entry.direction === direction.id);
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);
}
@@ -251,7 +252,7 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
autoBox.type = 'checkbox';
autoBox.checked = entry.autoSync;
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');
autoText.textContent = 'Auto-sync';
@@ -288,7 +289,7 @@ function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
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);
@@ -367,7 +368,7 @@ function renderSnapshots(): void {
remove.textContent = 'Delete';
remove.addEventListener('click', () => {
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);
row.append(name, meta, actions);
@@ -408,7 +409,6 @@ addHostButton.addEventListener('click', () => {
newHostInput.value = '';
newLabelInput.value = '';
checkResultEl.classList.add('hidden');
return refresh();
})
.catch((error: unknown) => {
renderCheckResult({
@@ -446,7 +446,7 @@ testHostButton.addEventListener('click', () => {
autoIntervalInput.addEventListener('change', () => {
const minutes = Number(autoIntervalInput.value);
if (!Number.isFinite(minutes) || minutes < 1) return;
void api.setAutoSyncInterval(Math.floor(minutes)).then(refreshFromState);
void api.setAutoSyncInterval(Math.floor(minutes));
});
cancelButton.addEventListener('click', () => {
+4 -4
View File
@@ -65,15 +65,15 @@ export interface SyncUiHostUpdateRequest {
export interface SyncUiAPI {
getSnapshot: () => Promise<SyncUiSnapshot>;
saveHost: (update: SyncUiHostUpdateRequest) => Promise<SyncHostsState>;
removeHost: (host: string) => Promise<SyncHostsState>;
setAutoSyncInterval: (minutes: number) => Promise<SyncHostsState>;
saveHost: (update: SyncUiHostUpdateRequest) => Promise<void>;
removeHost: (host: string) => Promise<void>;
setAutoSyncInterval: (minutes: number) => Promise<void>;
runSync: (request: SyncUiRunRequest) => Promise<SyncUiStartResult>;
cancelRun: () => Promise<boolean>;
checkHost: (host: string) => Promise<SyncUiCheckResult>;
createSnapshot: () => Promise<SyncUiStartResult>;
mergeSnapshotFile: (path: string, force?: boolean) => Promise<SyncUiStartResult>;
deleteSnapshot: (path: string) => Promise<SyncUiSnapshotFile[]>;
deleteSnapshot: (path: string) => Promise<void>;
revealSnapshot: (path: string) => Promise<boolean>;
pickSnapshotFile: () => Promise<string | null>;
onProgress: (listener: (payload: SyncUiProgressPayload) => void) => () => void;