// generate + ad-hoc sign the RNX Daemon.app wrapper that the launchd // agent points its ProgramArguments at. // // why this exists: macOS Background Task Management (the "X wants to run in // the background" prompt + the Login Items entry) reads the *signer* of // ProgramArguments[0] to decide what name to display. when launchd is // pointed at bun directly the entry shows "software from Jarred Sumner" // because that's bun's code-signing identity. wrapping the real invocation // in an ad-hoc-signed `.app` lets BTM read CFBundleDisplayName from the // bundle Info.plist instead, so the entry reads "RNX Daemon". // // the bundle is regenerated every time `daemonInstall` runs — it's tiny // (a plist + a shell launcher) and the launcher embeds a snapshot of the // invocation, so `rnx upgrade` followed by `rnx daemon install --force` is // the documented upgrade path. there's no notarization // or Developer ID signing here yet; ad-hoc + hardened runtime is enough to // fix the attribution, and a real signed bundle (shipped via the runtime // CDN) is a follow-up. import { execFileSync } from 'child_process' import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'fs' import { dirname } from 'path' import { daemonAppBundlePath, daemonAppDir, daemonAppLauncherPath, } from '../../src/home-paths' import type { RnxSelfInvocation } from '../self-invocation' const BUNDLE_IDENTIFIER = 'dev.sootsim.daemon' const BUNDLE_DISPLAY_NAME = 'RNX Daemon' // kept in sync with package.json on release. exact value doesn't matter for // BTM display — macOS only reads it for "About this app" surfaces — so we // hardcode it rather than reach back into the package.json from a runtime // path that varies between npm-global, bun-compiled standalone, and dev // checkouts. const BUNDLE_VERSION = '0.1.36' export interface DaemonAppPaths { /** the rebuilt .app bundle. */ bundlePath: string /** the Mach-O / shell launcher inside the bundle. launchd points here. */ launcherPath: string } /** rebuild ~/.rnx/daemon-app/RNX Daemon.app/ for the given invocation * and re-sign it ad-hoc. safe to call repeatedly; the directory is wiped * before write so stale state from earlier installs can't leak through. */ export function ensureDaemonApp( invocation: RnxSelfInvocation, port: number, logDir: { stdout: string; stderr: string }, ): DaemonAppPaths { const bundlePath = daemonAppBundlePath() const launcherPath = daemonAppLauncherPath() const contentsDir = dirname(dirname(launcherPath)) const macOSDir = dirname(launcherPath) const resourcesDir = `${contentsDir}/Resources` rmSync(bundlePath, { recursive: true, force: true }) mkdirSync(daemonAppDir(), { recursive: true }) mkdirSync(macOSDir, { recursive: true }) mkdirSync(resourcesDir, { recursive: true }) writeFileSync(`${contentsDir}/Info.plist`, renderInfoPlist()) writeFileSync(`${contentsDir}/PkgInfo`, 'APPL????') writeFileSync(launcherPath, renderLauncherScript(invocation, port, logDir)) chmodSync(launcherPath, 0o755) // ad-hoc sign the bundle so macOS treats it as a real .app and BTM reads // its Info.plist. `--deep` covers the inner launcher script + future // resources without us having to enumerate them. signing is best-effort — // an unsigned bundle still works for launchd, it just falls back to the // worse BTM display, which is no worse than the status quo. try { execFileSync('codesign', ['--force', '--sign', '-', '--deep', bundlePath], { stdio: 'pipe', }) } catch (err) { const msg = err instanceof Error ? err.message : String(err) console.warn( ` warning: ad-hoc codesign of ${bundlePath} failed: ${msg}\n` + ` the daemon will still run, but Login Items may show a generic name.`, ) } return { bundlePath, launcherPath } } function renderInfoPlist(): string { // LSUIElement + LSBackgroundOnly together hide the app from the Dock and // the Cmd-Tab switcher. CFBundleDisplayName is what Login Items shows. // NSHumanReadableCopyright is what "About" surfaces show. return ` CFBundleDevelopmentRegionen CFBundleDisplayName${BUNDLE_DISPLAY_NAME} CFBundleExecutablernx-daemon CFBundleIdentifier${BUNDLE_IDENTIFIER} CFBundleInfoDictionaryVersion6.0 CFBundleName${BUNDLE_DISPLAY_NAME} CFBundlePackageTypeAPPL CFBundleShortVersionString${BUNDLE_VERSION} CFBundleSignature???? CFBundleVersion${BUNDLE_VERSION} LSBackgroundOnly LSUIElement LSMinimumSystemVersion13.0 NSHumanReadableCopyrightTamagui LLC ` } function renderLauncherScript( invocation: RnxSelfInvocation, port: number, logDir: { stdout: string; stderr: string }, ): string { // `exec` replaces the shell process with bun/node, so launchd's PID // tracking + KeepAlive both follow the real daemon process. stdout/stderr // are already redirected by the launchd plist's StandardOutPath / // StandardErrorPath, so we don't redirect again here — doing so would // hide log output from `launchctl print`. const parts = [ invocation.executable, ...invocation.prefixArgs, 'serve', '--quiet', '--port', String(port), ] const quoted = parts.map(shellSingleQuote).join(' ') const stdout = shellSingleQuote(logDir.stdout) const stderr = shellSingleQuote(logDir.stderr) const rnxHome = process.env.RNX_HOME const environment = rnxHome ? `export RNX_HOME=${shellSingleQuote(rnxHome)}\n` : '' return `#!/bin/sh # rnx daemon launcher generated by 'rnx daemon install'. # launchd spawns this; we exec the real rnx invocation in place so # launchd tracks the daemon process, not the shell wrapper. # # stdout: ${logDir.stdout} # stderr: ${logDir.stderr} # a missing or broken executable can make launchd retry forever. truncate a # retry log before it passes 10 MiB so that failure stays bounded on disk. for log_path in ${stdout} ${stderr}; do if [ -f "$log_path" ]; then log_bytes=$(wc -c < "$log_path" 2>/dev/null || echo 0) if [ "$log_bytes" -gt 10485760 ]; then : > "$log_path" fi fi done ${environment}exec ${quoted} ` } function shellSingleQuote(value: string): string { // single-quote everything and escape embedded single quotes the POSIX way. // safe for paths containing spaces, $, backticks, etc. return `'${value.replace(/'/g, `'\\''`)}'` }