Repository navigation
fix(cli): Linux autostart via systemd user service (fixes #2627) #2635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0816c5f
6c86d08
6ba1e4a
f468e20
f9aee72
dd819d1
387b026
521ae7c
1801749
d4f26a8
1369301
0967c92
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,16 +1,177 @@ | ||||||
| import { existsSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"; | ||||||
| import { existsSync, writeFileSync, unlinkSync, mkdirSync, realpathSync } from "node:fs"; | ||||||
| import { execSync } from "node:child_process"; | ||||||
| import { homedir } from "node:os"; | ||||||
| import { join, dirname } from "node:path"; | ||||||
| import { fileURLToPath } from "node:url"; | ||||||
|
|
||||||
| const APP_LABEL = "com.omniroute.autostart"; | ||||||
| const WIN_REG_VALUE = "OmniRoute"; | ||||||
| const LINUX_SERVICE_NAME = "omniroute.service"; | ||||||
| const LINUX_DESKTOP_NAME = "omniroute.desktop"; | ||||||
|
|
||||||
| function resolveCliPath() { | ||||||
| return ( | ||||||
| process.argv[1] || join(dirname(fileURLToPath(import.meta.url)), "..", "..", "omniroute.mjs") | ||||||
| ); | ||||||
| const candidates = []; | ||||||
| if (process.argv[1]) candidates.push(process.argv[1]); | ||||||
| try { | ||||||
| const which = execSync("command -v omniroute 2>/dev/null", { encoding: "utf8" }).trim(); | ||||||
| if (which) candidates.push(which); | ||||||
| } catch { | ||||||
| // command -v unavailable | ||||||
| } | ||||||
| candidates.push(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "omniroute.mjs")); | ||||||
|
|
||||||
| for (const candidate of candidates) { | ||||||
| if (!candidate) continue; | ||||||
| try { | ||||||
| const resolved = realpathSync(candidate); | ||||||
| if (resolved.endsWith("omniroute.mjs") && existsSync(resolved)) return resolved; | ||||||
| } catch { | ||||||
| // try next candidate | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| const fallback = candidates[candidates.length - 1]; | ||||||
| return existsSync(fallback) ? fallback : null; | ||||||
| } | ||||||
|
|
||||||
| function quoteExecArg(value) { | ||||||
| if (!/[ \t"'\\]/.test(value)) return value; | ||||||
| return `"${value.replace(/(["\\])/g, "\\$1")}"`; | ||||||
| } | ||||||
|
|
||||||
| function buildServeExecLine(cliPath, { tray = false } = {}) { | ||||||
| const parts = [quoteExecArg(process.execPath), quoteExecArg(cliPath), "serve", "--no-open"]; | ||||||
| if (tray) parts.push("--tray"); | ||||||
| return parts.join(" "); | ||||||
| } | ||||||
|
|
||||||
| function isGraphicalLinuxSession() { | ||||||
| if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return true; | ||||||
| if (process.env.XDG_CURRENT_DESKTOP) return true; | ||||||
| return false; | ||||||
| } | ||||||
|
|
||||||
| function linuxSystemdUnitPath() { | ||||||
| return join(homedir(), ".config", "systemd", "user", LINUX_SERVICE_NAME); | ||||||
| } | ||||||
|
|
||||||
| function linuxDesktopPath() { | ||||||
| return join(homedir(), ".config", "autostart", LINUX_DESKTOP_NAME); | ||||||
| } | ||||||
|
|
||||||
| function runUserSystemctl(args, { ignoreFailure = true } = {}) { | ||||||
| try { | ||||||
| execSync(`systemctl --user ${args}`, { stdio: "ignore" }); | ||||||
| return true; | ||||||
| } catch { | ||||||
| return ignoreFailure ? false : false; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| function isSystemdUserAvailable() { | ||||||
| try { | ||||||
| execSync("systemctl --user --version", { stdio: "ignore" }); | ||||||
| return true; | ||||||
| } catch { | ||||||
| return false; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| function isSystemdServiceEnabled() { | ||||||
| if (!existsSync(linuxSystemdUnitPath())) return false; | ||||||
| try { | ||||||
| execSync(`systemctl --user is-enabled ${LINUX_SERVICE_NAME}`, { stdio: "ignore" }); | ||||||
| return true; | ||||||
| } catch { | ||||||
| return false; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| function tryEnableLinger() { | ||||||
| try { | ||||||
| const user = | ||||||
| process.env.USER || | ||||||
| process.env.LOGNAME || | ||||||
| execSync("whoami", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); | ||||||
| if (!user) return false; | ||||||
| execSync(`loginctl enable-linger ${JSON.stringify(user)}`, { stdio: "ignore" }); | ||||||
| return true; | ||||||
| } catch { | ||||||
| return false; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| function writeLinuxSystemdUnit(cliPath) { | ||||||
| const unitDir = dirname(linuxSystemdUnitPath()); | ||||||
| mkdirSync(unitDir, { recursive: true }); | ||||||
| const envFile = join(homedir(), ".omniroute", ".env"); | ||||||
| const lines = [ | ||||||
| "[Unit]", | ||||||
| "Description=OmniRoute AI proxy router", | ||||||
| "After=network-online.target", | ||||||
| "Wants=network-online.target", | ||||||
| "", | ||||||
| "[Service]", | ||||||
| "Type=simple", | ||||||
| `ExecStart=${buildServeExecLine(cliPath, { tray: false })}`, | ||||||
| "Restart=on-failure", | ||||||
| "RestartSec=5", | ||||||
| ]; | ||||||
| if (existsSync(envFile)) lines.push(`EnvironmentFile=-${envFile}`); | ||||||
| lines.push("", "[Install]", "WantedBy=default.target", ""); | ||||||
| writeFileSync(linuxSystemdUnitPath(), `${lines.join("\n")}\n`, { mode: 0o644 }); | ||||||
| } | ||||||
|
|
||||||
| function writeLinuxDesktopEntry(cliPath) { | ||||||
| const dir = dirname(linuxDesktopPath()); | ||||||
| mkdirSync(dir, { recursive: true }); | ||||||
| const desktop = | ||||||
| [ | ||||||
| "[Desktop Entry]", | ||||||
| "Type=Application", | ||||||
| "Name=OmniRoute", | ||||||
| "Comment=AI proxy router with auto fallback", | ||||||
| `Exec=${buildServeExecLine(cliPath, { tray: true })}`, | ||||||
| "Terminal=false", | ||||||
| "Hidden=false", | ||||||
| "X-GNOME-Autostart-enabled=true", | ||||||
| ].join("\n") + "\n"; | ||||||
| writeFileSync(linuxDesktopPath(), desktop, { mode: 0o644 }); | ||||||
| } | ||||||
|
|
||||||
| export function getAutostartStatus() { | ||||||
| if (process.platform === "linux") { | ||||||
| const systemdUnit = linuxSystemdUnitPath(); | ||||||
| const desktopFile = linuxDesktopPath(); | ||||||
| const systemdEnabled = isSystemdServiceEnabled(); | ||||||
| const desktopEnabled = existsSync(desktopFile); | ||||||
| const enabled = systemdEnabled || desktopEnabled; | ||||||
| let mechanism = null; | ||||||
| if (systemdEnabled) mechanism = "systemd-user"; | ||||||
| else if (desktopEnabled) mechanism = "xdg-desktop"; | ||||||
| return { | ||||||
| enabled, | ||||||
| mechanism, | ||||||
| systemdUnit: existsSync(systemdUnit) ? systemdUnit : null, | ||||||
| desktopFile: desktopEnabled ? desktopFile : null, | ||||||
| linger: tryReadLingerEnabled(), | ||||||
| }; | ||||||
| } | ||||||
| return { enabled: isAutostartEnabled(), mechanism: null }; | ||||||
| } | ||||||
|
|
||||||
| function tryReadLingerEnabled() { | ||||||
| try { | ||||||
| const user = process.env.USER || process.env.LOGNAME; | ||||||
| if (!user) return null; | ||||||
|
Comment on lines
+165
to
+166
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For consistency with const user =
process.env.USER ||
process.env.LOGNAME ||
execSync("whoami", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
if (!user) return null; |
||||||
| const out = execSync(`loginctl show-user ${JSON.stringify(user)} -p Linger`, { | ||||||
| encoding: "utf8", | ||||||
| stdio: ["ignore", "pipe", "ignore"], | ||||||
| }); | ||||||
| return out.includes("Linger=yes"); | ||||||
| } catch { | ||||||
| return null; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| export function enable() { | ||||||
|
|
@@ -39,6 +200,7 @@ function enableMac() { | |||||
| mkdirSync(plistDir, { recursive: true }); | ||||||
| const plistPath = join(plistDir, `${APP_LABEL}.plist`); | ||||||
| const cliPath = resolveCliPath(); | ||||||
| if (!cliPath) return false; | ||||||
| const plist = `<?xml version="1.0" encoding="UTF-8"?> | ||||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||||
| <plist version="1.0"><dict> | ||||||
|
|
@@ -77,6 +239,7 @@ function isEnabledMac() { | |||||
|
|
||||||
| function enableWin() { | ||||||
| const cliPath = resolveCliPath(); | ||||||
| if (!cliPath) return false; | ||||||
| const value = `"${process.execPath}" "${cliPath}" serve --tray --no-open`; | ||||||
| try { | ||||||
| execSync( | ||||||
|
|
@@ -114,34 +277,46 @@ function isEnabledWin() { | |||||
| } | ||||||
|
|
||||||
| function enableLinux() { | ||||||
| const dir = join(homedir(), ".config", "autostart"); | ||||||
| mkdirSync(dir, { recursive: true }); | ||||||
| const cliPath = resolveCliPath(); | ||||||
| const desktop = | ||||||
| [ | ||||||
| "[Desktop Entry]", | ||||||
| "Type=Application", | ||||||
| "Name=OmniRoute", | ||||||
| "Comment=AI proxy router with auto fallback", | ||||||
| `Exec=${process.execPath} ${cliPath} serve --tray --no-open`, | ||||||
| "Terminal=false", | ||||||
| "Hidden=false", | ||||||
| "X-GNOME-Autostart-enabled=true", | ||||||
| ].join("\n") + "\n"; | ||||||
| writeFileSync(join(dir, "omniroute.desktop"), desktop, { mode: 0o644 }); | ||||||
| return true; | ||||||
| if (!cliPath) return false; | ||||||
|
|
||||||
| if (!isSystemdUserAvailable() && !isGraphicalLinuxSession()) { | ||||||
| return false; | ||||||
| } | ||||||
|
|
||||||
| let ok = false; | ||||||
|
|
||||||
| if (isSystemdUserAvailable()) { | ||||||
| writeLinuxSystemdUnit(cliPath); | ||||||
| runUserSystemctl("daemon-reload"); | ||||||
| ok = runUserSystemctl(`enable ${LINUX_SERVICE_NAME}`) || existsSync(linuxSystemdUnitPath()); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fallback
Suggested change
|
||||||
| runUserSystemctl(`start ${LINUX_SERVICE_NAME}`); | ||||||
| tryEnableLinger(); | ||||||
| } | ||||||
|
|
||||||
| if (isGraphicalLinuxSession()) { | ||||||
| writeLinuxDesktopEntry(cliPath); | ||||||
| ok = true; | ||||||
| } | ||||||
|
|
||||||
| return ok || isEnabledLinux(); | ||||||
| } | ||||||
|
|
||||||
| function disableLinux() { | ||||||
| const desktopPath = join(homedir(), ".config", "autostart", "omniroute.desktop"); | ||||||
| try { | ||||||
| unlinkSync(desktopPath); | ||||||
| return true; | ||||||
| } catch { | ||||||
| return false; | ||||||
| if (isSystemdUserAvailable()) { | ||||||
| runUserSystemctl(`disable --now ${LINUX_SERVICE_NAME}`); | ||||||
| runUserSystemctl("daemon-reload"); | ||||||
| } | ||||||
| try { | ||||||
| unlinkSync(linuxSystemdUnitPath()); | ||||||
| } catch {} | ||||||
| try { | ||||||
| unlinkSync(linuxDesktopPath()); | ||||||
| } catch {} | ||||||
| return !isEnabledLinux(); | ||||||
| } | ||||||
|
|
||||||
| function isEnabledLinux() { | ||||||
| return existsSync(join(homedir(), ".config", "autostart", "omniroute.desktop")); | ||||||
| if (isSystemdServiceEnabled()) return true; | ||||||
| return existsSync(linuxDesktopPath()); | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ternary operator
ignoreFailure ? false : falseis redundant as it always returnsfalse. Additionally, theignoreFailureparameter is currently ignored in the error path. If the intention is to allow errors to propagate whenignoreFailureis false, the logic should be updated to throw the error.