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 @@
1import { describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
2 5
3import {6import {
4 cmdSetValue,7 cmdSetValue,
@@ -12,6 +15,14 @@ import { · 7 unmodified lines
12 parseSchtasksRunning,15 parseSchtasksRunning,
13} from "./service";16} from "./service";
14 17
18const tempDirs: Array<string> = [];
19
20afterEach(() => {
21 for (const dir of tempDirs.splice(0)) {
22 rmSync(dir, { recursive: true, force: true });
23 }
24});
25
15describe("service unit generation", () => {26describe("service unit generation", () => {
16 const launchdInput = {27 const launchdInput = {
17 label: "sh.executor.daemon",28 label: "sh.executor.daemon",
@@ -36,16 +47,49 @@ describe("service unit generation", () => { · 18 unmodified lines
36 workingDirectory: "/Users/x/.executor",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 it("renders a launchd plist that restarts on crash but not clean stop", () => {87 it("renders a launchd plist that restarts on crash but not clean stop", () => {
40 const plist = generateLaunchdPlist(launchdInput);88 const plist = generateLaunchdPlist(launchdInput);
89 expectLaunchdInvariants(plist, launchdInput);
41 expect(plist).toContain('<plist version="1.0">');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 // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout.91 // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout.
46 expect(plist).toContain("<key>KeepAlive</key>");
47 expect(plist).toContain("<key>SuccessfulExit</key>");92 expect(plist).toContain("<key>SuccessfulExit</key>");
48 expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/);
49 expect(plist).toContain("<key>ProcessType</key>");93 expect(plist).toContain("<key>ProcessType</key>");
50 expect(plist).toContain("<string>Background</string>");94 expect(plist).toContain("<string>Background</string>");
51 expect(plist).toContain("--foreground");95 expect(plist).toContain("--foreground");
@@ -59,6 +103,32 @@ describe("service unit generation", () => { · 7 unmodified lines
59 expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD");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 it("xml-escapes environment values", () => {132 it("xml-escapes environment values", () => {
63 const plist = generateLaunchdPlist({133 const plist = generateLaunchdPlist({
64 ...launchdInput,134 ...launchdInput,
apps/cli/src/service.ts+45−2
@@ -1,4 +1,5 @@
1import { execFile } from "node:child_process";1import { execFile } from "node:child_process";
2import { readFileSync } from "node:fs";
2import { homedir, userInfo } from "node:os";3import { homedir, userInfo } from "node:os";
3import { FileSystem, Path } from "effect";4import { FileSystem, Path } from "effect";
4import type { PlatformError } from "effect/PlatformError";5import type { PlatformError } from "effect/PlatformError";
@@ -143,6 +144,14 @@ const xmlEscape = (value: string): string => · 138 unmodified lines
143 .replaceAll('"', "&quot;")144 .replaceAll('"', "&quot;")
144 .replaceAll("'", "&apos;");145 .replaceAll("'", "&apos;");
145 146
147const xmlUnescape = (value: string): string =>
148 value
149 .replaceAll("&apos;", "'")
150 .replaceAll("&quot;", '"')
151 .replaceAll("&gt;", ">")
152 .replaceAll("&lt;", "<")
153 .replaceAll("&amp;", "&");
154
146// ---------------------------------------------------------------------------155// ---------------------------------------------------------------------------
147// Shared service environment + program args156// Shared service environment + program args
148// ---------------------------------------------------------------------------157// ---------------------------------------------------------------------------
@@ -209,6 +218,36 @@ const launchdPlistPath = (path: Path.Path): string => · 60 unmodified lines
209 218
210const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");219const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");
211 220
221const DESKTOP_APP_BUNDLE_IDENTIFIER = "sh.executor.desktop";
222
223const enclosingAppBundlePath = (executablePath: string): string | null => {
224 const match = executablePath.match(/^(.*\.app)\/Contents\//);
225 return match?.[1] ?? null;
226};
227
228const 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
239const 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
212export interface LaunchdPlistOptions {251export interface LaunchdPlistOptions {
213 readonly label: string;252 readonly label: string;
214 readonly programArguments: ReadonlyArray<string>;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 uses261 * Render a user LaunchAgent plist. KeepAlive uses
223 * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero262 * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero
224 * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM →263 * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM →
225 * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background264 * 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 const programArgs = options.programArguments268 const programArgs = options.programArguments
230 .map((arg) => ` <string>${xmlEscape(arg)}</string>`)269 .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
231 .join("\n");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 const envEntries = Object.entries(options.environment)275 const envEntries = Object.entries(options.environment)
233 .map(276 .map(
234 ([key, value]) =>277 ([key, value]) =>
@@ -241,7 +284,7 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 6 unmodified lines
241<dict>284<dict>
242 <key>Label</key>285 <key>Label</key>
243 <string>${xmlEscape(options.label)}</string>286 <string>${xmlEscape(options.label)}</string>
244 <key>ProgramArguments</key>287${associatedBundleIdentifiers} <key>ProgramArguments</key>
245 <array>288 <array>
246${programArgs}289${programArgs}
247 </array>290 </array>