diff --git a/src/main.ts b/src/main.ts index af214e20..9512fe9f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2257,7 +2257,11 @@ const syncUiRuntime = createSyncUiRuntime({ const configured = getResolvedConfig().immersionTracking?.dbPath?.trim(); return configured || DEFAULT_IMMERSION_DB_PATH; }, - resolveLauncherCommand: () => resolveSyncLauncherCommand(), + resolveLauncherCommand: () => + resolveSyncLauncherCommand({ + execPath: process.execPath, + appPath: app.isPackaged ? null : app.getAppPath(), + }), runLauncher: runSyncLauncher, getWindow: () => appState.syncUiWindow, pickSnapshotFile: async () => { diff --git a/src/main/runtime/sync-launcher-client.test.ts b/src/main/runtime/sync-launcher-client.test.ts index 1894261f..5d965d0d 100644 --- a/src/main/runtime/sync-launcher-client.test.ts +++ b/src/main/runtime/sync-launcher-client.test.ts @@ -120,28 +120,18 @@ test('runSyncLauncher surfaces spawn errors', async () => { assert.match(result.error ?? '', /ENOENT/); }); -test('resolveSyncLauncherCommand prefers the bundled launcher over PATH', async () => { +test('resolveSyncLauncherCommand self-spawns the app in --sync-cli mode', async () => { const { resolveSyncLauncherCommand } = await import('./sync-launcher-client'); - const bundled = resolveSyncLauncherCommand({ - findCommand: (name: string) => - name === 'bun' ? '/usr/bin/bun' : name === 'subminer' ? '/home/u/.local/bin/subminer' : null, - resolveResourcePath: () => '/opt/app/resources/launcher/subminer', - existsSync: () => true, + const packaged = resolveSyncLauncherCommand({ + execPath: '/opt/SubMiner/subminer-app', + appPath: null, }); - assert.deepEqual(bundled.command, ['/usr/bin/bun', '/opt/app/resources/launcher/subminer']); + assert.deepEqual(packaged.command, ['/opt/SubMiner/subminer-app', '--sync-cli']); + assert.equal(packaged.error, null); - const fallback = resolveSyncLauncherCommand({ - findCommand: (name: string) => (name === 'subminer' ? '/home/u/.local/bin/subminer' : null), - resolveResourcePath: () => '/missing', - existsSync: () => false, + const dev = resolveSyncLauncherCommand({ + execPath: '/usr/bin/electron', + appPath: '/home/u/SubMiner', }); - assert.deepEqual(fallback.command, ['/home/u/.local/bin/subminer']); - - const none = resolveSyncLauncherCommand({ - findCommand: () => null, - resolveResourcePath: () => '/missing', - existsSync: () => false, - }); - assert.equal(none.command, null); - assert.match(none.error ?? '', /launcher/i); + assert.deepEqual(dev.command, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']); }); diff --git a/src/main/runtime/sync-launcher-client.ts b/src/main/runtime/sync-launcher-client.ts index ba29465f..99452b74 100644 --- a/src/main/runtime/sync-launcher-client.ts +++ b/src/main/runtime/sync-launcher-client.ts @@ -1,8 +1,6 @@ import { spawn as nodeSpawn } from 'node:child_process'; -import fs from 'node:fs'; import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events'; -import { findCommand } from './command-line-launcher-deps'; -import { resolveLauncherResourcePath } from './command-line-launcher'; +import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args'; export interface SyncLauncherChildLike { stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null; @@ -29,33 +27,21 @@ export interface SyncLauncherResolution { error: string | null; } -// The launcher is a bun script. Prefer the bundled launcher resource (it -// always matches this app version's sync protocol) and only fall back to a -// PATH-installed `subminer`, which may be older. +// Sync runs in a child copy of this app in headless --sync-cli mode: same +// engine and NDJSON protocol as `subminer sync --json`, with no dependency on +// bun or an installed command-line launcher. In dev runs process.execPath is +// a bare electron binary, so the app path is passed as its entry argument. export function resolveSyncLauncherCommand( deps: { - findCommand?: typeof findCommand; - resolveResourcePath?: () => string; - existsSync?: (candidate: string) => boolean; + execPath?: string; + appPath?: string | null; } = {}, ): SyncLauncherResolution { - const find = deps.findCommand ?? findCommand; - const exists = deps.existsSync ?? fs.existsSync; - const resourcePath = deps.resolveResourcePath - ? deps.resolveResourcePath() - : resolveLauncherResourcePath({}); - const bunPath = find('bun', {}); - if (bunPath && resourcePath && exists(resourcePath)) { - return { command: [bunPath, resourcePath], error: null }; - } - - const installed = find('subminer', {}); - if (installed) return { command: [installed], error: null }; - + const execPath = deps.execPath ?? process.execPath; + const appPath = deps.appPath ?? null; return { - command: null, - error: - 'Could not find the subminer launcher. Install the command-line launcher from SubMiner setup (or install bun).', + command: appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG], + error: null, }; } @@ -66,7 +52,15 @@ export function runSyncLauncher(options: { onStderr?: (text: string) => void; spawn?: SyncLauncherSpawn; }): SyncLauncherRunHandle { - const spawn = options.spawn ?? ((command, args) => nodeSpawn(command, args, { stdio: 'pipe' })); + const spawn = + options.spawn ?? + ((command, args) => { + // The child must boot as a full Electron app (its entry handles + // --sync-cli); a leaked ELECTRON_RUN_AS_NODE would turn it into node. + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + return nodeSpawn(command, args, { stdio: 'pipe', env }); + }); const [executable, ...prefixArgs] = options.command; const child = spawn(executable!, [...prefixArgs, ...options.args]);