macOS 13+ groups bare legacy LaunchAgents in System Settings > Login Items under the signing certificate developer name, so the Executor daemon showed up as "Rhys Sullivan" instead of "Executor". Emit AssociatedBundleIdentifiers pointing at the enclosing app bundle (read from its Info.plist) whenever the service binary lives inside a .app; standalone CLI installs are unchanged.
Unified · Split
| @@ -1,4 +1,7 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 2 | 5 | |
| 3 | 6 | import { |
| 4 | 7 | cmdSetValue, |
| @@ -12,6 +15,14 @@ import { · 7 unmodified lines | ||
| 12 | 15 | parseSchtasksRunning, |
| 13 | 16 | } from "./service"; |
| 14 | 17 | |
| 18 | +const tempDirs: Array<string> = []; | |
| 19 | + | |
| 20 | +afterEach(() => { | |
| 21 | + for (const dir of tempDirs.splice(0)) { | |
| 22 | + rmSync(dir, { recursive: true, force: true }); | |
| 23 | + } | |
| 24 | +}); | |
| 25 | + | |
| 15 | 26 | describe("service unit generation", () => { |
| 16 | 27 | const launchdInput = { |
| 17 | 28 | label: "sh.executor.daemon", |
| @@ -36,16 +47,49 @@ describe("service unit generation", () => { · 18 unmodified lines | ||
| 36 | 47 | workingDirectory: "/Users/x/.executor", |
| 37 | 48 | }; |
| 38 | 49 | |
| 50 | + const makeExecutorBundle = (bundleIdentifier: string): string => { | |
| 51 | + const root = mkdtempSync(join(tmpdir(), "executor-launchd-bundle-")); | |
| 52 | + tempDirs.push(root); | |
| 53 | + const contentsDir = join(root, "Executor.app", "Contents"); | |
| 54 | + const executableDir = join(contentsDir, "Resources", "executor"); | |
| 55 | + mkdirSync(executableDir, { recursive: true }); | |
| 56 | + writeFileSync( | |
| 57 | + join(contentsDir, "Info.plist"), | |
| 58 | + `<?xml version="1.0" encoding="UTF-8"?> | |
| 59 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 60 | +<plist version="1.0"> | |
| 61 | +<dict> | |
| 62 | + <key>CFBundleIdentifier</key> | |
| 63 | + <string>${bundleIdentifier}</string> | |
| 64 | +</dict> | |
| 65 | +</plist> | |
| 66 | +`, | |
| 67 | + ); | |
| 68 | + return join(executableDir, "executor"); | |
| 69 | + }; | |
| 70 | + | |
| 71 | + const expectLaunchdInvariants = (plist: string, input: typeof launchdInput): void => { | |
| 72 | + const programArguments = input.programArguments | |
| 73 | + .map((arg) => ` <string>${arg}</string>`) | |
| 74 | + .join("\n"); | |
| 75 | + | |
| 76 | + expect(plist).toContain("<key>Label</key>"); | |
| 77 | + expect(plist).toContain(`<string>${input.label}</string>`); | |
| 78 | + expect(plist).toContain( | |
| 79 | + ` <key>ProgramArguments</key>\n <array>\n${programArguments}\n </array>`, | |
| 80 | + ); | |
| 81 | + expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/); | |
| 82 | + expect(plist).toMatch( | |
| 83 | + /<key>KeepAlive<\/key>\s*<dict>\s*<key>SuccessfulExit<\/key>\s*<false\/>\s*<\/dict>/, | |
| 84 | + ); | |
| 85 | + }; | |
| 86 | + | |
| 39 | 87 | it("renders a launchd plist that restarts on crash but not clean stop", () => { |
| 40 | 88 | const plist = generateLaunchdPlist(launchdInput); |
| 89 | + expectLaunchdInvariants(plist, launchdInput); | |
| 41 | 90 | expect(plist).toContain('<plist version="1.0">'); |
| 42 | − expect(plist).toContain("<key>Label</key>"); | |
| 43 | − expect(plist).toContain("<string>sh.executor.daemon</string>"); | |
| 44 | − expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/); | |
| 45 | 91 | // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout. |
| 46 | − expect(plist).toContain("<key>KeepAlive</key>"); | |
| 47 | 92 | expect(plist).toContain("<key>SuccessfulExit</key>"); |
| 48 | − expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/); | |
| 49 | 93 | expect(plist).toContain("<key>ProcessType</key>"); |
| 50 | 94 | expect(plist).toContain("<string>Background</string>"); |
| 51 | 95 | expect(plist).toContain("--foreground"); |
| @@ -59,6 +103,32 @@ describe("service unit generation", () => { · 7 unmodified lines | ||
| 59 | 103 | expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD"); |
| 60 | 104 | }); |
| 61 | 105 | |
| 106 | + it("associates an app-bundled launchd plist with the enclosing bundle identifier", () => { | |
| 107 | + const executablePath = makeExecutorBundle("sh.executor.desktop.test"); | |
| 108 | + const input = { | |
| 109 | + ...launchdInput, | |
| 110 | + programArguments: [executablePath, ...launchdInput.programArguments.slice(1)], | |
| 111 | + }; | |
| 112 | + const plist = generateLaunchdPlist(input); | |
| 113 | + | |
| 114 | + expectLaunchdInvariants(plist, input); | |
| 115 | + expect(plist.match(/<key>AssociatedBundleIdentifiers<\/key>/g)).toHaveLength(1); | |
| 116 | + expect(plist).toMatch( | |
| 117 | + /<dict>\s*<key>Label<\/key>\s*<string>sh\.executor\.daemon<\/string>\s*<key>AssociatedBundleIdentifiers<\/key>\s*<array>\s*<string>sh\.executor\.desktop\.test<\/string>\s*<\/array>\s*<key>ProgramArguments<\/key>/, | |
| 118 | + ); | |
| 119 | + }); | |
| 120 | + | |
| 121 | + it("omits app association for standalone launchd binaries", () => { | |
| 122 | + const input = { | |
| 123 | + ...launchdInput, | |
| 124 | + programArguments: ["/usr/local/bin/executor", ...launchdInput.programArguments.slice(1)], | |
| 125 | + }; | |
| 126 | + const plist = generateLaunchdPlist(input); | |
| 127 | + | |
| 128 | + expectLaunchdInvariants(plist, input); | |
| 129 | + expect(plist).not.toContain("AssociatedBundleIdentifiers"); | |
| 130 | + }); | |
| 131 | + | |
| 62 | 132 | it("xml-escapes environment values", () => { |
| 63 | 133 | const plist = generateLaunchdPlist({ |
| 64 | 134 | ...launchdInput, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { execFile } from "node:child_process"; |
| 2 | +import { readFileSync } from "node:fs"; | |
| 2 | 3 | import { homedir, userInfo } from "node:os"; |
| 3 | 4 | import { FileSystem, Path } from "effect"; |
| 4 | 5 | import type { PlatformError } from "effect/PlatformError"; |
| @@ -143,6 +144,14 @@ const xmlEscape = (value: string): string => · 138 unmodified lines | ||
| 143 | 144 | .replaceAll('"', """) |
| 144 | 145 | .replaceAll("'", "'"); |
| 145 | 146 | |
| 147 | +const xmlUnescape = (value: string): string => | |
| 148 | + value | |
| 149 | + .replaceAll("'", "'") | |
| 150 | + .replaceAll(""", '"') | |
| 151 | + .replaceAll(">", ">") | |
| 152 | + .replaceAll("<", "<") | |
| 153 | + .replaceAll("&", "&"); | |
| 154 | + | |
| 146 | 155 | // --------------------------------------------------------------------------- |
| 147 | 156 | // Shared service environment + program args |
| 148 | 157 | // --------------------------------------------------------------------------- |
| @@ -209,6 +218,36 @@ const launchdPlistPath = (path: Path.Path): string => · 60 unmodified lines | ||
| 209 | 218 | |
| 210 | 219 | const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs"); |
| 211 | 220 | |
| 221 | +const DESKTOP_APP_BUNDLE_IDENTIFIER = "sh.executor.desktop"; | |
| 222 | + | |
| 223 | +const enclosingAppBundlePath = (executablePath: string): string | null => { | |
| 224 | + const match = executablePath.match(/^(.*\.app)\/Contents\//); | |
| 225 | + return match?.[1] ?? null; | |
| 226 | +}; | |
| 227 | + | |
| 228 | +const readBundleIdentifier = (appBundlePath: string): string | null => { | |
| 229 | + try { | |
| 230 | + const plist = readFileSync(`${appBundlePath}/Contents/Info.plist`, "utf8"); | |
| 231 | + const match = plist.match(/<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>([^<]*)<\/string>/); | |
| 232 | + const bundleIdentifier = match ? xmlUnescape(match[1].trim()) : ""; | |
| 233 | + return bundleIdentifier.length > 0 ? bundleIdentifier : null; | |
| 234 | + } catch { | |
| 235 | + return null; | |
| 236 | + } | |
| 237 | +}; | |
| 238 | + | |
| 239 | +const associatedBundleIdentifier = (programArguments: ReadonlyArray<string>): string | null => { | |
| 240 | + const executablePath = programArguments[0]; | |
| 241 | + if (!executablePath) return null; | |
| 242 | + const appBundlePath = enclosingAppBundlePath(executablePath); | |
| 243 | + if (!appBundlePath) return null; | |
| 244 | + const bundleIdentifier = readBundleIdentifier(appBundlePath); | |
| 245 | + if (bundleIdentifier) return bundleIdentifier; | |
| 246 | + return /(?:^|\/)Executor\.app\/Contents\//.test(executablePath) | |
| 247 | + ? DESKTOP_APP_BUNDLE_IDENTIFIER | |
| 248 | + : null; | |
| 249 | +}; | |
| 250 | + | |
| 212 | 251 | export interface LaunchdPlistOptions { |
| 213 | 252 | readonly label: string; |
| 214 | 253 | readonly programArguments: ReadonlyArray<string>; |
| @@ -219,7 +258,7 @@ export interface LaunchdPlistOptions { · 4 unmodified lines | ||
| 219 | 258 | } |
| 220 | 259 | |
| 221 | 260 | /** |
| 222 | − * Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses | |
| 261 | + * Render a user LaunchAgent plist. KeepAlive uses | |
| 223 | 262 | * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero |
| 224 | 263 | * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM → |
| 225 | 264 | * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background |
| @@ -229,6 +268,10 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 3 unmodified lines | ||
| 229 | 268 | const programArgs = options.programArguments |
| 230 | 269 | .map((arg) => ` <string>${xmlEscape(arg)}</string>`) |
| 231 | 270 | .join("\n"); |
| 271 | + const bundleIdentifier = associatedBundleIdentifier(options.programArguments); | |
| 272 | + const associatedBundleIdentifiers = bundleIdentifier | |
| 273 | + ? ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${xmlEscape(bundleIdentifier)}</string>\n </array>\n` | |
| 274 | + : ""; | |
| 232 | 275 | const envEntries = Object.entries(options.environment) |
| 233 | 276 | .map( |
| 234 | 277 | ([key, value]) => |
| @@ -241,7 +284,7 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 6 unmodified lines | ||
| 241 | 284 | <dict> |
| 242 | 285 | <key>Label</key> |
| 243 | 286 | <string>${xmlEscape(options.label)}</string> |
| 244 | − <key>ProgramArguments</key> | |
| 287 | +${associatedBundleIdentifiers} <key>ProgramArguments</key> | |
| 245 | 288 | <array> |
| 246 | 289 | ${programArgs} |
| 247 | 290 | </array> |