mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
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:
@@ -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,
|
||||
|
||||
@@ -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\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user