History

Associate the launchd agent with the app bundle in Login Items (#1283)

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.

Rhys Sullivan committed 4 months ago

Commit a380e341 parent 78efe022 files · +121 −8Browse files

Unified · Split

2 files
  1. apps/cli/src/service.test.ts +76 −6
  2. apps/cli/src/service.ts +45 −2
apps/cli/src/service.test.ts+76−6
@@ -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";
25
36 import {
47 cmdSetValue,
@@ -12,6 +15,14 @@ import { · 7 unmodified lines
1215 parseSchtasksRunning,
1316 } from "./service";
1417
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+
1526 describe("service unit generation", () => {
1627 const launchdInput = {
1728 label: "sh.executor.daemon",
@@ -36,16 +47,49 @@ describe("service unit generation", () => { · 18 unmodified lines
3647 workingDirectory: "/Users/x/.executor",
3748 };
3849
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+
3987 it("renders a launchd plist that restarts on crash but not clean stop", () => {
4088 const plist = generateLaunchdPlist(launchdInput);
89+ expectLaunchdInvariants(plist, launchdInput);
4190 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\/>/);
4591 // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout.
46− expect(plist).toContain("<key>KeepAlive</key>");
4792 expect(plist).toContain("<key>SuccessfulExit</key>");
48− expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/);
4993 expect(plist).toContain("<key>ProcessType</key>");
5094 expect(plist).toContain("<string>Background</string>");
5195 expect(plist).toContain("--foreground");
@@ -59,6 +103,32 @@ describe("service unit generation", () => { · 7 unmodified lines
59103 expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD");
60104 });
61105
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+
62132 it("xml-escapes environment values", () => {
63133 const plist = generateLaunchdPlist({
64134 ...launchdInput,
apps/cli/src/service.ts+45−2
@@ -1,4 +1,5 @@
11 import { execFile } from "node:child_process";
2+import { readFileSync } from "node:fs";
23 import { homedir, userInfo } from "node:os";
34 import { FileSystem, Path } from "effect";
45 import type { PlatformError } from "effect/PlatformError";
@@ -143,6 +144,14 @@ const xmlEscape = (value: string): string => · 138 unmodified lines
143144 .replaceAll('"', "&quot;")
144145 .replaceAll("'", "&apos;");
145146
147+const xmlUnescape = (value: string): string =>
148+ value
149+ .replaceAll("&apos;", "'")
150+ .replaceAll("&quot;", '"')
151+ .replaceAll("&gt;", ">")
152+ .replaceAll("&lt;", "<")
153+ .replaceAll("&amp;", "&");
154+
146155 // ---------------------------------------------------------------------------
147156 // Shared service environment + program args
148157 // ---------------------------------------------------------------------------
@@ -209,6 +218,36 @@ const launchdPlistPath = (path: Path.Path): string => · 60 unmodified lines
209218
210219 const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");
211220
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+
212251 export interface LaunchdPlistOptions {
213252 readonly label: string;
214253 readonly programArguments: ReadonlyArray<string>;
@@ -219,7 +258,7 @@ export interface LaunchdPlistOptions { · 4 unmodified lines
219258 }
220259
221260 /**
222− * Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses
261+ * Render a user LaunchAgent plist. KeepAlive uses
223262 * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero
224263 * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM →
225264 * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background
@@ -229,6 +268,10 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 3 unmodified lines
229268 const programArgs = options.programArguments
230269 .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
231270 .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+ : "";
232275 const envEntries = Object.entries(options.environment)
233276 .map(
234277 ([key, value]) =>
@@ -241,7 +284,7 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 6 unmodified lines
241284 <dict>
242285 <key>Label</key>
243286 <string>${xmlEscape(options.label)}</string>
244− <key>ProgramArguments</key>
287+${associatedBundleIdentifiers} <key>ProgramArguments</key>
245288 <array>
246289 ${programArgs}
247290 </array>