diff --git a/changes/sync-ui-window.md b/changes/sync-ui-window.md index 3daecd90..d9bea86a 100644 --- a/changes/sync-ui-window.md +++ b/changes/sync-ui-window.md @@ -1,5 +1,5 @@ type: added area: sync -- Added a sync window (`subminer sync --ui`, or **Sync Stats & History** in the tray menu) for cross-machine immersion sync: saved devices with per-host direction (two-way/push/pull) and remove, one-click sync with live stage-by-stage progress and merge summaries, connection testing for first-time setup, cancellable runs with a one-click `--force` retry when the running-app guard trips, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled are synced in the background on a configurable interval while no mpv session or stats server is writing the database, with results reported as overlay notifications. Hosts synced from the CLI are remembered in `/sync-hosts.json` and show up in the window automatically. +- Added a sync window (`subminer sync --ui`, or **Sync Stats & History** in the tray menu) for cross-machine immersion sync: saved devices with per-host direction (two-way/push/pull) and remove, one-click sync with live stage-by-stage progress and merge summaries, connection testing for first-time setup, cancellable runs with a one-click `--force` retry when the running-app guard trips, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled are synced in the background on a configurable interval while no mpv session or stats server is writing the database, with results reported as overlay notifications. Hosts synced from the CLI are remembered in `/sync-hosts.json` and show up in the window automatically. When launched with `subminer sync --ui`, closing the window also exits the attached CLI process cleanly. - Added `subminer sync --check` to test the SSH connection and remote launcher availability without syncing, and `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes). diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts index bb2d31af..47a4d46b 100644 --- a/launcher/commands/app-command.ts +++ b/launcher/commands/app-command.ts @@ -1,12 +1,14 @@ import { launchAppBackgroundDetached, launchTexthookerOnly, + runAppCommandInteractive, runAppCommandWithInherit, } from '../mpv.js'; import type { LauncherCommandContext } from './context.js'; type AppCommandDeps = { runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void; + runAppCommandInteractive: (appPath: string, appArgs: string[]) => void; launchAppBackgroundDetached: ( appPath: string, logLevel: LauncherCommandContext['args']['logLevel'], @@ -15,6 +17,7 @@ type AppCommandDeps = { const defaultAppCommandDeps: AppCommandDeps = { runAppCommandWithInherit, + runAppCommandInteractive, launchAppBackgroundDetached, }; @@ -31,7 +34,7 @@ export function runAppPassthroughCommand( return true; } if (args.syncUi) { - deps.runAppCommandWithInherit(appPath, ['--sync-window']); + deps.runAppCommandInteractive(appPath, ['--sync-window']); return true; } if (!args.appPassthrough) { diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index c709b423..25e34f12 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -206,6 +206,7 @@ test('app command starts default macOS background app detached from launcher', ( runAppCommandWithInherit: () => { calls.push('attached'); }, + runAppCommandInteractive: () => calls.push('interactive'), launchAppBackgroundDetached: (appPath, logLevel) => { calls.push(`detached:${appPath}:${logLevel}`); }, @@ -225,6 +226,7 @@ test('app command starts default Linux background app detached from launcher', ( runAppCommandWithInherit: () => { calls.push('attached'); }, + runAppCommandInteractive: () => calls.push('interactive'), launchAppBackgroundDetached: (appPath, logLevel) => { calls.push(`detached:${appPath}:${logLevel}`); }, @@ -245,6 +247,7 @@ test('app command keeps explicit passthrough args attached', () => { runAppCommandWithInherit: (_appPath, appArgs) => { forwarded.push(appArgs); }, + runAppCommandInteractive: () => detached.push('interactive'), launchAppBackgroundDetached: () => { detached.push('detached'); }, @@ -255,6 +258,21 @@ test('app command keeps explicit passthrough args attached', () => { assert.deepEqual(detached, []); }); +test('sync UI command attaches the app directly to the terminal', () => { + const context = createContext(); + context.args.syncUi = true; + const calls: string[] = []; + + const handled = runAppPassthroughCommand(context, { + runAppCommandWithInherit: () => calls.push('piped'), + runAppCommandInteractive: (_appPath, appArgs) => calls.push(`direct:${appArgs.join(' ')}`), + launchAppBackgroundDetached: () => calls.push('detached'), + }); + + assert.equal(handled, true); + assert.deepEqual(calls, ['direct:--sync-window']); +}); + test('mpv pre-app command exits non-zero when socket is not ready', async () => { const context = createContext(); context.args.mpvStatus = true; diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index b939d3d9..bba2d4e4 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -172,6 +172,26 @@ test('startAppLifecycle defers quit until async cleanup settles', async () => { assert.deepEqual(calls, ['quitApp']); }); +test('startAppLifecycle contains synchronous quit cleanup failures', () => { + let willQuit: ((event: { preventDefault(): void }) => void) | null = null; + const { deps, calls } = createDeps({ + shouldStartApp: () => true, + onWillQuit: (handler) => { + willQuit = handler; + }, + onWillQuitCleanup: () => { + throw new Error('cleanup exploded'); + }, + }); + + startAppLifecycle(makeArgs({ start: true }), deps); + assert.ok(willQuit); + assert.doesNotThrow(() => + (willQuit as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} }), + ); + assert.deepEqual(calls, []); +}); + test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => { const { deps, calls, getLockCalls } = createDeps({ shouldStartApp: () => false, @@ -385,3 +405,22 @@ test('startAppLifecycle quits macOS setup-only launch when all windows close', ( handler(); assert.deepEqual(calls, ['quitApp']); }); + +test('startAppLifecycle quits macOS sync-window launch when its window closes', () => { + let windowAllClosedHandler: (() => void) | null = null; + const { deps, calls } = createDeps({ + shouldStartApp: () => true, + isDarwinPlatform: () => true, + shouldQuitOnWindowAllClosed: () => true, + onWindowAllClosed: (handler) => { + windowAllClosedHandler = handler; + }, + }); + + startAppLifecycle(makeArgs({ syncWindow: true }), deps); + + const handler = windowAllClosedHandler as (() => void) | null; + assert.ok(handler); + handler(); + assert.deepEqual(calls, ['quitApp']); +}); diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index e6c7dd08..e74643cd 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -183,7 +183,10 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic deps.onWindowAllClosed(() => { if ( deps.shouldQuitOnWindowAllClosed() && - (!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup) + (!deps.isDarwinPlatform() || + initialArgs.settings || + initialArgs.setup || + initialArgs.syncWindow) ) { deps.quitApp(); } @@ -199,7 +202,13 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic event.preventDefault(); return; } - const cleanup = deps.onWillQuitCleanup(); + let cleanup: void | Promise; + try { + cleanup = deps.onWillQuitCleanup(); + } catch (error) { + logger.error('App quit cleanup failed:', error); + return; + } if (!(cleanup instanceof Promise)) return; quitCleanupPending = true; event.preventDefault();