Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions desktop/garyx-desktop/src/main/gateway-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uid>` (Aqua sessions) or `user/<uid>`
// (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<string> {
Expand All @@ -58,12 +59,23 @@ async function tryRunCommand(file: string, args: string[]): Promise<string | nul
}
}

async function loadedDomainOutput(): Promise<{ target: string; output: string } | null> {
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<number | null> {
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;
}
Expand All @@ -72,12 +84,13 @@ async function getLaunchdPid(): Promise<number | null> {
}

async function ensureLaunchdLoaded(): Promise<void> {
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')) {
Expand All @@ -87,7 +100,11 @@ async function ensureLaunchdLoaded(): Promise<void> {
}

async function kickstartLaunchd(): Promise<void> {
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<number | null> {
Expand Down
31 changes: 30 additions & 1 deletion garyx-gateway/src/restart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uid>`, desktop
// login) or the per-user (`user/<uid>`, 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])
Expand Down Expand Up @@ -138,6 +146,27 @@ async fn try_launchd_restart() -> bool {
}
}

/// Return the `<domain>/<uid>/<service>` 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<String> {
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);
Expand Down
109 changes: 88 additions & 21 deletions garyx/src/service_manager/launchd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ impl LaunchdManager {
Self
}

fn domain(&self) -> Result<String, Box<dyn std::error::Error>> {
fn uid(&self) -> Result<String, Box<dyn std::error::Error>> {
let output = ProcessCommand::new("id").arg("-u").output()?;
if !output.status.success() {
return Err(format!(
Expand All @@ -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/<uid>`
/// 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/<uid>` 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<Vec<String>, Box<dyn std::error::Error>> {
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<String> {
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<String, Box<dyn std::error::Error>> {
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<PathBuf, Box<dyn std::error::Error>> {
Expand Down Expand Up @@ -78,27 +140,34 @@ impl LaunchdManager {
}

fn ensure_bootstrapped(&self, plist_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
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<String> = 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<dyn std::error::Error>> {
Expand Down Expand Up @@ -255,8 +324,6 @@ fn render_launch_agent_plist(
<true/>
<key>ThrottleInterval</key>
<integer>35</integer>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
{env_block} <key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
Expand Down
6 changes: 6 additions & 0 deletions garyx/src/service_manager/launchd/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ fn render_launch_agent_plist_uses_expected_label_and_program() {
assert!(plist.contains("<key>HardResourceLimits</key>"));
assert!(plist.contains("<integer>65536</integer>"));
assert!(!plist.contains("<integer>1024</integer>"));
// 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/<uid> on desktop, user/<uid> 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]
Expand Down
Loading