mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-24 05:16:19 -07:00
fix(launcher): load macOS login shell PATH for GUI launches
This commit is contained in:
+37
-19
@@ -392,6 +392,7 @@ import {
|
||||
installLauncher as installCommandLineLauncher,
|
||||
refreshManagedCommandLineLauncher,
|
||||
} from './main/runtime/command-line-launcher';
|
||||
import { applyLoginShellPath } from './main/runtime/login-shell-path';
|
||||
import {
|
||||
createWindowsMpvLaunchDeps,
|
||||
getConfiguredWindowsMpvPathStatus,
|
||||
@@ -1456,20 +1457,35 @@ const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||
appDataDir: app.getPath('appData'),
|
||||
desktopDir: app.getPath('desktop'),
|
||||
});
|
||||
const createCommandLineLauncherRuntimeOptions = () => ({
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
homeDir: os.homedir(),
|
||||
localAppData: process.env.LOCALAPPDATA,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
cwd: process.cwd(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
appExePath: process.execPath,
|
||||
appVersion: app.getVersion(),
|
||||
bundledBunPath: app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bun', process.platform === 'win32' ? 'bun.exe' : 'bun')
|
||||
: undefined,
|
||||
});
|
||||
// Finder/Dock launches inherit launchd's minimal PATH; pick up the user's shell PATH so
|
||||
// launcher/Bun detection and spawned tools match what their terminal sees.
|
||||
const loginShellPathReady: Promise<void> =
|
||||
process.platform === 'darwin'
|
||||
? applyLoginShellPath({ env: process.env })
|
||||
.then((applied) => {
|
||||
if (!applied) logger.warn('Login shell PATH was empty; using inherited PATH');
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to read login shell PATH; using inherited PATH', error);
|
||||
})
|
||||
: Promise.resolve();
|
||||
const createCommandLineLauncherRuntimeOptions = async () => {
|
||||
await loginShellPathReady;
|
||||
return {
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
homeDir: os.homedir(),
|
||||
localAppData: process.env.LOCALAPPDATA,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
cwd: process.cwd(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
appExePath: process.execPath,
|
||||
appVersion: app.getVersion(),
|
||||
bundledBunPath: app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bun', process.platform === 'win32' ? 'bun.exe' : 'bun')
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
getDictionaryBackend: () => activeDictionaryBackend,
|
||||
getHachidoriHostStatus: async () => {
|
||||
@@ -1548,10 +1564,10 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
shell.writeShortcutLink(shortcutPath, operation, details),
|
||||
});
|
||||
},
|
||||
detectCommandLineLauncher: () =>
|
||||
detectCommandLineLauncher(createCommandLineLauncherRuntimeOptions()),
|
||||
detectCommandLineLauncher: async () =>
|
||||
detectCommandLineLauncher(await createCommandLineLauncherRuntimeOptions()),
|
||||
installBun: async () => {
|
||||
const snapshot = await installCommandLineBun(createCommandLineLauncherRuntimeOptions());
|
||||
const snapshot = await installCommandLineBun(await createCommandLineLauncherRuntimeOptions());
|
||||
return {
|
||||
ok: snapshot.status === 'ready',
|
||||
message:
|
||||
@@ -1562,7 +1578,9 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
};
|
||||
},
|
||||
installCommandLineLauncher: async () => {
|
||||
const snapshot = await installCommandLineLauncher(createCommandLineLauncherRuntimeOptions());
|
||||
const snapshot = await installCommandLineLauncher(
|
||||
await createCommandLineLauncherRuntimeOptions(),
|
||||
);
|
||||
const ok = snapshot.status === 'ready' || snapshot.status === 'not_on_path';
|
||||
return {
|
||||
ok,
|
||||
@@ -6452,7 +6470,7 @@ runAndApplyStartupState();
|
||||
void app.whenReady().then(() => {
|
||||
void takePendingLauncherMigrationPath(async (pendingLauncherPath) => {
|
||||
const acknowledgedPaths = await refreshManagedCommandLineLauncher({
|
||||
...createCommandLineLauncherRuntimeOptions(),
|
||||
...(await createCommandLineLauncherRuntimeOptions()),
|
||||
additionalLauncherPaths: pendingLauncherPath ? [pendingLauncherPath] : [],
|
||||
});
|
||||
return pendingLauncherPath !== undefined && acknowledgedPaths.includes(pendingLauncherPath);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyLoginShellPath, mergePathValues, readLoginShellPath } from './login-shell-path';
|
||||
|
||||
const wrap = (value: string) =>
|
||||
`motd noise\n__SUBMINER_LOGIN_PATH__${value}\n__SUBMINER_LOGIN_PATH__`;
|
||||
|
||||
test('readLoginShellPath extracts PATH between markers, ignoring rc-file output', async () => {
|
||||
let invoked: { shell: string; args: string[] } | null = null;
|
||||
const value = await readLoginShellPath({
|
||||
env: { SHELL: '/bin/zsh' },
|
||||
runShell: async (shell, args) => {
|
||||
invoked = { shell, args };
|
||||
return wrap('/Users/me/.local/bin:/usr/bin');
|
||||
},
|
||||
});
|
||||
assert.equal(value, '/Users/me/.local/bin:/usr/bin');
|
||||
assert.equal(invoked!.shell, '/bin/zsh');
|
||||
assert.equal(invoked!.args[0], '-ilc');
|
||||
});
|
||||
|
||||
test('readLoginShellPath returns null when markers are missing', async () => {
|
||||
const value = await readLoginShellPath({ env: {}, shell: '/bin/sh', runShell: async () => '' });
|
||||
assert.equal(value, null);
|
||||
});
|
||||
|
||||
test('mergePathValues puts login entries first and keeps process-only entries', () => {
|
||||
assert.equal(
|
||||
mergePathValues('/Users/me/.local/bin:/usr/bin:/bin', '/usr/bin:/bin:/usr/sbin:/sbin'),
|
||||
'/Users/me/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyLoginShellPath updates env.PATH so launcher detection sees shell dirs', async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin' };
|
||||
const applied = await applyLoginShellPath({
|
||||
env,
|
||||
shell: '/bin/zsh',
|
||||
runShell: async () => wrap('/Users/me/.local/bin:/usr/bin'),
|
||||
});
|
||||
assert.equal(applied, true);
|
||||
assert.equal(env.PATH, '/Users/me/.local/bin:/usr/bin:/bin');
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
|
||||
const MARKER = '__SUBMINER_LOGIN_PATH__';
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
|
||||
export type RunShell = (shell: string, args: string[], timeoutMs: number) => Promise<string>;
|
||||
|
||||
type LoginShellPathOptions = {
|
||||
env: NodeJS.ProcessEnv;
|
||||
shell?: string;
|
||||
runShell?: RunShell;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const runShellDefault: RunShell = (shell, args, timeoutMs) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
shell,
|
||||
args,
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DISABLE_AUTO_UPDATE: 'true' },
|
||||
},
|
||||
(error, stdout) => (error ? reject(error) : resolve(stdout)),
|
||||
);
|
||||
child.stdin?.end();
|
||||
});
|
||||
|
||||
function defaultShell(env: NodeJS.ProcessEnv): string {
|
||||
if (env.SHELL) return env.SHELL;
|
||||
try {
|
||||
return os.userInfo().shell || '/bin/zsh';
|
||||
} catch {
|
||||
return '/bin/zsh';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads PATH as the user's interactive login shell sees it. macOS GUI apps inherit
|
||||
* launchd's minimal PATH, so dirs added in ~/.zshrc and friends are otherwise invisible.
|
||||
*/
|
||||
export async function readLoginShellPath(options: LoginShellPathOptions): Promise<string | null> {
|
||||
const shell = options.shell ?? defaultShell(options.env);
|
||||
const run = options.runShell ?? runShellDefault;
|
||||
// printenv keeps this shell-agnostic (fish exposes $PATH as a list).
|
||||
const command = `printf '%s' '${MARKER}'; /usr/bin/printenv PATH; printf '%s' '${MARKER}'`;
|
||||
const stdout = await run(shell, ['-ilc', command], options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
const value = stdout.split(MARKER)[1]?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/** Puts login-shell entries first (terminal precedence), keeping any process-only entries after. */
|
||||
export function mergePathValues(loginPath: string, currentPath: string | undefined): string {
|
||||
const merged: string[] = [];
|
||||
for (const entry of [...loginPath.split(':'), ...(currentPath ?? '').split(':')]) {
|
||||
if (entry && !merged.includes(entry)) merged.push(entry);
|
||||
}
|
||||
return merged.join(':');
|
||||
}
|
||||
|
||||
/** Merges the login-shell PATH into `options.env.PATH`. Resolves false when it couldn't be read. */
|
||||
export async function applyLoginShellPath(options: LoginShellPathOptions): Promise<boolean> {
|
||||
const loginPath = await readLoginShellPath(options);
|
||||
if (!loginPath) return false;
|
||||
options.env.PATH = mergePathValues(loginPath, options.env.PATH);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user