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