diff --git a/changes/sync-ui-window.md b/changes/sync-ui-window.md index e8546508..bc843c7a 100644 --- a/changes/sync-ui-window.md +++ b/changes/sync-ui-window.md @@ -2,4 +2,4 @@ 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. `subminer sync --ui` launches silently in the background and returns the shell immediately; closing that standalone Sync window shuts down the app. -- Added `subminer sync --check` to test the SSH connection and remote launcher availability without syncing, using bounded noninteractive probes and settling when the check process exits even if an inherited output pipe remains open. Headless sync commands now exit directly after completion so Linux AppImage runs cannot fall through into GUI startup. Added `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes). +- Added `subminer sync --check` to test the SSH connection and remote launcher availability without syncing, using bounded noninteractive probes and settling when the check process exits even if an inherited output pipe remains open, without dropping terminal progress that races process exit. Linux AppImage sync commands now run the bundled engine in Node-only mode and exit directly, so remote snapshots never initialize the GUI or require a display server. Added `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes). diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index 3e688dec..78aefb4b 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -114,7 +114,7 @@ Unfinished sessions (a crash mid-playback) are skipped until the app finalizes t `subminer sync --check` verifies a host without touching any data: it probes the SSH connection, locates SubMiner on the remote (launcher or app binary), and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes). -`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp ` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically. +`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp ` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically and runs AppImages in Node-only mode, so remote sync does not require a graphical session. ### Sync window diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 104ce667..90e81c4b 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -119,6 +119,37 @@ test('runAppCommandCaptureOutput transports Linux AppImage args through environm } }); +test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', () => { + const { dir } = createTempSocketPath(); + const appPath = path.join(dir, 'SubMiner.AppImage'); + fs.writeFileSync( + appPath, + [ + '#!/bin/sh', + 'printf "args:%s\\n" "$*"', + 'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"', + 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"', + 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"', + '', + ].join('\n'), + ); + fs.chmodSync(appPath, 0o755); + + try { + const result = withPlatform('linux', () => + runAppCommandCaptureOutput(appPath, ['--sync-cli', 'sync', '--snapshot', '/tmp/out']), + ); + + assert.equal(result.status, 0); + assert.match(result.stdout, /^args:-e /m); + assert.match(result.stdout, /^electron-node:1$/m); + assert.match(result.stdout, /^argc:4$/m); + assert.match(result.stdout, /^arg0:--sync-cli$/m); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('parseMpvArgString preserves empty quoted tokens', () => { assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [ '--title', diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 8b18250d..569b2b78 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -1277,6 +1277,15 @@ function shouldTransportAppArgsForAppImage(appPath: string): boolean { return process.platform === 'linux' && /\.AppImage$/i.test(appPath); } +const APPIMAGE_SYNC_NODE_RUNNER = [ + 'const root=process.env.APPDIR+"/resources/app.asar";', + 'const {runSyncCliFromProcess}=require(root+"/dist/main/sync-cli.js");', + 'const count=Number(process.env.SUBMINER_APP_ARGC);', + 'const argv=[process.execPath,...Array.from({length:count},(_,i)=>process.env["SUBMINER_APP_ARG_"+i]??"")];', + 'runSyncCliFromProcess(argv,require(root+"/package.json").version)', + '.then(code=>process.exit(code),error=>{console.error(error);process.exit(1)});', +].join(''); + function buildAppEnv( baseEnv: NodeJS.ProcessEnv = process.env, extraEnv: NodeJS.ProcessEnv = {}, @@ -1432,6 +1441,16 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean { function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget { if (shouldTransportAppArgsForAppImage(appPath)) { + if (appArgs[0] === '--sync-cli') { + return { + command: appPath, + args: ['-e', APPIMAGE_SYNC_NODE_RUNNER], + env: { + ...buildTransportedAppArgsEnv(appArgs), + ELECTRON_RUN_AS_NODE: '1', + }, + }; + } return { command: appPath, args: [], diff --git a/src/main/runtime/sync-launcher-client.test.ts b/src/main/runtime/sync-launcher-client.test.ts index 990d72d7..450d5d4e 100644 --- a/src/main/runtime/sync-launcher-client.test.ts +++ b/src/main/runtime/sync-launcher-client.test.ts @@ -89,7 +89,7 @@ test('runSyncLauncher falls back to stderr when no result event arrives', async assert.match(result.error ?? '', /boom/); }); -test('runSyncLauncher settles when the child exits before its stdio pipes close', async () => { +test('runSyncLauncher settles on exit when the terminal event is already parsed', async () => { const { spawn, children } = makeSpawn(); const handle = runSyncLauncher({ command: ['subminer'], @@ -97,6 +97,10 @@ test('runSyncLauncher settles when the child exits before its stdio pipes close' onEvent: () => {}, spawn, }); + children[0]!.stdout.emit( + 'data', + Buffer.from('{"type":"result","ok":true,"error":null}\n'), + ); children[0]!.emit('exit', 0, null); const result = await Promise.race([ @@ -106,6 +110,31 @@ test('runSyncLauncher settles when the child exits before its stdio pipes close' assert.deepEqual(result, { ok: true, error: null }); }); +test('runSyncLauncher waits for terminal NDJSON when exit precedes final stdout data', async () => { + const { spawn, children } = makeSpawn(); + const events: SyncProgressEvent[] = []; + const handle = runSyncLauncher({ + command: ['subminer'], + args: ['sync', 'media-box', '--json'], + onEvent: (event) => events.push(event), + spawn, + }); + const child = children[0]!; + let settled = false; + void handle.done.then(() => { + settled = true; + }); + + child.emit('exit', 0, null); + await Promise.resolve(); + assert.equal(settled, false); + + child.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n')); + + assert.deepEqual(await handle.done, { ok: true, error: null }); + assert.deepEqual(events.at(-1), { type: 'result', ok: true, error: null }); +}); + test('runSyncLauncher cancel kills the child and resolves as cancelled', async () => { const { spawn, children } = makeSpawn(); const handle = runSyncLauncher({ diff --git a/src/main/runtime/sync-launcher-client.ts b/src/main/runtime/sync-launcher-client.ts index 171cc0d9..0224c3ab 100644 --- a/src/main/runtime/sync-launcher-client.ts +++ b/src/main/runtime/sync-launcher-client.ts @@ -73,6 +73,7 @@ export function runSyncLauncher(options: { let stderrTail = ''; let resultEvent: Extract | null = null; let terminationError: string | null = null; + let settleAfterTerminalEvent: (() => void) | null = null; child.stdout?.on('data', (chunk) => { stdoutBuffer += chunk.toString(); @@ -82,7 +83,10 @@ export function runSyncLauncher(options: { stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); const event = parseSyncProgressLine(line); if (event) { - if (event.type === 'result') resultEvent = event; + if (event.type === 'result') { + resultEvent = event; + settleAfterTerminalEvent?.(); + } options.onEvent(event); } newlineIndex = stdoutBuffer.indexOf('\n'); @@ -105,6 +109,8 @@ export function runSyncLauncher(options: { const done = new Promise((resolve) => { let settled = false; + let exitObserved = false; + let exitCode: number | null = null; const settle = (result: SyncLauncherRunResult): void => { if (settled) return; settled = true; @@ -127,10 +133,17 @@ export function runSyncLauncher(options: { resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`); settle({ ok: false, error }); }; + settleAfterTerminalEvent = () => { + if (exitObserved) settleFromExit(exitCode); + }; // Electron descendants can retain inherited stdio pipes after the main - // child exits, delaying `close` indefinitely. `exit` is authoritative for - // process completion; keep `close` as a fallback for test/process shims. - child.on('exit', settleFromExit); + // child exits, delaying `close` indefinitely. Once terminal NDJSON has + // arrived, `exit` is authoritative; keep `close` as the fallback. + child.on('exit', (code) => { + exitObserved = true; + exitCode = code; + if (resultEvent) settleFromExit(code); + }); child.on('close', settleFromExit); });