mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
refactor(sync-ui): split runtime into focused modules
- Extract sync-ui-runtime.ts into sync-ui-hosts-state, sync-ui-ipc-handlers, sync-ui-run-lifecycle, sync-ui-snapshots - Reject sync --ui combined with --remote-cmd or --db in cli-parser-builder
This commit is contained in:
@@ -87,7 +87,10 @@ test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => {
|
||||
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');
|
||||
const removeTemp = parseCliPrograms(
|
||||
['sync', '--remove-temp', '/tmp/subminer-sync-x'],
|
||||
'subminer',
|
||||
);
|
||||
assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']);
|
||||
});
|
||||
|
||||
@@ -112,3 +115,16 @@ test('parseCliPrograms captures sync --ui', () => {
|
||||
/--ui cannot be combined/,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseCliPrograms rejects sync --ui with --remote-cmd', () => {
|
||||
assert.throws(
|
||||
() => parseCliPrograms(['sync', '--ui', '--remote-cmd', '/opt/SubMiner.AppImage'], 'subminer'),
|
||||
{ message: 'Sync --ui cannot be combined with other sync options.' },
|
||||
);
|
||||
});
|
||||
|
||||
test('parseCliPrograms rejects sync --ui with --db', () => {
|
||||
assert.throws(() => parseCliPrograms(['sync', '--ui', '--db', '/tmp/db.sqlite'], 'subminer'), {
|
||||
message: 'Sync --ui cannot be combined with other sync options.',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,6 +340,8 @@ export function parseCliPrograms(
|
||||
check ||
|
||||
makeTemp ||
|
||||
removeTemp ||
|
||||
options.remoteCmd !== undefined ||
|
||||
options.db !== undefined ||
|
||||
options.json === true ||
|
||||
options.force === true
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
readSyncHostsState,
|
||||
writeSyncHostsState,
|
||||
type SyncHostsState,
|
||||
} from '../../shared/sync/sync-hosts-store';
|
||||
|
||||
interface SyncUiHostsStateDeps {
|
||||
hostsFilePath: string;
|
||||
broadcastStateChanged: () => void;
|
||||
}
|
||||
|
||||
export function createSyncUiHostsState(deps: SyncUiHostsStateDeps) {
|
||||
function readState(): SyncHostsState {
|
||||
return readSyncHostsState(deps.hostsFilePath);
|
||||
}
|
||||
|
||||
function writeState(state: SyncHostsState): void {
|
||||
writeSyncHostsState(deps.hostsFilePath, state);
|
||||
deps.broadcastStateChanged();
|
||||
}
|
||||
|
||||
return { readState, writeState };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { removeSyncHost, upsertSyncHost } from '../../shared/sync/sync-hosts-store';
|
||||
import type {
|
||||
SyncUiHostUpdateRequest,
|
||||
SyncUiRunRequest,
|
||||
SyncUiSnapshot,
|
||||
SyncUiStartResult,
|
||||
} from '../../types/sync-ui';
|
||||
|
||||
interface SyncUiIpcHandlersDeps {
|
||||
ipcMain: {
|
||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
||||
};
|
||||
nowMs: () => number;
|
||||
revealPath: (targetPath: string) => void;
|
||||
pickSnapshotFile: () => Promise<string | null>;
|
||||
getSnapshot: () => SyncUiSnapshot;
|
||||
readState: () => SyncUiSnapshot['hosts'];
|
||||
writeState: (state: SyncUiSnapshot['hosts']) => void;
|
||||
runHostSync: (request: SyncUiRunRequest) => SyncUiStartResult;
|
||||
cancelRun: () => boolean;
|
||||
checkHost: (host: string) => unknown;
|
||||
createSnapshot: () => SyncUiStartResult;
|
||||
mergeSnapshotFile: (filePath: string, force?: boolean) => SyncUiStartResult;
|
||||
deleteSnapshot: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function registerSyncUiIpcHandlers(deps: SyncUiIpcHandlersDeps): void {
|
||||
const channels = IPC_CHANNELS.request;
|
||||
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => deps.getSnapshot());
|
||||
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
|
||||
deps.writeState(
|
||||
upsertSyncHost(deps.readState(), update as SyncUiHostUpdateRequest, deps.nowMs()),
|
||||
);
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
|
||||
deps.writeState(removeSyncHost(deps.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.');
|
||||
}
|
||||
deps.writeState({ ...deps.readState(), autoSyncIntervalMinutes: Math.floor(value) });
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
|
||||
deps.runHostSync(request as SyncUiRunRequest),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiCancelRun, () => deps.cancelRun());
|
||||
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => deps.checkHost(String(host)));
|
||||
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => deps.createSnapshot());
|
||||
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
|
||||
deps.mergeSnapshotFile(String(filePath), force === true),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
|
||||
deps.deleteSnapshot(String(filePath));
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
|
||||
deps.revealPath(String(filePath));
|
||||
return true;
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { isValidSyncHost } from '../../shared/sync/sync-hosts-store';
|
||||
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||
import type {
|
||||
SyncUiCheckResult,
|
||||
SyncUiRunKind,
|
||||
SyncUiRunState,
|
||||
SyncUiStartResult,
|
||||
} from '../../types/sync-ui';
|
||||
import {
|
||||
runSyncLauncher,
|
||||
type SyncLauncherRunHandle,
|
||||
type SyncLauncherRunResult,
|
||||
} from './sync-launcher-client';
|
||||
|
||||
interface ActiveRun {
|
||||
id: number;
|
||||
kind: SyncUiRunKind;
|
||||
host: string | null;
|
||||
handle: SyncLauncherRunHandle;
|
||||
resultSeen: boolean;
|
||||
completion?: Promise<void>;
|
||||
}
|
||||
|
||||
interface SyncUiRunLifecycleDeps {
|
||||
runLauncher: typeof runSyncLauncher;
|
||||
resolveLauncherCommand: () => string[];
|
||||
log?: (message: string) => void;
|
||||
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
|
||||
sendToWindow: (channel: string, payload?: unknown) => void;
|
||||
broadcastStateChanged: () => void;
|
||||
}
|
||||
|
||||
export function createSyncUiRunLifecycle(deps: SyncUiRunLifecycleDeps) {
|
||||
let runCounter = 0;
|
||||
let currentRun: ActiveRun | null = null;
|
||||
|
||||
function launchRun(
|
||||
kind: SyncUiRunKind,
|
||||
host: string | null,
|
||||
args: string[],
|
||||
options: {
|
||||
notify?: boolean;
|
||||
timeoutMs?: number;
|
||||
emitProgress?: boolean;
|
||||
onEvent?: (event: SyncProgressEvent) => void;
|
||||
} = {},
|
||||
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
|
||||
if (currentRun) {
|
||||
return {
|
||||
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
|
||||
done: null,
|
||||
};
|
||||
}
|
||||
const emitProgress = options.emitProgress ?? true;
|
||||
runCounter += 1;
|
||||
const runId = runCounter;
|
||||
const run: ActiveRun = {
|
||||
id: runId,
|
||||
kind,
|
||||
host,
|
||||
resultSeen: false,
|
||||
handle: deps.runLauncher({
|
||||
command: deps.resolveLauncherCommand(),
|
||||
args,
|
||||
timeoutMs: options.timeoutMs,
|
||||
onEvent: (event: SyncProgressEvent) => {
|
||||
if (event.type === 'result') run.resultSeen = true;
|
||||
options.onEvent?.(event);
|
||||
if (emitProgress) {
|
||||
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
|
||||
}
|
||||
},
|
||||
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
|
||||
}),
|
||||
};
|
||||
currentRun = run;
|
||||
run.completion = run.handle.done
|
||||
.then((result: SyncLauncherRunResult) => {
|
||||
if (currentRun?.id === runId) currentRun = null;
|
||||
if (emitProgress && !run.resultSeen) {
|
||||
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
|
||||
runId,
|
||||
kind,
|
||||
host,
|
||||
event: { type: 'result', ok: result.ok, error: result.error },
|
||||
});
|
||||
}
|
||||
deps.broadcastStateChanged();
|
||||
if (options.notify && deps.notify) {
|
||||
const target = host ?? 'local database';
|
||||
deps.notify(
|
||||
result.ok
|
||||
? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' }
|
||||
: {
|
||||
title: 'Sync failed',
|
||||
body: `${target}: ${result.error ?? 'unknown error'}`,
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
deps.log?.(
|
||||
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
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 checkHost(host: string): Promise<SyncUiCheckResult> {
|
||||
const trimmed = host.trim();
|
||||
const failed = (error: string): SyncUiCheckResult => ({
|
||||
host: trimmed,
|
||||
sshOk: false,
|
||||
remoteCommand: null,
|
||||
remoteVersion: null,
|
||||
ok: false,
|
||||
error,
|
||||
});
|
||||
if (!isValidSyncHost(trimmed)) {
|
||||
return Promise.resolve(failed(`Invalid sync host: ${host}`));
|
||||
}
|
||||
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 cancelRun(): boolean {
|
||||
if (!currentRun) return false;
|
||||
currentRun.handle.cancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
const run = currentRun;
|
||||
if (!run) return;
|
||||
run.handle.cancel();
|
||||
await (run.completion ?? run.handle.done.then(() => undefined));
|
||||
}
|
||||
|
||||
function runState(): SyncUiRunState {
|
||||
return currentRun
|
||||
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
|
||||
: { running: false, runId: null, kind: null, host: null };
|
||||
}
|
||||
|
||||
return {
|
||||
startRun,
|
||||
checkHost,
|
||||
cancelRun,
|
||||
shutdown,
|
||||
runState,
|
||||
isRunning: () => currentRun !== null,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
isValidSyncHost,
|
||||
readSyncHostsState,
|
||||
removeSyncHost,
|
||||
upsertSyncHost,
|
||||
writeSyncHostsState,
|
||||
type SyncDirection,
|
||||
type SyncHostsState,
|
||||
} from '../../shared/sync/sync-hosts-store';
|
||||
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||
import type {
|
||||
SyncUiCheckResult,
|
||||
SyncUiHostUpdateRequest,
|
||||
SyncUiRunKind,
|
||||
SyncUiRunRequest,
|
||||
SyncUiRunState,
|
||||
SyncUiSnapshot,
|
||||
SyncUiSnapshotFile,
|
||||
SyncUiStartResult,
|
||||
} from '../../types/sync-ui';
|
||||
import type { SyncLauncherRunHandle, SyncLauncherRunResult } from './sync-launcher-client';
|
||||
import type { SyncUiRunRequest, SyncUiSnapshot } from '../../types/sync-ui';
|
||||
import { createSyncUiHostsState } from './sync-ui-hosts-state';
|
||||
import { registerSyncUiIpcHandlers } from './sync-ui-ipc-handlers';
|
||||
import { createSyncUiRunLifecycle } from './sync-ui-run-lifecycle';
|
||||
import { runSyncLauncher } from './sync-launcher-client';
|
||||
import { createSyncUiSnapshots } from './sync-ui-snapshots';
|
||||
|
||||
interface SyncUiWindowLike {
|
||||
isDestroyed(): boolean;
|
||||
@@ -46,27 +33,7 @@ export interface SyncUiRuntimeDeps {
|
||||
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
|
||||
}
|
||||
|
||||
interface ActiveRun {
|
||||
id: number;
|
||||
kind: SyncUiRunKind;
|
||||
host: string | null;
|
||||
handle: SyncLauncherRunHandle;
|
||||
resultSeen: boolean;
|
||||
completion?: Promise<void>;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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`;
|
||||
}
|
||||
|
||||
export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
let runCounter = 0;
|
||||
let currentRun: ActiveRun | null = null;
|
||||
|
||||
function sendToWindow(channel: string, payload?: unknown): void {
|
||||
const window = deps.getWindow();
|
||||
if (!window || window.isDestroyed()) return;
|
||||
@@ -77,146 +44,35 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiStateChanged);
|
||||
}
|
||||
|
||||
function readState(): SyncHostsState {
|
||||
return readSyncHostsState(deps.hostsFilePath);
|
||||
}
|
||||
|
||||
function writeState(state: SyncHostsState): void {
|
||||
writeSyncHostsState(deps.hostsFilePath, state);
|
||||
broadcastStateChanged();
|
||||
}
|
||||
|
||||
function listSnapshots(): SyncUiSnapshotFile[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(deps.snapshotsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const files: SyncUiSnapshotFile[] = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.sqlite')) continue;
|
||||
const filePath = path.join(deps.snapshotsDir, name);
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) continue;
|
||||
files.push({
|
||||
path: filePath,
|
||||
name,
|
||||
sizeBytes: stat.size,
|
||||
modifiedAtMs: stat.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
// file disappeared between readdir and stat
|
||||
}
|
||||
}
|
||||
files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
|
||||
return files;
|
||||
}
|
||||
|
||||
function runState(): SyncUiRunState {
|
||||
return currentRun
|
||||
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
|
||||
: { running: false, runId: null, kind: null, host: null };
|
||||
}
|
||||
const hostsState = createSyncUiHostsState({
|
||||
hostsFilePath: deps.hostsFilePath,
|
||||
broadcastStateChanged,
|
||||
});
|
||||
const runLifecycle = createSyncUiRunLifecycle({
|
||||
runLauncher: deps.runLauncher,
|
||||
resolveLauncherCommand: deps.resolveLauncherCommand,
|
||||
log: deps.log,
|
||||
notify: deps.notify,
|
||||
sendToWindow,
|
||||
broadcastStateChanged,
|
||||
});
|
||||
const snapshots = createSyncUiSnapshots({
|
||||
snapshotsDir: deps.snapshotsDir,
|
||||
nowMs: deps.nowMs,
|
||||
startRun: runLifecycle.startRun,
|
||||
broadcastStateChanged,
|
||||
});
|
||||
|
||||
function getSnapshot(): SyncUiSnapshot {
|
||||
return {
|
||||
dbPath: deps.getDbPath(),
|
||||
hosts: readState(),
|
||||
hosts: hostsState.readState(),
|
||||
snapshotsDir: deps.snapshotsDir,
|
||||
snapshots: listSnapshots(),
|
||||
run: runState(),
|
||||
snapshots: snapshots.listSnapshots(),
|
||||
run: runLifecycle.runState(),
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
timeoutMs?: number;
|
||||
emitProgress?: boolean;
|
||||
onEvent?: (event: SyncProgressEvent) => void;
|
||||
} = {},
|
||||
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
|
||||
if (currentRun) {
|
||||
return {
|
||||
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
|
||||
done: null,
|
||||
};
|
||||
}
|
||||
const emitProgress = options.emitProgress ?? true;
|
||||
runCounter += 1;
|
||||
const runId = runCounter;
|
||||
const run: ActiveRun = {
|
||||
id: runId,
|
||||
kind,
|
||||
host,
|
||||
resultSeen: false,
|
||||
handle: deps.runLauncher({
|
||||
command: deps.resolveLauncherCommand(),
|
||||
args,
|
||||
timeoutMs: options.timeoutMs,
|
||||
onEvent: (event: SyncProgressEvent) => {
|
||||
if (event.type === 'result') run.resultSeen = true;
|
||||
options.onEvent?.(event);
|
||||
if (emitProgress) {
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
|
||||
}
|
||||
},
|
||||
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
|
||||
}),
|
||||
};
|
||||
currentRun = run;
|
||||
run.completion = run.handle.done
|
||||
.then((result: SyncLauncherRunResult) => {
|
||||
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 (emitProgress && !run.resultSeen) {
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
|
||||
runId,
|
||||
kind,
|
||||
host,
|
||||
event: { type: 'result', ok: result.ok, error: result.error },
|
||||
});
|
||||
}
|
||||
broadcastStateChanged();
|
||||
if (options.notify && deps.notify) {
|
||||
const target = host ?? 'local database';
|
||||
deps.notify(
|
||||
result.ok
|
||||
? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' }
|
||||
: {
|
||||
title: 'Sync failed',
|
||||
body: `${target}: ${result.error ?? 'unknown error'}`,
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
deps.log?.(
|
||||
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
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 } = {}) {
|
||||
const host = request.host.trim();
|
||||
if (!isValidSyncHost(host)) {
|
||||
@@ -228,132 +84,46 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
if (direction === 'pull') args.push('--pull');
|
||||
if (request.force) args.push('--force');
|
||||
args.push('--json');
|
||||
const result = startRun('host-sync', host, args, options);
|
||||
const result = runLifecycle.startRun('host-sync', host, args, options);
|
||||
if (result.started) {
|
||||
// Remember the host (and the direction used) before the launcher's own
|
||||
// bookkeeping lands, so it shows up in the UI immediately.
|
||||
writeState(upsertSyncHost(readState(), { host, direction: request.direction }, deps.nowMs()));
|
||||
// Persist before launcher bookkeeping lands so the host appears immediately.
|
||||
hostsState.writeState(
|
||||
upsertSyncHost(
|
||||
hostsState.readState(),
|
||||
{ host, direction: request.direction },
|
||||
deps.nowMs(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkHost(host: string): Promise<SyncUiCheckResult> {
|
||||
const trimmed = host.trim();
|
||||
const failed = (error: string): SyncUiCheckResult => ({
|
||||
host: trimmed,
|
||||
sshOk: false,
|
||||
remoteCommand: null,
|
||||
remoteVersion: null,
|
||||
ok: false,
|
||||
error,
|
||||
});
|
||||
if (!isValidSyncHost(trimmed)) {
|
||||
return Promise.resolve(failed(`Invalid sync host: ${host}`));
|
||||
}
|
||||
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 {
|
||||
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
|
||||
return startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']);
|
||||
}
|
||||
|
||||
function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` };
|
||||
}
|
||||
const args = ['sync', '--merge', filePath];
|
||||
if (force) args.push('--force');
|
||||
args.push('--json');
|
||||
return startRun('merge', null, args);
|
||||
}
|
||||
|
||||
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')) {
|
||||
throw new Error('Refusing to delete a file outside the snapshots directory.');
|
||||
}
|
||||
fs.rmSync(resolved, { force: true });
|
||||
broadcastStateChanged();
|
||||
}
|
||||
|
||||
function cancelRun(): boolean {
|
||||
if (!currentRun) return false;
|
||||
currentRun.handle.cancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
const run = currentRun;
|
||||
if (!run) return;
|
||||
run.handle.cancel();
|
||||
await (run.completion ?? run.handle.done.then(() => undefined));
|
||||
}
|
||||
|
||||
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()));
|
||||
registerSyncUiIpcHandlers({
|
||||
ipcMain: deps.ipcMain,
|
||||
nowMs: deps.nowMs,
|
||||
revealPath: deps.revealPath,
|
||||
pickSnapshotFile: deps.pickSnapshotFile,
|
||||
getSnapshot,
|
||||
readState: hostsState.readState,
|
||||
writeState: hostsState.writeState,
|
||||
runHostSync,
|
||||
cancelRun: runLifecycle.cancelRun,
|
||||
checkHost: runLifecycle.checkHost,
|
||||
createSnapshot: snapshots.createSnapshot,
|
||||
mergeSnapshotFile: snapshots.mergeSnapshotFile,
|
||||
deleteSnapshot: snapshots.deleteSnapshot,
|
||||
});
|
||||
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.');
|
||||
}
|
||||
writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
|
||||
runHostSync(request as SyncUiRunRequest),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiCancelRun, () => cancelRun());
|
||||
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => checkHost(String(host)));
|
||||
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => createSnapshot());
|
||||
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.syncUiRevealSnapshot, (_event, filePath) => {
|
||||
deps.revealPath(String(filePath));
|
||||
return true;
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
|
||||
}
|
||||
|
||||
return {
|
||||
registerHandlers,
|
||||
getSnapshot,
|
||||
readState,
|
||||
readState: hostsState.readState,
|
||||
runHostSync,
|
||||
checkHost,
|
||||
cancelRun,
|
||||
shutdown,
|
||||
isRunning: () => currentRun !== null,
|
||||
checkHost: runLifecycle.checkHost,
|
||||
cancelRun: runLifecycle.cancelRun,
|
||||
shutdown: runLifecycle.shutdown,
|
||||
isRunning: runLifecycle.isRunning,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { SyncUiSnapshotFile, SyncUiStartResult } from '../../types/sync-ui';
|
||||
|
||||
interface SyncUiSnapshotsDeps {
|
||||
snapshotsDir: string;
|
||||
nowMs: () => number;
|
||||
startRun: (kind: 'snapshot' | 'merge', host: null, args: string[]) => SyncUiStartResult;
|
||||
broadcastStateChanged: () => void;
|
||||
}
|
||||
|
||||
function formatSnapshotName(nowMs: number): string {
|
||||
// Milliseconds prevent snapshots taken in the same second from overwriting each other.
|
||||
const iso = new Date(nowMs).toISOString();
|
||||
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
|
||||
return `immersion-${stamp}.sqlite`;
|
||||
}
|
||||
|
||||
export function createSyncUiSnapshots(deps: SyncUiSnapshotsDeps) {
|
||||
function listSnapshots(): SyncUiSnapshotFile[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(deps.snapshotsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const files: SyncUiSnapshotFile[] = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.sqlite')) continue;
|
||||
const filePath = path.join(deps.snapshotsDir, name);
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) continue;
|
||||
files.push({
|
||||
path: filePath,
|
||||
name,
|
||||
sizeBytes: stat.size,
|
||||
modifiedAtMs: stat.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
// file disappeared between readdir and stat
|
||||
}
|
||||
}
|
||||
files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
|
||||
return files;
|
||||
}
|
||||
|
||||
function createSnapshot(): SyncUiStartResult {
|
||||
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
|
||||
return deps.startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']);
|
||||
}
|
||||
|
||||
function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` };
|
||||
}
|
||||
const args = ['sync', '--merge', filePath];
|
||||
if (force) args.push('--force');
|
||||
args.push('--json');
|
||||
return deps.startRun('merge', null, args);
|
||||
}
|
||||
|
||||
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')) {
|
||||
throw new Error('Refusing to delete a file outside the snapshots directory.');
|
||||
}
|
||||
fs.rmSync(resolved, { force: true });
|
||||
deps.broadcastStateChanged();
|
||||
}
|
||||
|
||||
return { listSnapshots, createSnapshot, mergeSnapshotFile, deleteSnapshot };
|
||||
}
|
||||
Reference in New Issue
Block a user