feat(sync-ui): defer app quit until async cleanup resolves

- check-host participates in active-run coordination and can be cancelled
- shutdown() cancels and awaits the active launcher run on quit
- onWillQuit calls preventDefault and re-triggers quit once cleanup settles
- Snapshot mode awaits tracker quiescent before writing output
- Auto-scheduler catches synchronous triggerHostSync failures
- SCP endpoint accepts Windows absolute paths; cmd.exe rejects % in quoted values
- runAppCommand unified (inherit vs pipe stdio) in launcher/mpv.ts
- Docs: add --check, --json, --ui, --sync-cli, --make-temp/--remove-temp examples
This commit is contained in:
2026-07-12 03:35:39 -07:00
parent f8c10edce0
commit a013a7ea55
30 changed files with 356 additions and 135 deletions
+1 -1
View File
@@ -8,8 +8,8 @@ import { selectOne, type OpenSyncDb, type SyncDb } from './driver';
export { SCHEMA_VERSION };
export type { SyncMergeSummary } from '../../../shared/sync/sync-events';
import type { SyncMergeSummary } from '../../../shared/sync/sync-events';
export type { SyncMergeSummary };
export function createEmptyMergeSummary(): SyncMergeSummary {
return {
+25 -10
View File
@@ -64,10 +64,15 @@ test('resolveRemoteSubminerCommand verifies the launcher under the remote runtim
test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => {
const probed: string[] = [];
const command = resolveRemoteSubminerCommand('media-box', null, 'posix', (_host, remoteCommand) => {
probed.push(remoteCommand);
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1);
});
const command = resolveRemoteSubminerCommand(
'media-box',
null,
'posix',
(_host, remoteCommand) => {
probed.push(remoteCommand);
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1);
},
);
assert.match(command, / SubMiner --sync-cli$/);
// Launcher candidates (PATH + ~/.local/bin) are tried before app binaries.
@@ -77,13 +82,19 @@ test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mo
});
test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => {
const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', 'posix', (_host, cmd) =>
remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
const asApp = resolveRemoteSubminerCommand(
'media-box',
'/opt/SubMiner.AppImage',
'posix',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
);
assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/);
const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', 'posix', (_host, cmd) =>
remoteResult(cmd.includes('--sync-cli') ? 1 : 0),
const asLauncher = resolveRemoteSubminerCommand(
'media-box',
'/opt/subminer',
'posix',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 1 : 0),
);
assert.match(asLauncher, /'\/opt\/subminer'$/);
@@ -104,8 +115,11 @@ test('resolveRemoteSubminerCommand probes Windows install locations without a PA
assert.equal(probed[0], 'subminer --help');
assert.equal(probed[1], '"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd" --help');
const powershell = resolveRemoteSubminerCommand('win-box', null, 'windows-powershell', (_host, cmd) =>
remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1),
const powershell = resolveRemoteSubminerCommand(
'win-box',
null,
'windows-powershell',
(_host, cmd) => remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1),
);
assert.equal(powershell, '& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe" --sync-cli');
});
@@ -158,6 +172,7 @@ test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values',
'"C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab"',
);
assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/);
assert.throws(() => quoteForRemoteShell('windows-cmd', 'C:/tmp/%TEMP%/db.sqlite'), /percent/);
assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/);
});
+4
View File
@@ -35,6 +35,7 @@ export function runSsh(host: string, remoteCommand: string): RemoteRunResult {
}
function assertSafeScpEndpoint(endpoint: string): void {
if (/^[A-Za-z]:[\\/]/.test(endpoint)) return;
const colon = endpoint.indexOf(':');
const slash = endpoint.indexOf('/');
if (colon <= 0 || (slash !== -1 && slash < colon)) {
@@ -116,6 +117,9 @@ export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): s
if (value.includes('"')) {
throw new Error(`Refusing to quote a value with quotes for a Windows shell: ${value}`);
}
if (value.includes('%')) {
throw new Error(`Refusing to quote a value with percent signs for cmd.exe: ${value}`);
}
return `"${value}"`;
}
+22 -5
View File
@@ -136,6 +136,9 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
true,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
assert.ok(
calls.indexOf('quiescent') < calls.indexOf('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'),
);
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
@@ -200,7 +203,8 @@ test('runHostSync keeps tracker quiescent through both merges and cleans up afte
});
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
() =>
runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
);
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
@@ -222,7 +226,8 @@ test('runHostSync includes remote snapshot stderr in failures', async () => {
});
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
() =>
runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
);
});
@@ -357,7 +362,12 @@ test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
});
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncCheck: true, syncJson: true }),
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
deps,
);
@@ -374,7 +384,12 @@ test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
const failLines: string[] = [];
await assert.rejects(() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncCheck: true, syncJson: true }),
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
makeDeps({
consoleLog: (line) => {
failLines.push(line);
@@ -424,7 +439,9 @@ test('runHostSync speaks Windows shells: app command, double quotes, temp protoc
assert.ok(scpCalls.some((call) => call.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`)));
assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`)));
// No POSIX shell-isms reach a Windows remote.
assert.ok(!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')));
assert.ok(
!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')),
);
assert.ok(!sshCommands.some((command) => command.includes('PATH="$HOME')));
});
+8 -4
View File
@@ -145,11 +145,12 @@ export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
};
}
export function runSnapshotMode(
export async function runSnapshotMode(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): void {
): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const outPath = deps.resolvePath(context.args.syncSnapshotPath);
deps.emitEvent({
type: 'stage',
@@ -409,7 +410,10 @@ export async function runHostSync(
}
}
export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowDeps): Promise<boolean> {
export async function runSyncFlow(
context: SyncFlowContext,
inputDeps: SyncFlowDeps,
): Promise<boolean> {
let deps = inputDeps;
const { args } = context;
if (!args.sync) return false;
@@ -430,7 +434,7 @@ export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowD
if (args.syncCheck) {
await runCheckMode(context, deps);
} else if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
await runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {