03ecc9e29a
- Settings → About → "Start AgentHub automatically" (AutostartSettings.tsx,
GET/PUT /api/settings/autostart, server/lib/autostart.js). The switch is
the systemd --user unit's / LaunchAgent's enabled state: on writes a unit
for the running server when none exists and enables it, off disables it;
neither starts or stops the server. A foreign unit is never touched
(409 EFOREIGN)
- The plugin bootstrap no longer installs a unit on every install: it
installs or starts one only while the user has it enabled, and a rewrite
keeps the enabled state (writeUserService({ enable })). An install that
already had the unit enabled stays on. A stale-but-enabled unit beside a
live detached server is repointed, no longer started onto the bound port
- unitIsEnabled / setUnitEnabled in systemd-service.js and launchd-service.js
Also:
- fix(workspace): status bar no longer covers the chat box below md (#61)
265 lines
8.3 KiB
JavaScript
265 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @file launchd-service.js
|
|
* @description The macOS twin of `systemd-service.js`: a per-user LaunchAgent
|
|
* (`~/Library/LaunchAgents/io.smartgift.<name>.plist`) that starts the AgentHub
|
|
* dashboard at login and keeps it running, without Claude Code being opened
|
|
* first. Same exports and the same return values as the systemd module, so
|
|
* `service-manager.js` can hand either one to the bootstrap, the CLI and the
|
|
* doctor. Every `launchctl` call goes through one seam (`__setExecImpl`) so
|
|
* tests never touch a real launchd.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
|
|
const MARKER = "<!-- agenthub-managed-unit — regenerated on install, do not edit by hand -->";
|
|
|
|
const kind = "launchd";
|
|
const label = "LaunchAgent";
|
|
|
|
function jobLabel(name) {
|
|
return `io.smartgift.${name}`;
|
|
}
|
|
|
|
function unitPath(name) {
|
|
return path.join(os.homedir(), "Library", "LaunchAgents", `${jobLabel(name)}.plist`);
|
|
}
|
|
|
|
function logPath() {
|
|
return path.join(os.homedir(), "Library", "Logs", "agenthub.log");
|
|
}
|
|
|
|
function domain() {
|
|
return `gui/${process.getuid()}`;
|
|
}
|
|
|
|
let execImpl = (args, opts) => spawnSync("launchctl", args, opts);
|
|
|
|
function __setExecImpl(fn) {
|
|
execImpl = fn;
|
|
}
|
|
function __reset() {
|
|
execImpl = (args, opts) => spawnSync("launchctl", args, opts);
|
|
}
|
|
|
|
function launchctl(args) {
|
|
return execImpl(args, { encoding: "utf8", stdio: "pipe" });
|
|
}
|
|
|
|
/**
|
|
* A LaunchAgent lives in the user's GUI domain, which exists only while that
|
|
* user is logged in at the console. Over a bare SSH login there is none, and
|
|
* the caller falls back to its detached spawn.
|
|
*/
|
|
function systemctlAvailable() {
|
|
if (process.platform !== "darwin") return false;
|
|
return launchctl(["print", domain()]).status === 0;
|
|
}
|
|
|
|
function readPlist(name) {
|
|
try {
|
|
return fs.readFileSync(unitPath(name), "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function unitIsOurs(name) {
|
|
const body = readPlist(name);
|
|
return !!body && body.includes(MARKER);
|
|
}
|
|
|
|
const xmlEscape = (s) =>
|
|
String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
const xmlUnescape = (s) => s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
|
|
function workingDirOf(body) {
|
|
const m = body.match(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/);
|
|
return m ? xmlUnescape(m[1]) : null;
|
|
}
|
|
|
|
/** Same contract as the systemd twin: pure fs, never a foreign plist. */
|
|
function unitIsStale(name, root) {
|
|
const body = readPlist(name);
|
|
if (!body || !body.includes(MARKER)) return false;
|
|
return workingDirOf(body) !== root;
|
|
}
|
|
|
|
/** The directory the plist runs from — ours or not (the cache pruner's guard). */
|
|
function unitRoot(name) {
|
|
const body = readPlist(name);
|
|
return body ? workingDirOf(body) : null;
|
|
}
|
|
|
|
function isLoaded(name) {
|
|
return launchctl(["print", `${domain()}/${jobLabel(name)}`]).status === 0;
|
|
}
|
|
|
|
function unitIsActive(name) {
|
|
if (!systemctlAvailable()) return false;
|
|
const res = launchctl(["print", `${domain()}/${jobLabel(name)}`]);
|
|
return res.status === 0 && /^\s*state = running$/m.test(String(res.stdout || ""));
|
|
}
|
|
|
|
/** Is our plist set to load at login — the Settings "start automatically"
|
|
* switch. A `launchctl disable` override is what turns it off. */
|
|
function unitIsEnabled(name) {
|
|
if (!systemctlAvailable() || !unitIsOurs(name)) return false;
|
|
const res = launchctl(["print-disabled", domain()]);
|
|
const line = String(res.stdout || "")
|
|
.split("\n")
|
|
.find((l) => l.includes(`"${jobLabel(name)}"`));
|
|
return !line || !/=>\s*(disabled|true)/.test(line);
|
|
}
|
|
|
|
/** Enable or disable loading at login — never loads or unloads the job, so
|
|
* the server answering the switch stays up either way. */
|
|
function setUnitEnabled(name, on) {
|
|
if (!systemctlAvailable()) return "unavailable";
|
|
if (!unitIsOurs(name)) return "foreign";
|
|
const res = launchctl([on ? "enable" : "disable", `${domain()}/${jobLabel(name)}`]);
|
|
return res.status === 0 ? "ok" : "failed";
|
|
}
|
|
|
|
/**
|
|
* `argv` is the program and its arguments as separate strings (the systemd
|
|
* twin's `execStart` is one line; a path with a space would split here).
|
|
* `KeepAlive` true is `Restart=always`. `AbandonProcessGroup` keeps the lane
|
|
* panes alive: they run in a tmux server the dashboard spawned, and launchd
|
|
* otherwise kills a stopped job's whole process group — every running agent
|
|
* with it, on every update.
|
|
*/
|
|
function unitContent({ name, workingDir, argv, env }) {
|
|
const str = (v) => `<string>${xmlEscape(v)}</string>`;
|
|
const envLines = Object.entries(env)
|
|
.map(([k, v]) => `\t\t<key>${xmlEscape(k)}</key>\n\t\t${str(v)}`)
|
|
.join("\n");
|
|
return `<?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">
|
|
${MARKER}
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
${str(jobLabel(name))}
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
${argv.map((a) => `\t\t${str(a)}`).join("\n")}
|
|
</array>
|
|
<key>WorkingDirectory</key>
|
|
${str(workingDir)}
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
${envLines}
|
|
</dict>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<true/>
|
|
<key>ThrottleInterval</key>
|
|
<integer>3</integer>
|
|
<key>AbandonProcessGroup</key>
|
|
<true/>
|
|
<key>StandardOutPath</key>
|
|
${str(logPath())}
|
|
<key>StandardErrorPath</key>
|
|
${str(logPath())}
|
|
</dict>
|
|
</plist>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Writes the plist and clears any "disabled" override (unless `enable:
|
|
* false`, which leaves it as it is) — and starts nothing.
|
|
* A loaded job keeps running its OLD definition until `restartUserService`;
|
|
* an unloaded one is picked up at the next login. Same split, same reason, as
|
|
* the systemd twin.
|
|
*
|
|
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
|
*/
|
|
function writeUserService({ name, workingDir, argv, env = {}, enable = true }) {
|
|
if (!systemctlAvailable()) return "unavailable";
|
|
const body = unitContent({ name, workingDir, argv, env });
|
|
const target = unitPath(name);
|
|
try {
|
|
const current = readPlist(name);
|
|
if (current !== null) {
|
|
if (!current.includes(MARKER)) return "foreign";
|
|
if (current === body) return "unchanged";
|
|
}
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.mkdirSync(path.dirname(logPath()), { recursive: true });
|
|
fs.writeFileSync(target, body, "utf8");
|
|
if (enable) launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
|
|
return "written";
|
|
} catch {
|
|
return "failed";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reload the job from its plist. `kickstart -k` would restart the process but
|
|
* keep the definition launchd already holds — the old plugin root — so it is
|
|
* bootout + bootstrap. Unlike systemd, the caller is not reaped with the job:
|
|
* it runs in its own process group and the job abandons its group anyway.
|
|
*/
|
|
function restartUserService(name) {
|
|
if (!systemctlAvailable()) return "unavailable";
|
|
launchctl(["bootout", `${domain()}/${jobLabel(name)}`]); // not loaded: fine
|
|
const res = launchctl(["bootstrap", domain(), unitPath(name)]);
|
|
return res.status === 0 ? "restarted" : "failed";
|
|
}
|
|
|
|
/** Write, then load and start. For `agenthub service install` and a bootstrap
|
|
* that owns no running job. */
|
|
function installUserService(opts) {
|
|
const result = writeUserService(opts);
|
|
if (result === "written" || (result === "unchanged" && !isLoaded(opts.name))) {
|
|
if (restartUserService(opts.name) !== "restarted") return "failed";
|
|
} else if (result === "unchanged") {
|
|
launchctl(["kickstart", `${domain()}/${jobLabel(opts.name)}`]); // start if stopped
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
|
|
function uninstallUserService(name) {
|
|
if (!systemctlAvailable()) return "unavailable";
|
|
const body = readPlist(name);
|
|
if (body === null) return "absent";
|
|
if (!body.includes(MARKER)) return "foreign";
|
|
launchctl(["bootout", `${domain()}/${jobLabel(name)}`]);
|
|
fs.rmSync(unitPath(name), { force: true });
|
|
return "removed";
|
|
}
|
|
|
|
/** A LaunchAgent starts at login by definition; there is no linger to grant. */
|
|
function enableLinger() {
|
|
return true;
|
|
}
|
|
|
|
module.exports = {
|
|
kind,
|
|
label,
|
|
systemctlAvailable,
|
|
unitIsOurs,
|
|
unitIsStale,
|
|
unitRoot,
|
|
unitIsActive,
|
|
unitIsEnabled,
|
|
setUnitEnabled,
|
|
writeUserService,
|
|
restartUserService,
|
|
installUserService,
|
|
uninstallUserService,
|
|
enableLinger,
|
|
unitPath,
|
|
logPath,
|
|
__setExecImpl,
|
|
__reset,
|
|
};
|