From 5327a7dc30a0a6fefc55cea13c3ead91578ab118 Mon Sep 17 00:00:00 2001 From: Tri Dung Nguyen <103993527+ntdung6868@users.noreply.github.com> Date: Thu, 14 May 2026 09:57:05 +0700 Subject: [PATCH] fix(autostart): work on nvm + npm 9/10, actually register with launchctl (fixes #1082) (#1104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(autostart): resolve cli.js path locally, register with launchctl, verify state The current `cli/src/cli/tray/autostart.js` is silently broken on every nvm install (and likely Volta / asdf / Homebrew / user-prefix npm) and on every npm version >= 9. Enabling auto-start from the tray menu writes a launchd plist that references a non-existent script; the menu then reports "✓ Auto-start Enabled" because the existence check only verifies the file is on disk. On the next OS boot launchd fails with MODULE_NOT_FOUND. None of this surfaces to the user. This rewrite addresses the four problems reported in #1082: 1. `npm bin -g` was removed in npm 9. autostart.js called it as a primary resolution mechanism and silently fell back to a hardcoded `/usr/local/lib/node_modules/9router/cli.js` path on failure. Replaced with a `getCliJsPath()` helper that tries (in order): the explicit `cliPath` argument, `process.argv[1]` when it's our own cli.js, and a path computed relative to autostart.js's own location (since this file always lives at `/src/cli/tray/autostart.js`, cli.js is three levels up regardless of install layout). Returns null on no match. 2. The `/usr/local/...` fallback was outright wrong for nvm/Volta/asdf installs. Dropped entirely. If no candidate resolves, return false instead of writing a plist pointing at a missing script. 3. `enableMacOS()` never called `launchctl load -w`, only `unload`. The plist was therefore inert until the next user login, with no signal to the user that anything was wrong. Now it unloads (defensive, in case of re-enable) and then loads, so the agent is active in the current session. 4. `isAutoStartEnabled()` on macOS only checked file existence — so the tray menu reported "✓ Enabled" even when launchd had the agent in a failed state or hadn't loaded it. Now also runs `launchctl list ${APP_LABEL}` and only returns true if launchd recognizes the label. Additional changes for robustness: - The macOS plist now invokes node + cli.js directly with absolute paths instead of wrapping in `zsh -l -c "..."`. The shell-wrapper approach depended on the user's login shell sourcing nvm/PATH correctly, which is fragile (nvm.sh sourcing varies between users; some setups don't add node to PATH from a non-interactive login shell). - `EnvironmentVariables.PATH` in the plist now explicitly includes node's bin directory plus the standard system paths, so child processes spawned by cli.js (e.g. the runtime `npm install` calls) can still resolve `npm` even under launchd's minimal default env. - Windows and Linux paths were calling `npm bin -g` with the same fallback problem; both now use `getCliJsPath()` consistently. The Windows VBS branch is simplified (always run node+cli.js, drop the `9router.cmd` lookup that depended on the npm prefix path). - Removed the unused `getStartCommand()` helper that was never imported. Fixes #1082 * fix(autostart): skip launchctl unload/load when current process is the agent When the running 9router cli.js was itself spawned by the autostart launchd agent (after a reboot when autostart was previously enabled), clicking the tray menu's "Disable Auto-start" item would unload the agent — and that unload sends SIGTERM to the running process, killing the click handler and the tray icon before the menu could flip its label. Add a small `isAgentSelfMacOS()` probe (parses `launchctl list ${label}` and compares the PID against `process.pid`). When we're the agent itself, disable skips the unload (the plist file removal is enough to prevent the agent from starting on the next login) and enable skips the load (the plist is already loaded under our own PID, the on-disk update is what matters for next boot). External callers — e.g. enable from a manually-launched 9router or from a script — still get the full unload/load behavior they need. Without this, the tray UX after a reboot was: click Disable -> tray icon silently disappears, no menu label change. Now: click Disable -> label flips to "Enable Auto-start", tray stays, plist removed; click Enable again -> label flips back, plist re-created. --- cli/src/cli/tray/autostart.js | 333 ++++++++++++++++------------------ 1 file changed, 159 insertions(+), 174 deletions(-) diff --git a/cli/src/cli/tray/autostart.js b/cli/src/cli/tray/autostart.js index df34fff..4ab93cf 100644 --- a/cli/src/cli/tray/autostart.js +++ b/cli/src/cli/tray/autostart.js @@ -7,22 +7,36 @@ const APP_NAME = "9router"; const APP_LABEL = "com.9router.autostart"; /** - * Get the command to run 9router in tray mode + * Resolve the absolute path to this package's cli.js. + * + * Order of preference: + * 1. Explicit `cliPath` argument — cleanest, used when called from running + * cli.js with `__filename`. + * 2. `process.argv[1]` if it's our cli.js — true when 9router is currently + * running and the tray menu fires this code path. + * 3. Compute relative to this file's own location. autostart.js lives at + * `/src/cli/tray/autostart.js`, so cli.js is three levels up. + * This works for any global install layout (nvm, Volta, asdf, Homebrew, + * /usr/local, etc.) without depending on `npm bin -g` (removed in npm 9) + * or a hardcoded `/usr/local/...` path. + * + * Returns null if no candidate exists — callers should not write an autostart + * entry pointing at a non-existent script. */ -function getStartCommand() { - // Find the global npm bin path for 9router - try { - const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); - const routerPath = path.join(npmBin, "9router"); - if (fs.existsSync(routerPath)) { - return `"${routerPath}" --tray --skip-update`; - } - } catch (e) { - // npm not available or failed +function getCliJsPath(cliPath) { + if (cliPath) { + const resolved = path.resolve(cliPath); + if (fs.existsSync(resolved)) return resolved; } - - // Fallback: use npx - return "npx 9router --tray --skip-update"; + if (process.argv[1]) { + const resolved = path.resolve(process.argv[1]); + if (path.basename(resolved) === "cli.js" && fs.existsSync(resolved)) { + return resolved; + } + } + const computed = path.resolve(__dirname, "..", "..", "..", "cli.js"); + if (fs.existsSync(computed)) return computed; + return null; } /** @@ -32,29 +46,17 @@ function getStartCommand() { */ function enableAutoStart(cliPath) { const platform = process.platform; - - // Skip on unsupported platforms - if (!["darwin", "win32", "linux"].includes(platform)) { - return false; - } - - // Skip on Linux without GUI - if (platform === "linux" && !process.env.DISPLAY) { - return false; - } - + + if (!["darwin", "win32", "linux"].includes(platform)) return false; + if (platform === "linux" && !process.env.DISPLAY) return false; + try { - if (platform === "darwin") { - return enableMacOS(cliPath); - } else if (platform === "win32") { - return enableWindows(cliPath); - } else if (platform === "linux") { - return enableLinux(cliPath); - } + if (platform === "darwin") return enableMacOS(cliPath); + if (platform === "win32") return enableWindows(cliPath); + if (platform === "linux") return enableLinux(cliPath); } catch (err) { - // Silent fail - autostart is optional + // Silent fail — autostart is optional } - return false; } @@ -64,78 +66,99 @@ function enableAutoStart(cliPath) { */ function disableAutoStart() { const platform = process.platform; - try { - if (platform === "darwin") { - return disableMacOS(); - } else if (platform === "win32") { - return disableWindows(); - } else if (platform === "linux") { - return disableLinux(); - } - } catch (err) { - // Silent fail - } - + if (platform === "darwin") return disableMacOS(); + if (platform === "win32") return disableWindows(); + if (platform === "linux") return disableLinux(); + } catch (err) {} return false; } /** - * Check if autostart is enabled - * @returns {boolean} + * Check if autostart is enabled. + * + * On macOS, both the plist file and the launchd registration must be present — + * otherwise the tray menu would lie about the state (showing "✓ Enabled" even + * when launchd has the agent in a failed state or hasn't loaded it). */ function isAutoStartEnabled() { const platform = process.platform; - + try { if (platform === "darwin") { const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); - return fs.existsSync(plistPath); + if (!fs.existsSync(plistPath)) return false; + try { + execSync(`launchctl list ${APP_LABEL}`, { + stdio: ["ignore", "ignore", "ignore"], + timeout: 3000 + }); + return true; + } catch (e) { + return false; + } } else if (platform === "win32") { - const startupPath = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); + const startupPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); return fs.existsSync(startupPath); } else if (platform === "linux") { const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`); return fs.existsSync(desktopPath); } } catch (e) {} - return false; } // ============ macOS ============ +/** + * Returns true when the current Node process IS the running instance that + * launchd is managing under our agent label. + * + * `launchctl unload ` (and `load`) for an Aqua user-domain agent sends + * SIGTERM to the running process. When the running 9router cli.js was itself + * spawned by the autostart launchd agent (i.e. user enabled autostart at + * some point, then rebooted, then clicked the tray icon's "Disable + * Auto-start" menu item), an unload would kill the very process executing + * the click handler — and the tray icon would disappear instead of the menu + * label flipping back to "Enable Auto-start". This helper lets the enable + * and disable paths sidestep that by skipping launchctl when we'd otherwise + * be killing ourselves. + */ +function isAgentSelfMacOS() { + try { + const output = execSync(`launchctl list ${APP_LABEL}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000 + }); + const match = output.match(/"PID"\s*=\s*(\d+)/); + return !!(match && parseInt(match[1], 10) === process.pid); + } catch (e) { + return false; + } +} + function enableMacOS(cliPath) { const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents"); const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`); - - // Ensure directory exists + if (!fs.existsSync(launchAgentsDir)) { fs.mkdirSync(launchAgentsDir, { recursive: true }); } - - // Get absolute paths for node and 9router script + const nodePath = process.execPath; - let routerScript; - - if (cliPath) { - // Use provided path (from running cli.js) - routerScript = path.resolve(cliPath); - } else { - // Fallback: try to resolve from npm bin - try { - const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); - const routerLink = path.join(npmBin, "9router"); - routerScript = fs.realpathSync(routerLink); - } catch (e) { - // Last resort fallback - routerScript = "/usr/local/lib/node_modules/9router/cli.js"; - } - } - - // Determine user shell - const userShell = process.env.SHELL || '/bin/zsh'; - + const routerScript = getCliJsPath(cliPath); + // Don't write a broken plist that references a non-existent script. + if (!routerScript) return false; + + // Invoke node + cli.js directly with absolute paths — no shell wrapper. + // The previous design ran `zsh -l -c "..."` so a login shell would source + // nvm/.zshrc and set PATH; that's fragile (nvm.sh sourcing varies by user, + // some setups don't put node on PATH from a non-interactive login shell). + // EnvironmentVariables.PATH explicitly includes node's bin dir so child + // processes spawned by cli.js (npm install at runtime, etc.) resolve. + const launchPath = `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin`; + const plistContent = ` @@ -144,11 +167,16 @@ function enableMacOS(cliPath) { ${APP_LABEL} ProgramArguments - ${userShell} - -l - -c - ${nodePath} ${routerScript} --tray --skip-update + ${nodePath} + ${routerScript} + --tray + --skip-update + EnvironmentVariables + + PATH + ${launchPath} + RunAtLoad KeepAlive @@ -159,98 +187,79 @@ function enableMacOS(cliPath) { /tmp/9router.error.log `; - + fs.writeFileSync(plistPath, plistContent); - - // Load the launch agent + + // If we're the running agent already, launchctl unload/load would send + // ourselves SIGTERM. Skip it — the plist file is updated on disk and + // launchd will pick it up at next login. isAutoStartEnabled() will still + // return true because launchctl already has the agent loaded. + if (isAgentSelfMacOS()) { + return true; + } + + // Register with launchd in the current session. Without this, the agent + // only takes effect on the next user login and the user has no signal that + // anything actually happened. `unload` first defends against re-enable + // replacing an existing plist. try { - execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" }); + execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }); } catch (e) {} - + try { + execSync(`launchctl load -w "${plistPath}"`, { stdio: "ignore" }); + } catch (e) { + // Even if load fails, the plist is on disk and will be picked up at next + // login; report success based on the file write. + } return true; } function disableMacOS() { const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); - - try { - execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" }); - } catch (e) {} - + + // Don't kill ourselves: when the current process is the running agent, + // `launchctl unload` would send SIGTERM and the user clicking + // "Disable Auto-start" from the tray menu would lose their tray icon + // instead of just flipping the menu label. Skip the unload — removing the + // plist file is enough to prevent the agent from starting on next login. + if (!isAgentSelfMacOS()) { + try { + execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }); + } catch (e) {} + } + if (fs.existsSync(plistPath)) { fs.unlinkSync(plistPath); } - return true; } // ============ Windows ============ function enableWindows(cliPath) { - const startupDir = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); + const startupDir = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`); - - // Ensure startup directory exists - if (!fs.existsSync(startupDir)) { - return false; - } - - // Get absolute paths + + if (!fs.existsSync(startupDir)) return false; + const nodePath = process.execPath; - let routerScript; - - if (cliPath) { - // Use provided path (from running cli.js) - routerScript = path.resolve(cliPath); - } else { - // Fallback: try to resolve from npm bin - try { - const npmBin = execSync("npm bin -g", { encoding: "utf8", shell: true, stdio: ["ignore", "pipe", "ignore"] }).trim(); - const routerLink = path.join(npmBin, "9router.cmd"); - if (fs.existsSync(routerLink)) { - routerScript = routerLink; - } else { - // Try to resolve actual script - const routerJs = path.join(npmBin, "../lib/node_modules/9router/cli.js"); - if (fs.existsSync(routerJs)) { - routerScript = routerJs; - } - } - } catch (e) { - // Fallback - } - } - - // Create VBS script to run hidden (no console window) - let vbsContent; - if (routerScript && routerScript.endsWith(".js")) { - // Run node directly with script - vbsContent = `Set WshShell = CreateObject("WScript.Shell") + const routerScript = getCliJsPath(cliPath); + if (!routerScript) return false; + + // Run node + cli.js directly, hidden window. Avoids the fragile + // `9router.cmd` lookup that depended on the npm prefix path. + const vbsContent = `Set WshShell = CreateObject("WScript.Shell") WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False `; - } else if (routerScript) { - // Run .cmd file - vbsContent = `Set WshShell = CreateObject("WScript.Shell") -WshShell.Run """${routerScript}"" --tray --skip-update", 0, False -`; - } else { - // Fallback to npx - vbsContent = `Set WshShell = CreateObject("WScript.Shell") -WshShell.Run "npx 9router --tray --skip-update", 0, False -`; - } - fs.writeFileSync(vbsPath, vbsContent); return true; } function disableWindows() { - const vbsPath = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); - + const vbsPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); if (fs.existsSync(vbsPath)) { fs.unlinkSync(vbsPath); } - return true; } @@ -259,37 +268,16 @@ function disableWindows() { function enableLinux(cliPath) { const autostartDir = path.join(os.homedir(), ".config", "autostart"); const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`); - - // Ensure directory exists + if (!fs.existsSync(autostartDir)) { - try { - fs.mkdirSync(autostartDir, { recursive: true }); - } catch (e) { - return false; - } + try { fs.mkdirSync(autostartDir, { recursive: true }); } + catch (e) { return false; } } - - // Get absolute paths + const nodePath = process.execPath; - let routerScript; - - if (cliPath) { - // Use provided path (from running cli.js) - routerScript = path.resolve(cliPath); - } else { - // Fallback: try to resolve from npm bin - try { - const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); - const routerLink = path.join(npmBin, "9router"); - if (fs.existsSync(routerLink)) { - routerScript = fs.realpathSync(routerLink); - } - } catch (e) { - // Last resort fallback - routerScript = "/usr/local/lib/node_modules/9router/cli.js"; - } - } - + const routerScript = getCliJsPath(cliPath); + if (!routerScript) return false; + const desktopContent = `[Desktop Entry] Type=Application Name=9Router @@ -299,18 +287,15 @@ Hidden=false NoDisplay=false X-GNOME-Autostart-enabled=true `; - fs.writeFileSync(desktopPath, desktopContent); return true; } function disableLinux() { const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`); - if (fs.existsSync(desktopPath)) { fs.unlinkSync(desktopPath); } - return true; }