Skip to content

Commit 270b46e

Browse files
committed
Support installing launch agent to ~/Library/LaunchAgents when the app is not in /Applications
1 parent 55b29a0 commit 270b46e

2 files changed

Lines changed: 64 additions & 59 deletions

File tree

Core/Sources/HostApp/LaunchAgentManager.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ extension LaunchAgentManager {
77
serviceIdentifier: Bundle.main
88
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String +
99
".CommunicationBridge",
10-
executablePath: Bundle.main.bundleURL
10+
executableURL: Bundle.main.bundleURL
1111
.appendingPathComponent("Contents")
1212
.appendingPathComponent("Applications")
13-
.appendingPathComponent("CommunicationBridge")
14-
.path,
13+
.appendingPathComponent("CommunicationBridge"),
1514
bundleIdentifier: Bundle.main
1615
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String
1716
)

Core/Sources/LaunchAgentManager/LaunchAgentManager.swift

Lines changed: 62 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import ServiceManagement
44
public struct LaunchAgentManager {
55
let lastLaunchAgentVersionKey = "LastLaunchAgentVersion"
66
let serviceIdentifier: String
7-
let executablePath: String
7+
let executableURL: URL
88
let bundleIdentifier: String
99

1010
var launchAgentDirURL: URL {
@@ -16,15 +16,14 @@ public struct LaunchAgentManager {
1616
launchAgentDirURL.appendingPathComponent("\(serviceIdentifier).plist").path
1717
}
1818

19-
public init(serviceIdentifier: String, executablePath: String, bundleIdentifier: String) {
19+
public init(serviceIdentifier: String, executableURL: URL, bundleIdentifier: String) {
2020
self.serviceIdentifier = serviceIdentifier
21-
self.executablePath = executablePath
21+
self.executableURL = executableURL
2222
self.bundleIdentifier = bundleIdentifier
2323
}
2424

2525
public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws {
2626
if #available(macOS 13, *) {
27-
await removeObsoleteLaunchAgent()
2827
try await setupLaunchAgent()
2928
} else {
3029
if UserDefaults.standard.integer(forKey: lastLaunchAgentVersionKey) < 40 {
@@ -33,48 +32,18 @@ public struct LaunchAgentManager {
3332
}
3433
guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return }
3534
try await setupLaunchAgent()
36-
await removeObsoleteLaunchAgent()
3735
}
3836
}
3937

4038
public func setupLaunchAgent() async throws {
4139
if #available(macOS 13, *) {
42-
let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
43-
try bridgeLaunchAgent.register()
44-
} else {
45-
let content = """
46-
<?xml version="1.0" encoding="UTF-8"?>
47-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
48-
<plist version="1.0">
49-
<dict>
50-
<key>Label</key>
51-
<string>\(serviceIdentifier)</string>
52-
<key>Program</key>
53-
<string>\(executablePath)</string>
54-
<key>MachServices</key>
55-
<dict>
56-
<key>\(serviceIdentifier)</key>
57-
<true/>
58-
</dict>
59-
<key>AssociatedBundleIdentifiers</key>
60-
<array>
61-
<string>\(bundleIdentifier)</string>
62-
<string>\(serviceIdentifier)</string>
63-
</array>
64-
</dict>
65-
</plist>
66-
"""
67-
if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) {
68-
try FileManager.default.createDirectory(
69-
at: launchAgentDirURL,
70-
withIntermediateDirectories: false
71-
)
40+
if executableURL.path.hasPrefix("/Applications") {
41+
try setupLaunchAgentWithPredefinedPlist()
42+
} else {
43+
try await setupLaunchAgentWithDynamicPlist()
7244
}
73-
FileManager.default.createFile(
74-
atPath: launchAgentPath,
75-
contents: content.data(using: .utf8)
76-
)
77-
try await launchctl("load", launchAgentPath)
45+
} else {
46+
try await setupLaunchAgentWithDynamicPlist()
7847
}
7948

8049
let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String)
@@ -85,7 +54,11 @@ public struct LaunchAgentManager {
8554
public func removeLaunchAgent() async throws {
8655
if #available(macOS 13, *) {
8756
let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
88-
try await bridgeLaunchAgent.unregister()
57+
try? await bridgeLaunchAgent.unregister()
58+
if FileManager.default.fileExists(atPath: launchAgentPath) {
59+
try? await launchctl("unload", launchAgentPath)
60+
try? FileManager.default.removeItem(atPath: launchAgentPath)
61+
}
8962
} else {
9063
try await launchctl("unload", launchAgentPath)
9164
try FileManager.default.removeItem(atPath: launchAgentPath)
@@ -97,23 +70,56 @@ public struct LaunchAgentManager {
9770
try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier)
9871
}
9972
}
73+
}
10074

101-
public func removeObsoleteLaunchAgent() async {
102-
if #available(macOS 13, *) {
103-
let path = launchAgentPath
104-
if FileManager.default.fileExists(atPath: path) {
105-
try? await launchctl("unload", path)
106-
try? FileManager.default.removeItem(atPath: path)
107-
}
108-
} else {
109-
let path = launchAgentPath.replacingOccurrences(
110-
of: "ExtensionService",
111-
with: "XPCService"
75+
extension LaunchAgentManager {
76+
@available(macOS 13, *)
77+
func setupLaunchAgentWithPredefinedPlist() throws {
78+
let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
79+
try bridgeLaunchAgent.register()
80+
}
81+
82+
func setupLaunchAgentWithDynamicPlist() async throws {
83+
if FileManager.default.fileExists(atPath: launchAgentPath) {
84+
throw E(errorDescription: "Launch agent already exists.")
85+
}
86+
87+
let content = """
88+
<?xml version="1.0" encoding="UTF-8"?>
89+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
90+
<plist version="1.0">
91+
<dict>
92+
<key>Label</key>
93+
<string>\(serviceIdentifier)</string>
94+
<key>Program</key>
95+
<string>\(executableURL.path)</string>
96+
<key>MachServices</key>
97+
<dict>
98+
<key>\(serviceIdentifier)</key>
99+
<true/>
100+
</dict>
101+
<key>AssociatedBundleIdentifiers</key>
102+
<array>
103+
<string>\(bundleIdentifier)</string>
104+
<string>\(serviceIdentifier)</string>
105+
</array>
106+
</dict>
107+
</plist>
108+
"""
109+
if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) {
110+
try FileManager.default.createDirectory(
111+
at: launchAgentDirURL,
112+
withIntermediateDirectories: false
112113
)
113-
if FileManager.default.fileExists(atPath: path) {
114-
try? FileManager.default.removeItem(atPath: path)
115-
}
116114
}
115+
FileManager.default.createFile(
116+
atPath: launchAgentPath,
117+
contents: content.data(using: .utf8)
118+
)
119+
#if DEBUG
120+
#else
121+
try await launchctl("load", launchAgentPath)
122+
#endif
117123
}
118124
}
119125

@@ -170,7 +176,7 @@ private func launchctl(_ args: String...) async throws {
170176
return try await process("/bin/launchctl", args)
171177
}
172178

173-
struct E: Error, LocalizedError {
179+
private struct E: Error, LocalizedError {
174180
var errorDescription: String?
175181
}
176182

0 commit comments

Comments
 (0)