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
This commit is contained in:
2026-05-25 02:12:41 -07:00
parent 920cbab1bc
commit 9fe13601fb
5 changed files with 114 additions and 6 deletions
+30 -4
View File
@@ -1,19 +1,45 @@
import { launchTexthookerOnly, runAppCommandWithInherit } from '../mpv.js'; import {
launchAppBackgroundDetached,
launchTexthookerOnly,
runAppCommandWithInherit,
} from '../mpv.js';
import type { LauncherCommandContext } from './context.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; const { args, appPath } = context;
if (!appPath) { if (!appPath) {
return false; return false;
} }
if (args.settings) { if (args.settings) {
runAppCommandWithInherit(appPath, ['--settings']); deps.runAppCommandWithInherit(appPath, ['--settings']);
return true; return true;
} }
if (!args.appPassthrough) { if (!args.appPassthrough) {
return false; 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; return true;
} }
+43
View File
@@ -7,6 +7,7 @@ import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js'; import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js'; import { runDoctorCommand } from './doctor-command.js';
import { runMpvPreAppCommand } from './mpv-command.js'; import { runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js'; import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-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']]); 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 () => { test('mpv pre-app command exits non-zero when socket is not ready', async () => {
const context = createContext(); const context = createContext();
context.args.mpvStatus = true; context.args.mpvStatus = true;
+29
View File
@@ -14,6 +14,7 @@ import {
buildMpvEnv, buildMpvEnv,
cleanupPlaybackSession, cleanupPlaybackSession,
detectBackend, detectBackend,
launchAppBackgroundDetached,
findAppBinary, findAppBinary,
launchAppCommandDetached, launchAppCommandDetached,
launchTexthookerOnly, 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', () => { test('stopOverlay logs a warning when stop command cannot be spawned', () => {
const originalWrite = process.stdout.write; const originalWrite = process.stdout.write;
const writes: string[] = []; const writes: string[] = [];
+11 -1
View File
@@ -57,6 +57,7 @@ const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700; const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
export interface LauncherRuntimePluginPlan { export interface LauncherRuntimePluginPlan {
scriptPath: string | null; scriptPath: string | null;
@@ -1589,11 +1590,20 @@ export function launchAppStartDetached(appPath: string, logLevel: LogLevel): voi
launchAppCommandDetached(appPath, startArgs, logLevel, 'start'); 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( export function launchAppCommandDetached(
appPath: string, appPath: string,
appArgs: string[], appArgs: string[],
logLevel: LogLevel, logLevel: LogLevel,
label: string, label: string,
extraEnv: NodeJS.ProcessEnv = {},
): void { ): void {
if (maybeCaptureAppArgs(appArgs)) { if (maybeCaptureAppArgs(appArgs)) {
return; return;
@@ -1612,7 +1622,7 @@ export function launchAppCommandDetached(
const proc = spawn(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: ['ignore', stdoutFd, stderrFd], stdio: ['ignore', stdoutFd, stderrFd],
detached: true, detached: true,
env: buildAppEnv(process.env, target.env), env: buildAppEnv(process.env, { ...target.env, ...extraEnv }),
}); });
proc.once('error', (error) => { proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);