From bb8099399f9a8264572e873fe2ab70f80acb0116 Mon Sep 17 00:00:00 2001 From: Binlogo Date: Wed, 10 Jun 2026 16:52:20 +0800 Subject: [PATCH 1/2] Install the gateway launch agent into the SSH-reachable launchd domain hardcoded the gui/ launchd domain and pinned the plist to LimitLoadToSessionType=Aqua. Over SSH / headless logins the GUI domain is unreachable, so launchctl bootstrap failed with "125: Domain does not support specified action" and the gateway never ran (leaving plugins list empty), forcing users to hand-edit the plist and bootstrap into user/ themselves. Resolve the domain per session instead: prefer gui/ when the calling process is in the Aqua session (desktop parity), otherwise bootstrap into user/, with a fallback that tries the per-user domain if the GUI bootstrap is rejected. Probe both domains to find where the agent is actually loaded so stop/restart/uninstall and the runtime self-restart act on the live service regardless of which session installed it. Drop the Aqua session-type pin so the same headless unit loads in either domain. --- garyx-gateway/src/restart.rs | 31 +++++- garyx/src/service_manager/launchd.rs | 109 +++++++++++++++++---- garyx/src/service_manager/launchd/tests.rs | 6 ++ 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/garyx-gateway/src/restart.rs b/garyx-gateway/src/restart.rs index 3c0c27e7..8d3937ea 100644 --- a/garyx-gateway/src/restart.rs +++ b/garyx-gateway/src/restart.rs @@ -87,7 +87,15 @@ async fn try_launchd_restart() -> bool { return false; } - let service_target = format!("gui/{uid}/{LAUNCHD_SERVICE_NAME}"); + // The agent may be bootstrapped into either the GUI (`gui/`, desktop + // login) or the per-user (`user/`, SSH / headless) domain. Kickstart + // must target the domain it actually lives in, so probe both rather than + // assuming GUI. Falls back to the GUI target if neither prints (e.g. a + // dev `gateway run` not managed by launchd), where the stop/start and + // subprocess paths below still apply. + let service_target = resolve_loaded_target(&uid) + .await + .unwrap_or_else(|| format!("gui/{uid}/{LAUNCHD_SERVICE_NAME}")); match Command::new("launchctl") .args(["kickstart", "-k", &service_target]) @@ -138,6 +146,27 @@ async fn try_launchd_restart() -> bool { } } +/// Return the `//` target the agent is currently loaded +/// in, probing the GUI domain first and then the per-user domain. `None` when +/// the service is not loaded in either (the caller then assumes a sensible +/// default and relies on its further fallbacks). +#[cfg(not(test))] +async fn resolve_loaded_target(uid: &str) -> Option { + for domain in [format!("gui/{uid}"), format!("user/{uid}")] { + let target = format!("{domain}/{LAUNCHD_SERVICE_NAME}"); + let loaded = Command::new("launchctl") + .args(["print", &target]) + .output() + .await + .map(|out| out.status.success()) + .unwrap_or(false); + if loaded { + return Some(target); + } + } + None +} + #[cfg(not(test))] async fn try_subprocess_restart(exe: &std::path::Path, args: &[String]) -> bool { let mut cmd = Command::new(exe); diff --git a/garyx/src/service_manager/launchd.rs b/garyx/src/service_manager/launchd.rs index 836a9a44..32b95602 100644 --- a/garyx/src/service_manager/launchd.rs +++ b/garyx/src/service_manager/launchd.rs @@ -24,7 +24,7 @@ impl LaunchdManager { Self } - fn domain(&self) -> Result> { + fn uid(&self) -> Result> { let output = ProcessCommand::new("id").arg("-u").output()?; if !output.status.success() { return Err(format!( @@ -37,11 +37,73 @@ impl LaunchdManager { if uid.is_empty() { return Err("empty uid from `id -u`".into()); } - Ok(format!("gui/{uid}")) + Ok(uid) + } + + /// Whether the calling process lives in the GUI (Aqua) login session. + /// + /// Only an Aqua-session process can `bootstrap` into the `gui/` + /// domain; over SSH / headless logins the manager is the per-user + /// background domain and the GUI domain is unreachable (launchctl rejects + /// it with "Domain does not support specified action"). `managername` + /// reports the current process's domain manager, which is exactly the + /// signal that decides which domain we can install into. + fn is_aqua_session(&self) -> bool { + ProcessCommand::new(LAUNCHCTL_BIN) + .arg("managername") + .output() + .map(|out| { + out.status.success() + && String::from_utf8_lossy(&out.stdout).trim() == "Aqua" + }) + .unwrap_or(false) + } + + /// Domains to attempt when bootstrapping a not-yet-loaded agent, in + /// priority order. In an Aqua session we prefer `gui/` to keep parity + /// with historical desktop installs, falling back to the per-user domain; + /// over SSH the per-user domain is the only one available. + fn candidate_install_domains(&self) -> Result, Box> { + let uid = self.uid()?; + if self.is_aqua_session() { + Ok(vec![format!("gui/{uid}"), format!("user/{uid}")]) + } else { + Ok(vec![format!("user/{uid}")]) + } + } + + /// The domain the agent is currently loaded in, if any. Probing both + /// domains lets stop / restart / uninstall act on the live service + /// regardless of which session type installed it. + fn loaded_domain(&self) -> Option { + let uid = self.uid().ok()?; + for domain in [format!("gui/{uid}"), format!("user/{uid}")] { + let target = format!("{domain}/{LAUNCHD_SERVICE_NAME}"); + let loaded = ProcessCommand::new(LAUNCHCTL_BIN) + .args(["print", &target]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false); + if loaded { + return Some(domain); + } + } + None } fn target(&self) -> Result> { - Ok(format!("{}/{}", self.domain()?, LAUNCHD_SERVICE_NAME)) + // Prefer where the agent actually lives; otherwise fall back to the + // domain we would install into, so commands run before the first + // bootstrap still resolve a sensible target. + if let Some(domain) = self.loaded_domain() { + return Ok(format!("{domain}/{LAUNCHD_SERVICE_NAME}")); + } + let domain = self + .candidate_install_domains()? + .into_iter() + .next() + .ok_or("no launchd domain available for the current session")?; + Ok(format!("{domain}/{LAUNCHD_SERVICE_NAME}")) } fn plist_path(&self) -> Result> { @@ -78,27 +140,34 @@ impl LaunchdManager { } fn ensure_bootstrapped(&self, plist_path: &Path) -> Result<(), Box> { - let target = self.target()?; - let print_output = ProcessCommand::new(LAUNCHCTL_BIN) - .args(["print", &target]) - .output()?; - if print_output.status.success() { + if self.loaded_domain().is_some() { return Ok(()); } - let domain = self.domain()?; let plist_arg = plist_path.display().to_string(); - let output = ProcessCommand::new(LAUNCHCTL_BIN) - .args(["bootstrap", &domain, &plist_arg]) - .output()?; - if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("service already loaded") { - return Ok(()); + let mut last_err: Option = None; + for domain in self.candidate_install_domains()? { + let output = ProcessCommand::new(LAUNCHCTL_BIN) + .args(["bootstrap", &domain, &plist_arg]) + .output()?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("service already loaded") { + return Ok(()); + } + // Fall through to the next candidate domain — e.g. an Aqua session + // whose GUI domain rejected the bootstrap can still land in the + // per-user domain. + last_err = Some(format!( + "launchctl bootstrap {domain} failed: {}", + stderr.trim() + )); } - Err(format!("launchctl bootstrap failed: {}", stderr.trim()).into()) + Err(last_err + .unwrap_or_else(|| "launchctl bootstrap failed: no domain available".to_owned()) + .into()) } fn bootout(&self) -> Result<(), Box> { @@ -255,8 +324,6 @@ fn render_launch_agent_plist( ThrottleInterval 35 - LimitLoadToSessionType - Aqua {env_block} SoftResourceLimits NumberOfFiles diff --git a/garyx/src/service_manager/launchd/tests.rs b/garyx/src/service_manager/launchd/tests.rs index ada5639c..1d46d22c 100644 --- a/garyx/src/service_manager/launchd/tests.rs +++ b/garyx/src/service_manager/launchd/tests.rs @@ -31,6 +31,12 @@ fn render_launch_agent_plist_uses_expected_label_and_program() { assert!(plist.contains("HardResourceLimits")); assert!(plist.contains("65536")); assert!(!plist.contains("1024")); + // The unit must not pin itself to the Aqua (GUI) session: the agent is a + // headless HTTP server and is bootstrapped into an explicit domain + // (gui/ on desktop, user/ over SSH). An Aqua limit would stop it + // loading in the per-user domain that SSH / headless logins must use. + assert!(!plist.contains("LimitLoadToSessionType")); + assert!(!plist.contains("Aqua")); } #[test] From b54b43a0d3787a818c59571c703a3dc05b6cbaf3 Mon Sep 17 00:00:00 2001 From: Binlogo Date: Wed, 10 Jun 2026 18:52:56 +0800 Subject: [PATCH 2/2] Make desktop launchd control domain-aware The CLI can install the gateway into gui/ or user/, but the desktop launcher only probed and controlled gui/. After a user-domain install it would miss the live job, kill the running listener as unmanaged, and bootstrap a duplicate GUI job or error out. Probe both domains (matching the Rust loaded_domain logic) and drive print/kickstart against whichever domain actually holds the service. --- .../garyx-desktop/src/main/gateway-process.ts | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/desktop/garyx-desktop/src/main/gateway-process.ts b/desktop/garyx-desktop/src/main/gateway-process.ts index 5572aa99..ba90cb39 100644 --- a/desktop/garyx-desktop/src/main/gateway-process.ts +++ b/desktop/garyx-desktop/src/main/gateway-process.ts @@ -26,20 +26,21 @@ function setStatus(nextStatus: GatewayStatus): void { statusChangeHandler?.(); } -function launchdTarget(): string { - const uid = process.getuid?.(); - if (typeof uid !== 'number') { +function uid(): number { + const value = process.getuid?.(); + if (typeof value !== 'number') { throw new Error('launchd control requires a POSIX uid'); } - return `gui/${uid}/${LAUNCHD_SERVICE_NAME}`; + return value; } -function launchdDomain(): string { - const uid = process.getuid?.(); - if (typeof uid !== 'number') { - throw new Error('launchd control requires a POSIX uid'); - } - return `gui/${uid}`; +// Domains the gateway can be installed into, in the desktop's preferred order. +// The CLI may bootstrap into `gui/` (Aqua sessions) or `user/` +// (SSH/headless or the Aqua fallback), so the desktop must probe both rather +// than assuming the GUI domain. +function candidateDomains(): string[] { + const id = uid(); + return [`gui/${id}`, `user/${id}`]; } async function runCommand(file: string, args: string[]): Promise { @@ -58,12 +59,23 @@ async function tryRunCommand(file: string, args: string[]): Promise { + for (const domain of candidateDomains()) { + const target = `${domain}/${LAUNCHD_SERVICE_NAME}`; + const output = await tryRunCommand(LAUNCHCTL_BIN, ['print', target]); + if (output !== null) { + return { target, output }; + } + } + return null; +} + async function getLaunchdPid(): Promise { - const output = await tryRunCommand(LAUNCHCTL_BIN, ['print', launchdTarget()]); - if (!output) { + const loaded = await loadedDomainOutput(); + if (!loaded) { return null; } - const match = output.match(/\bpid = (\d+)\b/); + const match = loaded.output.match(/\bpid = (\d+)\b/); if (!match) { return null; } @@ -72,12 +84,13 @@ async function getLaunchdPid(): Promise { } async function ensureLaunchdLoaded(): Promise { - const existingPid = await getLaunchdPid(); - if (existingPid) { + if (await loadedDomainOutput()) { return; } + // Not loaded in any domain yet. The desktop launcher runs inside the Aqua + // session, so bootstrap into the GUI domain to match historical installs. try { - await runCommand(LAUNCHCTL_BIN, ['bootstrap', launchdDomain(), LAUNCHD_PLIST_PATH]); + await runCommand(LAUNCHCTL_BIN, ['bootstrap', `gui/${uid()}`, LAUNCHD_PLIST_PATH]); } catch (error) { const stderr = error instanceof Error ? error.message : String(error); if (!stderr.includes('service already loaded')) { @@ -87,7 +100,11 @@ async function ensureLaunchdLoaded(): Promise { } async function kickstartLaunchd(): Promise { - await runCommand(LAUNCHCTL_BIN, ['kickstart', '-k', launchdTarget()]); + const loaded = await loadedDomainOutput(); + if (!loaded) { + throw new Error('launchd gateway is not loaded in any domain'); + } + await runCommand(LAUNCHCTL_BIN, ['kickstart', '-k', loaded.target]); } async function getListeningPid(port: number): Promise {