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:
@@ -143,10 +143,20 @@ test('runSyncLauncher cancel kills the child and resolves as cancelled', async (
|
||||
onEvent: () => {},
|
||||
spawn,
|
||||
});
|
||||
const child = children[0]!;
|
||||
child.kill = () => {
|
||||
child.killed = true;
|
||||
child.emit('exit', null, 'SIGTERM');
|
||||
return true;
|
||||
};
|
||||
handle.cancel();
|
||||
assert.equal(children[0]!.killed, true);
|
||||
assert.equal(child.killed, true);
|
||||
|
||||
const result = await handle.done;
|
||||
const result = await Promise.race([
|
||||
handle.done,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
|
||||
]);
|
||||
assert.ok(result);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /cancel/i);
|
||||
});
|
||||
@@ -160,12 +170,18 @@ test('runSyncLauncher times out a child that never completes', async () => {
|
||||
spawn,
|
||||
timeoutMs: 1,
|
||||
});
|
||||
const child = children[0]!;
|
||||
child.kill = () => {
|
||||
child.killed = true;
|
||||
child.emit('exit', null, 'SIGTERM');
|
||||
return true;
|
||||
};
|
||||
|
||||
const result = await Promise.race([
|
||||
handle.done,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
|
||||
]);
|
||||
assert.equal(children[0]!.killed, true);
|
||||
assert.equal(child.killed, true);
|
||||
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
|
||||
});
|
||||
|
||||
@@ -190,12 +206,11 @@ test('resolveSyncLauncherCommand self-spawns the app in --sync-cli mode', async
|
||||
execPath: '/opt/SubMiner/subminer-app',
|
||||
appPath: null,
|
||||
});
|
||||
assert.deepEqual(packaged.command, ['/opt/SubMiner/subminer-app', '--sync-cli']);
|
||||
assert.equal(packaged.error, null);
|
||||
assert.deepEqual(packaged, ['/opt/SubMiner/subminer-app', '--sync-cli']);
|
||||
|
||||
const dev = resolveSyncLauncherCommand({
|
||||
execPath: '/usr/bin/electron',
|
||||
appPath: '/home/u/SubMiner',
|
||||
});
|
||||
assert.deepEqual(dev.command, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']);
|
||||
assert.deepEqual(dev, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']);
|
||||
});
|
||||
|
||||
@@ -26,11 +26,6 @@ export interface SyncLauncherRunHandle {
|
||||
done: Promise<SyncLauncherRunResult>;
|
||||
}
|
||||
|
||||
export interface SyncLauncherResolution {
|
||||
command: string[] | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Sync runs in a child copy of this app in headless --sync-cli mode: same
|
||||
// engine and NDJSON protocol as `subminer sync --json`, with no dependency on
|
||||
// bun or an installed command-line launcher. In dev runs process.execPath is
|
||||
@@ -40,13 +35,10 @@ export function resolveSyncLauncherCommand(
|
||||
execPath?: string;
|
||||
appPath?: string | null;
|
||||
} = {},
|
||||
): SyncLauncherResolution {
|
||||
): string[] {
|
||||
const execPath = deps.execPath ?? process.execPath;
|
||||
const appPath = deps.appPath ?? null;
|
||||
return {
|
||||
command: appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG],
|
||||
error: null,
|
||||
};
|
||||
return appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG];
|
||||
}
|
||||
|
||||
export function runSyncLauncher(options: {
|
||||
@@ -142,7 +134,7 @@ export function runSyncLauncher(options: {
|
||||
child.on('exit', (code) => {
|
||||
exitObserved = true;
|
||||
exitCode = code;
|
||||
if (resultEvent) settleFromExit(code);
|
||||
if (resultEvent || terminationError) settleFromExit(code);
|
||||
});
|
||||
child.on('close', settleFromExit);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
|
||||
hostsFilePath: path.join(root, 'sync-hosts.json'),
|
||||
snapshotsDir: path.join(root, 'snapshots'),
|
||||
getDbPath: () => path.join(root, 'immersion.sqlite'),
|
||||
resolveLauncherCommand: () => ({ command: ['subminer'], error: null }),
|
||||
resolveLauncherCommand: () => ['subminer'],
|
||||
runLauncher: (options): SyncLauncherRunHandle => {
|
||||
let finish: (result: { ok: boolean; error: string | null }) => void = () => {};
|
||||
const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => {
|
||||
@@ -106,21 +106,24 @@ test('registerHandlers registers every sync-ui request channel', () =>
|
||||
|
||||
test('save/remove host round-trips through the hosts file', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke } = makeTestRig(root);
|
||||
const saved = (await invoke('sync-ui:save-host', {
|
||||
const { invoke, sent } = makeTestRig(root);
|
||||
await invoke('sync-ui:save-host', {
|
||||
host: 'user@laptop',
|
||||
label: 'Laptop',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
})) as { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
|
||||
assert.equal(saved.hosts.length, 1);
|
||||
assert.equal(saved.hosts[0]!.direction, 'pull');
|
||||
assert.equal(saved.hosts[0]!.autoSync, true);
|
||||
|
||||
const removed = (await invoke('sync-ui:remove-host', 'user@laptop')) as {
|
||||
hosts: unknown[];
|
||||
});
|
||||
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
|
||||
const saved = (await invoke('sync-ui:get-snapshot')) as {
|
||||
hosts: { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
|
||||
};
|
||||
assert.equal(removed.hosts.length, 0);
|
||||
assert.equal(saved.hosts.hosts.length, 1);
|
||||
assert.equal(saved.hosts.hosts[0]!.direction, 'pull');
|
||||
assert.equal(saved.hosts.hosts[0]!.autoSync, true);
|
||||
|
||||
await invoke('sync-ui:remove-host', 'user@laptop');
|
||||
const removed = (await invoke('sync-ui:get-snapshot')) as { hosts: { hosts: unknown[] } };
|
||||
assert.equal(removed.hosts.hosts.length, 0);
|
||||
}));
|
||||
|
||||
test('save-host rejects invalid hosts', () =>
|
||||
@@ -195,7 +198,7 @@ test('create-snapshot runs the launcher with a timestamped path under the snapsh
|
||||
|
||||
test('delete-snapshot refuses paths outside the snapshots dir', () =>
|
||||
withTempDir(async (root) => {
|
||||
const { invoke } = makeTestRig(root);
|
||||
const { invoke, sent } = makeTestRig(root);
|
||||
fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true });
|
||||
const inside = path.join(root, 'snapshots', 'immersion-1.sqlite');
|
||||
fs.writeFileSync(inside, 'x');
|
||||
@@ -203,10 +206,13 @@ test('delete-snapshot refuses paths outside the snapshots dir', () =>
|
||||
fs.writeFileSync(outside, 'x');
|
||||
|
||||
await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside));
|
||||
const remaining = (await invoke('sync-ui:delete-snapshot', inside)) as unknown[];
|
||||
assert.equal(remaining.length, 0);
|
||||
await invoke('sync-ui:delete-snapshot', inside);
|
||||
assert.equal(fs.existsSync(inside), false);
|
||||
assert.equal(fs.existsSync(outside), true);
|
||||
// The renderer relies on the state-changed broadcast to refresh its list.
|
||||
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
|
||||
const snapshot = (await invoke('sync-ui:get-snapshot')) as { snapshots: unknown[] };
|
||||
assert.equal(snapshot.snapshots.length, 0);
|
||||
}));
|
||||
|
||||
test('check-host resolves with the check-result event', () =>
|
||||
|
||||
@@ -21,14 +21,10 @@ import type {
|
||||
SyncUiSnapshotFile,
|
||||
SyncUiStartResult,
|
||||
} from '../../types/sync-ui';
|
||||
import type {
|
||||
SyncLauncherResolution,
|
||||
SyncLauncherRunHandle,
|
||||
SyncLauncherRunResult,
|
||||
} from './sync-launcher-client';
|
||||
import type { SyncLauncherRunHandle, SyncLauncherRunResult } from './sync-launcher-client';
|
||||
import { runSyncLauncher } from './sync-launcher-client';
|
||||
|
||||
export interface SyncUiWindowLike {
|
||||
interface SyncUiWindowLike {
|
||||
isDestroyed(): boolean;
|
||||
webContents: { send(channel: string, payload?: unknown): void };
|
||||
}
|
||||
@@ -40,7 +36,7 @@ export interface SyncUiRuntimeDeps {
|
||||
hostsFilePath: string;
|
||||
snapshotsDir: string;
|
||||
getDbPath: () => string;
|
||||
resolveLauncherCommand: () => SyncLauncherResolution;
|
||||
resolveLauncherCommand: () => string[];
|
||||
runLauncher: typeof runSyncLauncher;
|
||||
getWindow: () => SyncUiWindowLike | null;
|
||||
pickSnapshotFile: () => Promise<string | null>;
|
||||
@@ -61,7 +57,7 @@ interface ActiveRun {
|
||||
|
||||
// Milliseconds are part of the stamp so two snapshots taken in the same second
|
||||
// do not land on the same path and silently overwrite each other.
|
||||
export function formatSnapshotName(nowMs: number): string {
|
||||
function formatSnapshotName(nowMs: number): string {
|
||||
const iso = new Date(nowMs).toISOString();
|
||||
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
|
||||
return `immersion-${stamp}.sqlite`;
|
||||
@@ -85,10 +81,9 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
return readSyncHostsState(deps.hostsFilePath);
|
||||
}
|
||||
|
||||
function writeState(state: SyncHostsState): SyncHostsState {
|
||||
function writeState(state: SyncHostsState): void {
|
||||
writeSyncHostsState(deps.hostsFilePath, state);
|
||||
broadcastStateChanged();
|
||||
return state;
|
||||
}
|
||||
|
||||
function listSnapshots(): SyncUiSnapshotFile[] {
|
||||
@@ -135,23 +130,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
function startRun(
|
||||
// Shared run lifecycle: single-run mutex, launcher spawn, cleanup + state
|
||||
// broadcast on completion. `emitProgress: false` runs silently (no renderer
|
||||
// progress events); `onEvent` taps every NDJSON event either way.
|
||||
function launchRun(
|
||||
kind: SyncUiRunKind,
|
||||
host: string | null,
|
||||
args: string[],
|
||||
options: { notify?: boolean } = {},
|
||||
): SyncUiStartResult {
|
||||
options: {
|
||||
notify?: boolean;
|
||||
timeoutMs?: number;
|
||||
emitProgress?: boolean;
|
||||
onEvent?: (event: SyncProgressEvent) => void;
|
||||
} = {},
|
||||
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
|
||||
if (currentRun) {
|
||||
return {
|
||||
started: false,
|
||||
runId: null,
|
||||
reason: 'A sync operation is already running.',
|
||||
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
|
||||
done: null,
|
||||
};
|
||||
}
|
||||
const resolution = deps.resolveLauncherCommand();
|
||||
if (!resolution.command) {
|
||||
return { started: false, runId: null, reason: resolution.error };
|
||||
}
|
||||
const emitProgress = options.emitProgress ?? true;
|
||||
runCounter += 1;
|
||||
const runId = runCounter;
|
||||
const run: ActiveRun = {
|
||||
@@ -160,11 +159,15 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
host,
|
||||
resultSeen: false,
|
||||
handle: deps.runLauncher({
|
||||
command: resolution.command,
|
||||
command: deps.resolveLauncherCommand(),
|
||||
args,
|
||||
timeoutMs: options.timeoutMs,
|
||||
onEvent: (event: SyncProgressEvent) => {
|
||||
if (event.type === 'result') run.resultSeen = true;
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
|
||||
options.onEvent?.(event);
|
||||
if (emitProgress) {
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
|
||||
}
|
||||
},
|
||||
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
|
||||
}),
|
||||
@@ -175,7 +178,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
if (currentRun?.id === runId) currentRun = null;
|
||||
// If the launcher died without emitting a result event (spawn failure,
|
||||
// kill), synthesize one so the renderer can settle its progress view.
|
||||
if (!run.resultSeen) {
|
||||
if (emitProgress && !run.resultSeen) {
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
|
||||
runId,
|
||||
kind,
|
||||
@@ -202,7 +205,16 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
return { started: true, runId, reason: null };
|
||||
return { start: { started: true, runId, reason: null }, done: run.handle.done };
|
||||
}
|
||||
|
||||
function startRun(
|
||||
kind: SyncUiRunKind,
|
||||
host: string | null,
|
||||
args: string[],
|
||||
options: { notify?: boolean } = {},
|
||||
): SyncUiStartResult {
|
||||
return launchRun(kind, host, args, options).start;
|
||||
}
|
||||
|
||||
function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) {
|
||||
@@ -238,57 +250,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
if (!isValidSyncHost(trimmed)) {
|
||||
return Promise.resolve(failed(`Invalid sync host: ${host}`));
|
||||
}
|
||||
if (currentRun) {
|
||||
return Promise.resolve(failed('A sync operation is already running.'));
|
||||
}
|
||||
const resolution = deps.resolveLauncherCommand();
|
||||
if (!resolution.command) {
|
||||
return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.'));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let checkResult: SyncUiCheckResult | null = null;
|
||||
runCounter += 1;
|
||||
const runId = runCounter;
|
||||
const handle = deps.runLauncher({
|
||||
command: resolution.command!,
|
||||
args: ['sync', trimmed, '--check', '--json'],
|
||||
timeoutMs: 30_000,
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'check-result') {
|
||||
checkResult = {
|
||||
host: event.host,
|
||||
sshOk: event.sshOk,
|
||||
remoteCommand: event.remoteCommand,
|
||||
remoteVersion: event.remoteVersion,
|
||||
ok: event.ok,
|
||||
error: event.error,
|
||||
};
|
||||
}
|
||||
},
|
||||
onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`),
|
||||
});
|
||||
const run: ActiveRun = {
|
||||
id: runId,
|
||||
kind: 'check',
|
||||
host: trimmed,
|
||||
handle,
|
||||
resultSeen: false,
|
||||
};
|
||||
currentRun = run;
|
||||
run.completion = handle.done
|
||||
.then((result) => {
|
||||
if (currentRun?.id === runId) currentRun = null;
|
||||
resolve(checkResult ?? failed(result.error ?? 'Connection check failed.'));
|
||||
broadcastStateChanged();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (currentRun?.id === runId) currentRun = null;
|
||||
resolve(failed(error instanceof Error ? error.message : String(error)));
|
||||
deps.log?.(
|
||||
`[sync-ui check] Cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
let checkResult: SyncUiCheckResult | null = null;
|
||||
const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], {
|
||||
timeoutMs: 30_000,
|
||||
emitProgress: false,
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'check-result') {
|
||||
checkResult = {
|
||||
host: event.host,
|
||||
sshOk: event.sshOk,
|
||||
remoteCommand: event.remoteCommand,
|
||||
remoteVersion: event.remoteVersion,
|
||||
ok: event.ok,
|
||||
error: event.error,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!start.started || !done) {
|
||||
return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.'));
|
||||
}
|
||||
return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.'));
|
||||
}
|
||||
|
||||
function createSnapshot(): SyncUiStartResult {
|
||||
@@ -306,7 +288,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
return startRun('merge', null, args);
|
||||
}
|
||||
|
||||
function deleteSnapshot(filePath: string): SyncUiSnapshotFile[] {
|
||||
function deleteSnapshot(filePath: string): void {
|
||||
const resolved = path.resolve(filePath);
|
||||
const dir = path.resolve(deps.snapshotsDir);
|
||||
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
|
||||
@@ -314,7 +296,6 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
}
|
||||
fs.rmSync(resolved, { force: true });
|
||||
broadcastStateChanged();
|
||||
return listSnapshots();
|
||||
}
|
||||
|
||||
function cancelRun(): boolean {
|
||||
@@ -333,18 +314,18 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
function registerHandlers(): void {
|
||||
const channels = IPC_CHANNELS.request;
|
||||
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
|
||||
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) =>
|
||||
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs())),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) =>
|
||||
writeState(removeSyncHost(readState(), String(host))),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
|
||||
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs()));
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
|
||||
writeState(removeSyncHost(readState(), String(host)));
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
|
||||
const value = Number(minutes);
|
||||
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
|
||||
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
|
||||
}
|
||||
return writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
|
||||
writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
|
||||
runHostSync(request as SyncUiRunRequest),
|
||||
@@ -355,9 +336,9 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
|
||||
mergeSnapshotFile(String(filePath), force === true),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) =>
|
||||
deleteSnapshot(String(filePath)),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
|
||||
deleteSnapshot(String(filePath));
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
|
||||
deps.revealPath(String(filePath));
|
||||
return true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { isLogFileEnabled } from '../../shared/log-files';
|
||||
import { canConnectSocket } from '../../shared/socket-probe';
|
||||
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
||||
import { buildMpvMsgLevel } from '../../shared/mpv-logging-args';
|
||||
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
|
||||
@@ -93,28 +93,6 @@ async function sleepMs(ms: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function canConnectSocket(socketPath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(400, () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSocketReady(socketPath: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
+9
-84
@@ -1,17 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { resolveConfigDir, resolveConfigFilePath } from '../config/path-resolution';
|
||||
import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path';
|
||||
import { canConnectSocket } from '../shared/socket-probe';
|
||||
import { getDefaultConfigDir } from '../shared/setup-state';
|
||||
import {
|
||||
extractSyncCliTokens,
|
||||
parseSyncCliTokens,
|
||||
syncCliUsage,
|
||||
} from '../core/services/stats-sync/cli-args';
|
||||
import { resolveImmersionDbPath } from '../core/services/stats-sync/db-path';
|
||||
import { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared';
|
||||
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
|
||||
import { mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
|
||||
import {
|
||||
assertSafeSshHost,
|
||||
detectRemoteShellFlavor,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
runScp,
|
||||
runSsh,
|
||||
} from '../core/services/stats-sync/ssh';
|
||||
import { openLibsqlSyncDb } from '../core/services/stats-sync/libsql-driver';
|
||||
import {
|
||||
ensureTrackerQuiescentFlow,
|
||||
runSyncFlow,
|
||||
@@ -40,75 +37,13 @@ export function shouldHandleSyncCliAtEntry(
|
||||
return extractSyncCliTokens(argv) !== null;
|
||||
}
|
||||
|
||||
function resolveAppConfigDir(): string {
|
||||
return resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTildePath(input: string): string {
|
||||
return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input);
|
||||
}
|
||||
|
||||
// Same resolution as the launcher: honor a configured immersionTracking.dbPath
|
||||
// (raw config read, tolerant of comments), else <configDir>/immersion.sqlite.
|
||||
function resolveDefaultDbPath(): string {
|
||||
const configPath = resolveConfigFilePath({
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
let configured = '';
|
||||
try {
|
||||
const data = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
||||
const tracking =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>).immersionTracking
|
||||
: null;
|
||||
if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) {
|
||||
const dbPath = (tracking as Record<string, unknown>).dbPath;
|
||||
if (typeof dbPath === 'string') configured = dbPath.trim();
|
||||
}
|
||||
} catch {
|
||||
// no config or unreadable config → default location
|
||||
}
|
||||
if (configured) return resolveTildePath(configured);
|
||||
return path.join(resolveAppConfigDir(), 'immersion.sqlite');
|
||||
}
|
||||
|
||||
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let settled = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(400, () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function recordHostSyncResultToDisk(
|
||||
host: string,
|
||||
status: 'success' | 'error',
|
||||
detail: string | null,
|
||||
): void {
|
||||
try {
|
||||
const filePath = getSyncHostsPath(resolveAppConfigDir());
|
||||
const filePath = getSyncHostsPath(getDefaultConfigDir());
|
||||
const state = recordSyncResult(readSyncHostsState(filePath), host, {
|
||||
atMs: Date.now(),
|
||||
status,
|
||||
@@ -122,24 +57,15 @@ function recordHostSyncResultToDisk(
|
||||
|
||||
function buildSyncCliDeps(): SyncFlowDeps {
|
||||
const deps: SyncFlowDeps = {
|
||||
createDbSnapshot: (dbPath, outPath) => createDbSnapshot(openLibsqlSyncDb, dbPath, outPath),
|
||||
mergeSnapshotIntoDb: (localDbPath, snapshotPath) =>
|
||||
mergeSnapshotIntoDb(openLibsqlSyncDb, localDbPath, snapshotPath),
|
||||
formatMergeSummary,
|
||||
createDbSnapshot,
|
||||
mergeSnapshotIntoDb,
|
||||
findLiveStatsDaemonPid,
|
||||
assertSafeSshHost,
|
||||
detectRemoteShellFlavor,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
runSsh,
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: (level, configured, message) => {
|
||||
if (level === 'debug' && configured !== 'debug') return;
|
||||
console.error(message);
|
||||
},
|
||||
canConnectUnixSocket,
|
||||
canConnectUnixSocket: canConnectSocket,
|
||||
realpathSync: (candidate) => fs.realpathSync(candidate),
|
||||
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
|
||||
rmSync: (target, options) => fs.rmSync(target, options),
|
||||
@@ -149,8 +75,7 @@ function buildSyncCliDeps(): SyncFlowDeps {
|
||||
ensureTrackerQuiescentFlow(context, dbPath, deps),
|
||||
emitEvent: () => {},
|
||||
recordHostSyncResult: recordHostSyncResultToDisk,
|
||||
resolveDefaultDbPath,
|
||||
resolvePath: resolveTildePath,
|
||||
resolveDefaultDbPath: resolveImmersionDbPath,
|
||||
};
|
||||
return deps;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user