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; }