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
@@ -13,7 +13,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
@@ -16,7 +16,9 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
+4 -3
View File
@@ -6,7 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void;
@@ -34,7 +34,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
return (): Promise<void> => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -42,7 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.stopSyncAutoScheduler();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -72,6 +72,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService();
return Promise.resolve(stopSyncAutoScheduler);
};
}
@@ -19,7 +19,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -26,7 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
@@ -15,7 +15,9 @@ test('app lifecycle runtime runner main deps builder maps lifecycle callbacks',
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
@@ -23,7 +23,9 @@ test('startup runtime handlers compose lifecycle runner and startup bootstrap ap
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
@@ -91,6 +91,28 @@ test('tick triggers at most one host per tick', () => {
assert.equal(triggered.length, 1);
});
test('tick logs a synchronous trigger failure and remains usable', () => {
const state = makeState();
const logs: string[] = [];
let attempts = 0;
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
canAutoSync: () => true,
triggerHostSync: () => {
attempts += 1;
throw new Error('spawn exploded');
},
nowMs: () => 100 * 60_000,
log: (message) => logs.push(message),
});
assert.doesNotThrow(() => scheduler.tick());
assert.doesNotThrow(() => scheduler.tick());
assert.equal(attempts, 2);
assert.ok(logs.some((message) => message.includes('spawn exploded')));
});
test('start/stop manage the interval timer', () => {
const state = makeState();
let setCalls = 0;
+7 -1
View File
@@ -30,7 +30,13 @@ export function createSyncAutoScheduler(deps: SyncAutoSchedulerDeps) {
);
if (!due) return;
deps.log?.(`Auto-sync starting for ${due.host} (${due.direction})`);
deps.triggerHostSync(due.host, due.direction);
try {
deps.triggerHostSync(due.host, due.direction);
} catch (error) {
deps.log?.(
`Auto-sync failed to start: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function start(): void {
+49 -1
View File
@@ -14,7 +14,7 @@ interface LauncherCall {
cancelled: boolean;
}
function makeTestRig(root: string) {
function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
const launcherCalls: LauncherCall[] = [];
const sent: Array<{ channel: string; payload: unknown }> = [];
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
@@ -59,6 +59,7 @@ function makeTestRig(root: string) {
pickSnapshotFile: async () => null,
revealPath: () => {},
nowMs: () => 1700000000000,
...overrides,
};
const runtime = createSyncUiRuntime(deps);
@@ -230,6 +231,53 @@ test('check-host resolves with the check-result event', () =>
assert.equal(result.sshOk, true);
}));
test('check-host participates in active-run coordination and can be cancelled', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{ ok: boolean }>;
await new Promise((resolve) => setImmediate(resolve));
const blocked = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
started: boolean;
reason: string | null;
};
assert.equal(blocked.started, false);
assert.match(blocked.reason ?? '', /already running/i);
assert.equal(await invoke('sync-ui:cancel-run'), true);
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal((await pending).ok, false);
}));
test('shutdown cancels and awaits the active launcher run', () =>
withTempDir(async (root) => {
const { runtime, invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:create-snapshot');
await runtime.shutdown();
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal(runtime.isRunning(), false);
}));
test('post-run side-effect failures are logged without rejecting cleanup', () =>
withTempDir(async (root) => {
const logs: string[] = [];
const { invoke, launcherCalls } = makeTestRig(root, {
getWindow: () => ({
isDestroyed: () => false,
webContents: {
send: () => {
throw new Error('window gone');
},
},
}),
log: (message) => logs.push(message),
});
await invoke('sync-ui:create-snapshot');
launcherCalls[0]!.finish({ ok: true, error: null });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(logs.some((message) => message.includes('window gone')));
}));
test('cancel-run cancels the active run', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
+66 -28
View File
@@ -56,6 +56,7 @@ interface ActiveRun {
host: string | null;
handle: SyncLauncherRunHandle;
resultSeen: boolean;
completion?: Promise<void>;
}
// Milliseconds are part of the stamp so two snapshots taken in the same second
@@ -169,32 +170,38 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
}),
};
currentRun = run;
void 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 (!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',
},
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 (!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 { started: true, runId, reason: null };
}
@@ -231,12 +238,17 @@ 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'],
@@ -254,9 +266,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
},
onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`),
});
void handle.done.then((result) => {
resolve(checkResult ?? failed(result.error ?? 'Connection check failed.'));
});
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)}`,
);
});
});
}
@@ -292,6 +322,13 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
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());
@@ -334,6 +371,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
runHostSync,
checkHost,
cancelRun,
shutdown,
isRunning: () => currentRun !== null,
};
}
+3 -1
View File
@@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.openSyncUi();
handlers.exportLogs();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true);
@@ -71,7 +72,7 @@ test('build tray template handler wires actions and init guards', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -97,6 +98,7 @@ test('build tray template handler wires actions and init guards', () => {
'setup-forced',
'yomitan',
'configuration',
'sync-ui',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
+1 -1
View File
@@ -14,7 +14,7 @@ export interface AppLifecycleRuntimeRunnerParams {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;