From 9fe13601fbb54e19668d7565e87d48d96c541466 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 25 May 2026 02:12:41 -0700 Subject: [PATCH] Launch macOS app background-detached when no args passed - Add `launchAppBackgroundDetached` that spawns with `--start --background` and `SUBMINER_BACKGROUND_CHILD=1` - On darwin with empty appArgs, use detached background launch instead of inherited process - Add `extraEnv` param to `launchAppCommandDetached` for env injection - Inject deps into `runAppPassthroughCommand` for testability - Bump vendor/subminer-yomitan submodule --- launcher/commands/app-command.ts | 34 +++++++++++++++--- launcher/commands/command-modules.test.ts | 43 +++++++++++++++++++++++ launcher/mpv.test.ts | 29 +++++++++++++++ launcher/mpv.ts | 12 ++++++- vendor/subminer-yomitan | 2 +- 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts index b6712437..3073aa8f 100644 --- a/launcher/commands/app-command.ts +++ b/launcher/commands/app-command.ts @@ -1,19 +1,45 @@ -import { launchTexthookerOnly, runAppCommandWithInherit } from '../mpv.js'; +import { + launchAppBackgroundDetached, + launchTexthookerOnly, + runAppCommandWithInherit, +} from '../mpv.js'; import type { LauncherCommandContext } from './context.js'; -export function runAppPassthroughCommand(context: LauncherCommandContext): boolean { +type AppCommandDeps = { + platform: () => NodeJS.Platform; + runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void; + launchAppBackgroundDetached: ( + appPath: string, + logLevel: LauncherCommandContext['args']['logLevel'], + ) => void; +}; + +const defaultAppCommandDeps: AppCommandDeps = { + platform: () => process.platform, + runAppCommandWithInherit, + launchAppBackgroundDetached, +}; + +export function runAppPassthroughCommand( + context: LauncherCommandContext, + deps: AppCommandDeps = defaultAppCommandDeps, +): boolean { const { args, appPath } = context; if (!appPath) { return false; } if (args.settings) { - runAppCommandWithInherit(appPath, ['--settings']); + deps.runAppCommandWithInherit(appPath, ['--settings']); return true; } if (!args.appPassthrough) { return false; } - runAppCommandWithInherit(appPath, args.appArgs); + if (deps.platform() === 'darwin' && args.appArgs.length === 0) { + deps.launchAppBackgroundDetached(appPath, args.logLevel); + return true; + } + deps.runAppCommandWithInherit(appPath, args.appArgs); return true; } diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 257ea20f..aaab9188 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -7,6 +7,7 @@ import { runConfigCommand } from './config-command.js'; import { runDictionaryCommand } from './dictionary-command.js'; import { runDoctorCommand } from './doctor-command.js'; import { runMpvPreAppCommand } from './mpv-command.js'; +import { runAppPassthroughCommand } from './app-command.js'; import { runStatsCommand } from './stats-command.js'; import { runUpdateCommand } from './update-command.js'; @@ -168,6 +169,48 @@ test('doctor command forwards refresh-known-words to app binary', () => { assert.deepEqual(forwarded, [['--refresh-known-words']]); }); +test('app command starts default macOS background app detached from launcher', () => { + const context = createContext(); + context.args.appPassthrough = true; + context.args.appArgs = []; + const calls: string[] = []; + + const handled = runAppPassthroughCommand(context, { + platform: () => 'darwin', + runAppCommandWithInherit: () => { + calls.push('attached'); + }, + launchAppBackgroundDetached: (appPath, logLevel) => { + calls.push(`detached:${appPath}:${logLevel}`); + }, + }); + + assert.equal(handled, true); + assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']); +}); + +test('app command keeps explicit passthrough args attached', () => { + const context = createContext(); + context.args.appPassthrough = true; + context.args.appArgs = ['--settings']; + const forwarded: string[][] = []; + const detached: string[] = []; + + const handled = runAppPassthroughCommand(context, { + platform: () => 'darwin', + runAppCommandWithInherit: (_appPath, appArgs) => { + forwarded.push(appArgs); + }, + launchAppBackgroundDetached: () => { + detached.push('detached'); + }, + }); + + assert.equal(handled, true); + assert.deepEqual(forwarded, [['--settings']]); + assert.deepEqual(detached, []); +}); + test('mpv pre-app command exits non-zero when socket is not ready', async () => { const context = createContext(); context.args.mpvStatus = true; diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 325b51ab..4d1f379b 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -14,6 +14,7 @@ import { buildMpvEnv, cleanupPlaybackSession, detectBackend, + launchAppBackgroundDetached, findAppBinary, launchAppCommandDetached, launchTexthookerOnly, @@ -425,6 +426,34 @@ test('launchAppCommandDetached handles child process spawn errors', async () => } }); +test('launchAppBackgroundDetached starts background child directly', async () => { + const { dir } = createTempSocketPath(); + const appPath = path.join(dir, 'fake-subminer.sh'); + const argsPath = path.join(dir, 'args.txt'); + const envPath = path.join(dir, 'env.txt'); + fs.writeFileSync( + appPath, + [ + '#!/bin/sh', + `printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`, + `printf '%s\\n' "$SUBMINER_BACKGROUND_CHILD" > ${JSON.stringify(envPath)}`, + '', + ].join('\n'), + ); + fs.chmodSync(appPath, 0o755); + + launchAppBackgroundDetached(appPath, 'info'); + + const deadline = Date.now() + 1000; + while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background'); + assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + test('stopOverlay logs a warning when stop command cannot be spawned', () => { const originalWrite = process.stdout.write; const writes: string[] = []; diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 06e0cb26..696df171 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -57,6 +57,7 @@ const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900; const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700; const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; +const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; export interface LauncherRuntimePluginPlan { scriptPath: string | null; @@ -1589,11 +1590,20 @@ export function launchAppStartDetached(appPath: string, logLevel: LogLevel): voi launchAppCommandDetached(appPath, startArgs, logLevel, 'start'); } +export function launchAppBackgroundDetached(appPath: string, logLevel: LogLevel): void { + const startArgs = ['--start', '--background']; + if (logLevel !== 'info') startArgs.push('--log-level', logLevel); + launchAppCommandDetached(appPath, startArgs, logLevel, 'app', { + [BACKGROUND_CHILD_ENV]: '1', + }); +} + export function launchAppCommandDetached( appPath: string, appArgs: string[], logLevel: LogLevel, label: string, + extraEnv: NodeJS.ProcessEnv = {}, ): void { if (maybeCaptureAppArgs(appArgs)) { return; @@ -1612,7 +1622,7 @@ export function launchAppCommandDetached( const proc = spawn(target.command, target.args, { stdio: ['ignore', stdoutFd, stderrFd], detached: true, - env: buildAppEnv(process.env, target.env), + env: buildAppEnv(process.env, { ...target.env, ...extraEnv }), }); proc.once('error', (error) => { log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); diff --git a/vendor/subminer-yomitan b/vendor/subminer-yomitan index ed31b7a3..58d970d3 160000 --- a/vendor/subminer-yomitan +++ b/vendor/subminer-yomitan @@ -1 +1 @@ -Subproject commit ed31b7a3eee1746d332f6cadc53447a2391d3327 +Subproject commit 58d970d302495dfc109c0fb6b0fc0154cdaa0312