mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 16:49:50 -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');
|
const makeTemp = parseCliPrograms(['sync', '--make-temp'], 'subminer');
|
||||||
assert.deepEqual(makeTemp.invocations.syncCliTokens, ['--make-temp']);
|
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']);
|
assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,3 +115,16 @@ test('parseCliPrograms captures sync --ui', () => {
|
|||||||
/--ui cannot be combined/,
|
/--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 ||
|
check ||
|
||||||
makeTemp ||
|
makeTemp ||
|
||||||
removeTemp ||
|
removeTemp ||
|
||||||
|
options.remoteCmd !== undefined ||
|
||||||
|
options.db !== undefined ||
|
||||||
options.json === true ||
|
options.json === true ||
|
||||||
options.force === 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 { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
isValidSyncHost,
|
isValidSyncHost,
|
||||||
readSyncHostsState,
|
|
||||||
removeSyncHost,
|
|
||||||
upsertSyncHost,
|
upsertSyncHost,
|
||||||
writeSyncHostsState,
|
|
||||||
type SyncDirection,
|
type SyncDirection,
|
||||||
type SyncHostsState,
|
|
||||||
} from '../../shared/sync/sync-hosts-store';
|
} from '../../shared/sync/sync-hosts-store';
|
||||||
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
|
import type { SyncUiRunRequest, SyncUiSnapshot } from '../../types/sync-ui';
|
||||||
import type {
|
import { createSyncUiHostsState } from './sync-ui-hosts-state';
|
||||||
SyncUiCheckResult,
|
import { registerSyncUiIpcHandlers } from './sync-ui-ipc-handlers';
|
||||||
SyncUiHostUpdateRequest,
|
import { createSyncUiRunLifecycle } from './sync-ui-run-lifecycle';
|
||||||
SyncUiRunKind,
|
|
||||||
SyncUiRunRequest,
|
|
||||||
SyncUiRunState,
|
|
||||||
SyncUiSnapshot,
|
|
||||||
SyncUiSnapshotFile,
|
|
||||||
SyncUiStartResult,
|
|
||||||
} from '../../types/sync-ui';
|
|
||||||
import type { SyncLauncherRunHandle, SyncLauncherRunResult } from './sync-launcher-client';
|
|
||||||
import { runSyncLauncher } from './sync-launcher-client';
|
import { runSyncLauncher } from './sync-launcher-client';
|
||||||
|
import { createSyncUiSnapshots } from './sync-ui-snapshots';
|
||||||
|
|
||||||
interface SyncUiWindowLike {
|
interface SyncUiWindowLike {
|
||||||
isDestroyed(): boolean;
|
isDestroyed(): boolean;
|
||||||
@@ -46,27 +33,7 @@ export interface SyncUiRuntimeDeps {
|
|||||||
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
|
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) {
|
export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||||
let runCounter = 0;
|
|
||||||
let currentRun: ActiveRun | null = null;
|
|
||||||
|
|
||||||
function sendToWindow(channel: string, payload?: unknown): void {
|
function sendToWindow(channel: string, payload?: unknown): void {
|
||||||
const window = deps.getWindow();
|
const window = deps.getWindow();
|
||||||
if (!window || window.isDestroyed()) return;
|
if (!window || window.isDestroyed()) return;
|
||||||
@@ -77,146 +44,35 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
|||||||
sendToWindow(IPC_CHANNELS.event.syncUiStateChanged);
|
sendToWindow(IPC_CHANNELS.event.syncUiStateChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readState(): SyncHostsState {
|
const hostsState = createSyncUiHostsState({
|
||||||
return readSyncHostsState(deps.hostsFilePath);
|
hostsFilePath: deps.hostsFilePath,
|
||||||
}
|
broadcastStateChanged,
|
||||||
|
});
|
||||||
function writeState(state: SyncHostsState): void {
|
const runLifecycle = createSyncUiRunLifecycle({
|
||||||
writeSyncHostsState(deps.hostsFilePath, state);
|
runLauncher: deps.runLauncher,
|
||||||
broadcastStateChanged();
|
resolveLauncherCommand: deps.resolveLauncherCommand,
|
||||||
}
|
log: deps.log,
|
||||||
|
notify: deps.notify,
|
||||||
function listSnapshots(): SyncUiSnapshotFile[] {
|
sendToWindow,
|
||||||
let names: string[];
|
broadcastStateChanged,
|
||||||
try {
|
});
|
||||||
names = fs.readdirSync(deps.snapshotsDir);
|
const snapshots = createSyncUiSnapshots({
|
||||||
} catch {
|
snapshotsDir: deps.snapshotsDir,
|
||||||
return [];
|
nowMs: deps.nowMs,
|
||||||
}
|
startRun: runLifecycle.startRun,
|
||||||
const files: SyncUiSnapshotFile[] = [];
|
broadcastStateChanged,
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSnapshot(): SyncUiSnapshot {
|
function getSnapshot(): SyncUiSnapshot {
|
||||||
return {
|
return {
|
||||||
dbPath: deps.getDbPath(),
|
dbPath: deps.getDbPath(),
|
||||||
hosts: readState(),
|
hosts: hostsState.readState(),
|
||||||
snapshotsDir: deps.snapshotsDir,
|
snapshotsDir: deps.snapshotsDir,
|
||||||
snapshots: listSnapshots(),
|
snapshots: snapshots.listSnapshots(),
|
||||||
run: runState(),
|
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 } = {}) {
|
function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) {
|
||||||
const host = request.host.trim();
|
const host = request.host.trim();
|
||||||
if (!isValidSyncHost(host)) {
|
if (!isValidSyncHost(host)) {
|
||||||
@@ -228,132 +84,46 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
|||||||
if (direction === 'pull') args.push('--pull');
|
if (direction === 'pull') args.push('--pull');
|
||||||
if (request.force) args.push('--force');
|
if (request.force) args.push('--force');
|
||||||
args.push('--json');
|
args.push('--json');
|
||||||
const result = startRun('host-sync', host, args, options);
|
const result = runLifecycle.startRun('host-sync', host, args, options);
|
||||||
if (result.started) {
|
if (result.started) {
|
||||||
// Remember the host (and the direction used) before the launcher's own
|
// Persist before launcher bookkeeping lands so the host appears immediately.
|
||||||
// bookkeeping lands, so it shows up in the UI immediately.
|
hostsState.writeState(
|
||||||
writeState(upsertSyncHost(readState(), { host, direction: request.direction }, deps.nowMs()));
|
upsertSyncHost(
|
||||||
|
hostsState.readState(),
|
||||||
|
{ host, direction: request.direction },
|
||||||
|
deps.nowMs(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
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 {
|
function registerHandlers(): void {
|
||||||
const channels = IPC_CHANNELS.request;
|
registerSyncUiIpcHandlers({
|
||||||
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
|
ipcMain: deps.ipcMain,
|
||||||
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
|
nowMs: deps.nowMs,
|
||||||
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, 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 {
|
return {
|
||||||
registerHandlers,
|
registerHandlers,
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
readState,
|
readState: hostsState.readState,
|
||||||
runHostSync,
|
runHostSync,
|
||||||
checkHost,
|
checkHost: runLifecycle.checkHost,
|
||||||
cancelRun,
|
cancelRun: runLifecycle.cancelRun,
|
||||||
shutdown,
|
shutdown: runLifecycle.shutdown,
|
||||||
isRunning: () => currentRun !== null,
|
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