├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── sLaunchctl
│ ├── Internals
│ ├── Error.swift
│ ├── OutputParser.swift
│ └── Utils.swift
│ ├── Launchctl.swift
│ └── Service.swift
└── Tests
└── sLaunchctlTests
├── LaunchctlParsingTests.swift
├── LiveTesting.swift
└── ServiceParsingTests.swift
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | pull_request:
7 | branches: "*"
8 |
9 | jobs:
10 | build:
11 | strategy:
12 | matrix:
13 | include:
14 | - xcode: "14.2" # Swift 5.7
15 | macOS: "13"
16 | iOS: "16.0"
17 | - xcode: "15.0" # Swift 5.9
18 | macOS: "13"
19 | iOS: "17.0"
20 | fail-fast: false
21 |
22 | runs-on: macos-${{ matrix.macOS }}
23 | name: Build with Xcode ${{ matrix.xcode }} on macOS ${{ matrix.macOS }}
24 |
25 | steps:
26 | - uses: actions/checkout@v3
27 |
28 | - name: Xcode Select Version
29 | uses: mobiledevops/xcode-select-version-action@v1
30 | with:
31 | xcode-select-version: ${{ matrix.xcode }}
32 | - run: xcodebuild -version
33 |
34 | - name: Test macOS with Xcode ${{ matrix.xcode }}
35 | run: |
36 | set -e
37 | set -o pipefail
38 |
39 | xcodebuild test -scheme sLaunchctl -destination "platform=macOS" SWIFT_ACTIVE_COMPILATION_CONDITIONS="SPELLBOOK_SLOW_CI_x20" | xcpretty
40 |
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 | /Package.resolved
92 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Vladimir (Alkenso)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.7
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "sLaunchctl",
8 | platforms: [.macOS(.v10_15)],
9 | products: [
10 | .library(
11 | name: "sLaunchctl",
12 | targets: ["sLaunchctl"]
13 | ),
14 | ],
15 | dependencies: [
16 | .package(url: "https://github.com/Alkenso/SwiftSpellbook.git", from: "0.4.1"),
17 | .package(url: "https://github.com/Alkenso/SwiftSpellbook_macOS.git", from: "0.0.2"),
18 | ],
19 | targets: [
20 | .target(
21 | name: "sLaunchctl",
22 | dependencies: [
23 | .product(name: "SpellbookFoundation", package: "SwiftSpellbook"),
24 | .product(name: "Spellbook_macOS", package: "SwiftSpellbook_macOS"),
25 | ]
26 | ),
27 | .testTarget(
28 | name: "sLaunchctlTests",
29 | dependencies: [
30 | "sLaunchctl",
31 | .product(name: "SpellbookFoundation", package: "SwiftSpellbook"),
32 | ]
33 | ),
34 | ]
35 | )
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | The package now is part of [SwiftSpellbook_macOS](https://github.com/Alkenso/SwiftSpellbook_macOS)
2 |
3 | # sLaunchctl - Swift API to manage daemons and user-agents
4 |
5 | Developing for macOS often assumes interaction with root daemons and user agents.
6 | Unfortunately, Apple does not provide any actual API. (Existing SMJobXxx is deprecated)
7 |
8 | sLaunchctl fills this gap providing convenient API that wraps up launchctl tool.
9 |
10 | Read the article dedicated to the package: [sLaunchctl — Swift API to manage Daemons and Agents](https://medium.com/@alkenso/slaunchctl-swift-api-to-manage-daemons-and-agents-eea357f04782)
11 |
12 | ### Library family
13 | You can also find Swift libraries for macOS / *OS development
14 | - [SwiftSpellbook](https://github.com/Alkenso/SwiftSpellbook): Swift common extensions and utilities used in everyday development
15 | - [sXPC](https://github.com/Alkenso/sXPC): Swift type-safe wrapper around NSXPCConnection and proxy object
16 | - [sMock](https://github.com/Alkenso/sMock): Swift unit-test mocking framework similar to gtest/gmock
17 | - [sEndpontSecurity](https://github.com/Alkenso/sEndpointSecurity): Swift wrapper around EndpointSecurity.framework
18 |
19 | ## Examples
20 |
21 | #### Bootstrap
22 | ```
23 | try Launchctl.system.bootstrap(URL(fileURLWithPath: "/path/to/com.my.daemon.plist"))
24 | try Launchctl.gui().bootstrap(URL(fileURLWithPath: "/path/to/com.my.user_agent.plist"))
25 | ```
26 |
27 | #### Bootout daemon
28 | ```
29 | try Launchctl.system.bootout(URL(fileURLWithPath: "/path/to/com.my.daemon.plist"))
30 | try Launchctl.gui().bootout(URL(fileURLWithPath: "/path/to/com.my.user_agent.plist"))
31 | ```
32 |
33 | #### List all daemons
34 | ```
35 | let rootDaemons = try Launchctl.system.list()
36 | let user505Agents = try Launchctl.gui(505).list()
37 | ```
38 |
39 | #### Find many more functional inside sLaunchctl!
40 |
--------------------------------------------------------------------------------
/Sources/sLaunchctl/Internals/Error.swift:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2022 Alkenso (Vladimir Vashurkin)
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | import Foundation
24 |
25 | extension NSError {
26 | internal convenience init(launchctlExitCode: Int32, stderr: String, underlyingError: Error? = nil) {
27 | var userInfo: [String: Any] = [
28 | NSDebugDescriptionErrorKey: Self.xpc_strerr(launchctlExitCode),
29 | "stderr": stderr,
30 | ]
31 | if let underlyingError {
32 | userInfo[NSUnderlyingErrorKey] = underlyingError
33 | }
34 |
35 | self.init(domain: Launchctl.errorDomain, code: Int(launchctlExitCode), userInfo: userInfo)
36 | }
37 |
38 | private static func xpc_strerr(_ code: Int32) -> String {
39 | switch code {
40 | case 0..<107:
41 | return String(cString: strerror(code))
42 | case 107:
43 | return "Malformed bundle"
44 | case 108:
45 | return "Invalid path"
46 | case 109:
47 | return "Invalid property list"
48 | case 110:
49 | return "Invalid or missing service identifier"
50 | case 111:
51 | return "Invalid or missing Program/ProgramArguments"
52 | case 112:
53 | return "Could not find specified domain"
54 | case 113:
55 | return "Could not find specified service"
56 | case 114:
57 | return "The specified username does not exist"
58 | case 115:
59 | return "The specified group does not exist"
60 | case 116:
61 | return "Routine not yet implemented"
62 | case 117:
63 | return "(n/a)"
64 | case 118:
65 | return "Bad response from server"
66 | case 119:
67 | return "Service is disabled"
68 | case 120:
69 | return "Bad subsystem destination for request"
70 | case 121:
71 | return "Path not searched for services"
72 | case 122:
73 | return "Path had bad ownership/permissions"
74 | case 123:
75 | return "Path is whitelisted for domain"
76 | case 124:
77 | return "Domain is tearing down"
78 | case 125:
79 | return "Domain does not support specified action"
80 | case 126:
81 | return "Request type is no longer supported"
82 | case 127:
83 | return "The specified service did not ship with the operating system"
84 | case 128:
85 | return "The specified path is not a bundle"
86 | case 129:
87 | return "The service was superseded by a later later version"
88 | case 130:
89 | return "The system encountered a condition where behavior was undefined"
90 | case 131:
91 | return "Out of order requests"
92 | case 132:
93 | return "Request for stale data"
94 | case 133:
95 | return "Multiple errors were returned; see stderr"
96 | case 134:
97 | return "Service cannot load in requested session"
98 | case 135:
99 | return "Process is not managed"
100 | case 136:
101 | return "Action not allowed on singleton service"
102 | case 137:
103 | return "Service does not support the specified action"
104 | case 138:
105 | return "Service cannot be loaded on this hardware"
106 | case 139:
107 | return "Service cannot presently execute"
108 | case 140:
109 | return "Service name is reserved or invalid"
110 | case 141:
111 | return "Reentrancy avoided"
112 | case 142:
113 | return "Operation only supported on development"
114 | case 143:
115 | return "Requested entry was cached"
116 | case 144:
117 | return "Requestor lacks required entitlement"
118 | case 145:
119 | return "Endpoint is hidden"
120 | case 146:
121 | return "Domain is in on-demand-only mode"
122 | case 147:
123 | return "The specified service did not ship in the requestor"
124 | case 148:
125 | return "The specified service path was not in the service cache"
126 | case 149:
127 | return "Could not find a bundle of the given identifier through LaunchServices"
128 | case 150:
129 | return "Operation not permitted while System Integrity Protection is engaged"
130 | case 151:
131 | return "A complete hack"
132 | case 152:
133 | return "Service cannot load in current boot environment"
134 | case 153:
135 | return "Completely unexpected error"
136 | case 154:
137 | return "Requestor is not a platform binary"
138 | case 155:
139 | return "Refusing to execute/trust quarantined program/file"
140 | case 156:
141 | return "Domain creation with that UID is not allowed anymore"
142 | case 157:
143 | return "System service is not in system service whitelist"
144 | case 158:
145 | return "Service cannot be loaded on current os variant"
146 | case 159:
147 | return "Unknown error"
148 | default:
149 | return "unknown error code"
150 | }
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/Sources/sLaunchctl/Internals/OutputParser.swift:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2022 Alkenso (Vladimir Vashurkin)
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | import Foundation
24 | import SpellbookFoundation
25 |
26 | internal struct OutputParser {
27 | let string: String
28 |
29 | func value(pattern: String, options: NSRegularExpression.Options = [], groupIdx: Int) throws -> String {
30 | let values = try values(pattern: pattern, options: options, groupIdx: groupIdx)
31 | guard let first = values.first else {
32 | throw CommonError.notFound(what: "pattern", value: pattern, where: self)
33 | }
34 | return first
35 | }
36 |
37 | func values(pattern: String, options: NSRegularExpression.Options = [], groupIdx: Int) throws -> [String] {
38 | let regex = try NSRegularExpression(pattern: pattern, options: options)
39 | let searchRange = NSRange(string.startIndex.. String {
54 | try value(pattern: "\(key) = (.*)", groupIdx: 1)
55 | }
56 |
57 | func stringArray(forKey key: String) throws -> [String] {
58 | let container = try container(forKey: key)
59 | let lines = try container
60 | .components(separatedBy: .newlines)
61 | .filter { !$0.isEmpty }
62 | .map { try OutputParser(string: $0).value(pattern: "\\s*(.*)", groupIdx: 1) }
63 | return lines
64 | }
65 |
66 | func stringDictionary(forKey key: String, separator: String = " => ") throws -> [String: String] {
67 | let lines = try stringArray(forKey: key)
68 | return try lines
69 | .map { try $0.parseKeyValuePair(separator: separator, allowSeparatorsInValue: true) }
70 | .reduce(into: [:]) { $0[$1.key] = $1.value }
71 | }
72 |
73 | func container(forKey key: String) throws -> String {
74 | try containers(key: key)[key].get(CommonError.notFound(what: "container", value: key, where: self))
75 | }
76 |
77 | func containers(key: String? = nil) throws -> [String: String] {
78 | let key = key ?? ".*?"
79 | let pattern = "^([ \t]*)\"?(\(key))\"? = \\{\n?([\\S\\s]*?)\n?^\\1\\}"
80 | let regex = try NSRegularExpression(pattern: pattern, options: .anchorsMatchLines)
81 | let searchRange = NSRange(string.startIndex.. String {
30 | try runLaunchctlFn(args)
31 | }
32 |
33 | internal var runLaunchctlFn = { (_ args: [String]) throws -> String in
34 | let (exitCode, stdout, stderr) = Process.launch(
35 | tool: URL(fileURLWithPath: "/bin/launchctl"),
36 | arguments: args
37 | )
38 | guard exitCode == 0 || exitCode == EINPROGRESS else {
39 | throw NSError(launchctlExitCode: exitCode, stderr: stderr)
40 | }
41 |
42 | return stdout
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/sLaunchctl/Launchctl.swift:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2022 Alkenso (Vladimir Vashurkin)
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | import Foundation
24 | import SpellbookFoundation
25 | import Spellbook_macOS
26 |
27 | extension Launchctl {
28 | /// Launchctl instance for system domain target.
29 | public static var system: Launchctl { .init(domainTarget: .system) }
30 |
31 | /// Launchctl instance for gui domain target.
32 | public static func gui(_ uid: uid_t) -> Launchctl { .init(domainTarget: .gui(uid)) }
33 |
34 | /// Launchctl instance for gui domain target of currently logged in user.
35 | public static func gui() -> Launchctl? { UnixUser.currentSession.flatMap { .gui($0.uid) } }
36 | }
37 |
38 | extension Launchctl {
39 | public static let errorDomain = "LaunchctlErrorDomain"
40 | }
41 |
42 | public struct Launchctl {
43 | public let domainTarget: DomainTarget
44 |
45 | public init(domainTarget: DomainTarget) {
46 | self.domainTarget = domainTarget
47 | }
48 |
49 | /// Loads the specified service.
50 | @discardableResult
51 | public func bootstrap(plist: URL) throws -> Service {
52 | try runLaunchctl(["bootstrap", domainTarget.description, plist.path])
53 | guard let name = NSDictionary(contentsOf: plist)?
54 | .object(forKey: LAUNCH_JOBKEY_LABEL) as? String
55 | else {
56 | throw NSError(launchctlExitCode: EINVAL, stderr: "Provided file does not contain service label.")
57 | }
58 | return Service(name: name, domainTarget: domainTarget)
59 | }
60 |
61 | /// Unloads the specified service.
62 | public func bootout(plist: URL) throws {
63 | try runLaunchctl(["bootout", domainTarget.description, plist.path])
64 | }
65 |
66 | /// Unloads the specified service.
67 | public func bootout(label: String) throws {
68 | let serviceTarget = Service(name: label, domainTarget: domainTarget).serviceTarget
69 | try runLaunchctl(["bootout", serviceTarget])
70 | }
71 |
72 | /// Lists all services loaded into launchd for the current domain target.
73 | public func list() throws -> [Service] {
74 | let output = try print()
75 | do {
76 | let names = try OutputParser(string: output).services()
77 | return names.map { Service(name: $0, domainTarget: domainTarget) }
78 | } catch {
79 | throw NSError(launchctlExitCode: ENOATTR, stderr: "No services dict found.", underlyingError: error)
80 | }
81 | }
82 |
83 | /// Prints the domain's metadata, including but not limited to all services in the domain.
84 | public func print() throws -> String {
85 | try runLaunchctl(["print", domainTarget.description])
86 | }
87 | }
88 |
89 | extension Launchctl {
90 | public enum DomainTarget {
91 | /// `system` domain target usually used by system-wide daemons, privileged helpers, system extensions.
92 | case system
93 |
94 | /// `gui` domain target usually used by per-user agents and login items.
95 | case gui(uid_t)
96 |
97 | // Rare use
98 | case pid(pid_t)
99 | case user(uid_t)
100 | case login(au_asid_t)
101 | case session(au_asid_t)
102 | }
103 | }
104 |
105 | extension Launchctl.DomainTarget: CustomStringConvertible {
106 | public var description: String {
107 | switch self {
108 | case .system:
109 | return "system"
110 | case .gui(let uid):
111 | return "gui/\(uid)"
112 | case .pid(let pid):
113 | return "pid/\(pid)"
114 | case .user(let uid):
115 | return "user/\(uid)"
116 | case .login(let asid):
117 | return "login/\(asid)"
118 | case .session(let asid):
119 | return "session/\(asid)"
120 | }
121 | }
122 | }
123 |
124 | extension OutputParser {
125 | internal func services() throws -> [String] {
126 | let lines = try stringArray(forKey: "services")
127 | return lines
128 | .map { $0.components(separatedBy: .whitespaces) }
129 | .compactMap(\.last)
130 | .filter { !$0.isEmpty }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/Sources/sLaunchctl/Service.swift:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2022 Alkenso (Vladimir Vashurkin)
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | import Foundation
24 | import SpellbookFoundation
25 |
26 | extension Launchctl {
27 | public struct Service {
28 | /// Service name
29 | public var name: String
30 |
31 | /// Service domain target.
32 | public var domainTarget: DomainTarget
33 |
34 | /// Service target (consists of domain and name).
35 | public var serviceTarget: String { "\(domainTarget)/\(name)" }
36 |
37 | public init(name: String, domainTarget: DomainTarget) {
38 | self.name = name
39 | self.domainTarget = domainTarget
40 | }
41 |
42 | /// Unloads the service.
43 | public func bootout() throws {
44 | try runLaunchctl(["bootout", serviceTarget])
45 | }
46 |
47 | /// Enables the service.
48 | public func enable() throws {
49 | try runLaunchctl(["enable", serviceTarget])
50 | }
51 |
52 | /// Disables the service.
53 | public func disable() throws {
54 | try runLaunchctl(["disable", serviceTarget])
55 | }
56 |
57 | /// Force a service to start.
58 | /// - Parameters:
59 | /// - kill: 'true' will kill existing instances before starting.
60 | public func kickstart(kill: Bool = false) throws {
61 | var args = ["kickstart"]
62 | if kill {
63 | args.append("-k")
64 | }
65 | args.append(serviceTarget)
66 | try runLaunchctl(args)
67 | }
68 |
69 | /// Sends a signal to a service's process.
70 | public func kill(signum: Int32) throws {
71 | try runLaunchctl(["kill", String(signum), serviceTarget])
72 | }
73 |
74 | /// Dumps the service's definition, properties & metadata in structured way.
75 | public func info() throws -> ServiceInfo {
76 | let output = try print()
77 | do {
78 | return try OutputParser(string: output).serviceInfo()
79 | } catch {
80 | throw NSError(
81 | launchctlExitCode: 109,
82 | stderr: "Unsupported description of \(self).",
83 | underlyingError: error
84 | )
85 | }
86 | }
87 |
88 | /// Dumps the service's definition, properties & metadata.
89 | public func print() throws -> String {
90 | try runLaunchctl(["print", serviceTarget])
91 | }
92 | }
93 | }
94 |
95 | extension Launchctl {
96 | public struct ServiceInfo: Equatable, Codable {
97 | public var pid: pid_t?
98 | public var daemon: DaemonInfo?
99 | public var loginItem: LoginItemInfo?
100 | public var endpoints: [String]?
101 | public var environment: Environment
102 | public var lastExitReason: ExitReason?
103 |
104 | public init(
105 | pid: pid_t?,
106 | daemon: DaemonInfo?,
107 | loginItem: LoginItemInfo?,
108 | endpoints: [String]?,
109 | environment: Environment,
110 | lastExitReason: ExitReason?
111 | ) {
112 | self.pid = pid
113 | self.daemon = daemon
114 | self.loginItem = loginItem
115 | self.endpoints = endpoints
116 | self.environment = environment
117 | self.lastExitReason = lastExitReason
118 | }
119 | }
120 |
121 | public enum ExitReason: Equatable, Codable {
122 | case signal(Int32)
123 | case exitCode(Int32)
124 | }
125 |
126 | public struct DaemonInfo: Equatable, Codable {
127 | public var plistPath: String
128 | public var program: String
129 | public var arguments: [String]?
130 | public var bundleID: String?
131 |
132 | public init(
133 | plistPath: String,
134 | program: String,
135 | arguments: [String]?,
136 | bundleID: String?
137 | ) {
138 | self.plistPath = plistPath
139 | self.program = program
140 | self.arguments = arguments
141 | self.bundleID = bundleID
142 | }
143 | }
144 |
145 | public struct LoginItemInfo: Equatable, Codable {
146 | public var identifier: String
147 | public var parentIdentifier: String
148 |
149 | public init(identifier: String, parentIdentifier: String) {
150 | self.identifier = identifier
151 | self.parentIdentifier = parentIdentifier
152 | }
153 | }
154 |
155 | public struct Environment: Equatable, Codable {
156 | public var generic: [String: String]?
157 | public var `default`: [String: String]?
158 | public var inherited: [String: String]?
159 |
160 | public init(
161 | generic: [String : String]? = nil,
162 | `default`: [String: String]? = nil,
163 | inherited: [String : String]? = nil
164 | ) {
165 | self.generic = generic
166 | self.default = `default`
167 | self.inherited = inherited
168 | }
169 | }
170 | }
171 |
172 | extension Launchctl.Service: CustomStringConvertible {
173 | public var description: String { serviceTarget }
174 | }
175 |
176 | extension OutputParser {
177 | internal func serviceInfo() throws -> Launchctl.ServiceInfo {
178 | let value = Launchctl.ServiceInfo(
179 | pid: (try? string(forKey: "pid")).flatMap(pid_t.init),
180 | daemon: daemonInfo(),
181 | loginItem: loginItemInfo(),
182 | endpoints: try? Array(OutputParser(string: container(forKey: "endpoints")).containers().keys),
183 | environment: environment(),
184 | lastExitReason: exitReason()
185 | )
186 |
187 | guard value.daemon != nil || value.loginItem != nil else {
188 | throw NSError(launchctlExitCode: 109, stderr: "Service information has unsupported format.")
189 | }
190 |
191 | return value
192 | }
193 |
194 | fileprivate func exitReason() -> Launchctl.ExitReason? {
195 | if let signal = (try? value(pattern: "last terminating signal = .*: (.*)", groupIdx: 1)).flatMap(Int32.init) {
196 | return .signal(signal)
197 | } else if let code = (try? string(forKey: "last exit code")).flatMap(Int32.init) {
198 | return .exitCode(code)
199 | } else {
200 | return nil
201 | }
202 | }
203 |
204 | fileprivate func daemonInfo() -> Launchctl.DaemonInfo? {
205 | do {
206 | return .init(
207 | plistPath: try string(forKey: "path"),
208 | program: try string(forKey: "program"),
209 | arguments: try? stringArray(forKey: "arguments"),
210 | bundleID: try? string(forKey: "bundle id")
211 | )
212 | } catch {
213 | return nil
214 | }
215 | }
216 |
217 | fileprivate func loginItemInfo() -> Launchctl.LoginItemInfo? {
218 | do {
219 | return .init(
220 | identifier: try value(pattern: "program identifier = (.*?)( |$)", groupIdx: 1),
221 | parentIdentifier: try string(forKey: "parent bundle identifier")
222 | )
223 | } catch {
224 | return nil
225 | }
226 | }
227 |
228 | fileprivate func environment() -> Launchctl.Environment {
229 | .init(
230 | generic: try? stringDictionary(forKey: "environment"),
231 | default: try? stringDictionary(forKey: "default environment"),
232 | inherited: try? stringDictionary(forKey: "inherited environment")
233 | )
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/Tests/sLaunchctlTests/LaunchctlParsingTests.swift:
--------------------------------------------------------------------------------
1 | @testable import sLaunchctl
2 |
3 | import XCTest
4 |
5 | class LaunchctlParsingTests: XCTestCase {
6 | func test_services() throws {
7 | let output = """
8 | system = {
9 | type = system
10 | handle = 0
11 | active count = 742
12 | service count = 380
13 | active service count = 146
14 | maximum allowed shutdown time = 65 s
15 | service stats = {
16 | com.apple.launchd.service-stats-default (4096 records)
17 | }
18 | creator = launchd[1]
19 | creator euid = 0
20 | auxiliary bootstrapper = com.apple.xpc.smd (complete)
21 | security context = {
22 | uid unset
23 | asid = 0
24 | }
25 |
26 | bringup time = 107 ms
27 | death port = 0xa03
28 | subdomains = {
29 | pid/39576
30 | user/0
31 | }
32 |
33 | services = {
34 | 0 - com.apple.lskdd
35 | 184 - com.apple.runningboardd
36 | 0 - com.apple.relatived
37 | }
38 |
39 | unmanaged processes = {
40 | com.apple.xpc.launchd.unmanaged.nsattributedstr.36189 = {
41 | active count = 3
42 | dynamic endpoints = {
43 | }
44 | pid-local endpoints = {
45 | "com.apple.tsm.portname" = {
46 | port = 0x1c91f
47 | active = 1
48 | managed = 0
49 | reset = 0
50 | hide = 0
51 | watching = 0
52 | }
53 | "com.apple.axserver" = {
54 | port = 0x6da4b
55 | active = 1
56 | managed = 0
57 | reset = 0
58 | hide = 0
59 | watching = 0
60 | }
61 | }
62 | }
63 | }
64 |
65 | endpoints = {
66 | 0 M D com.apple.fpsd.arcadeservice
67 | 0x1090b M A com.apple.VirtualDisplay
68 | 0xe03 M A com.apple.logd.admin
69 | }
70 |
71 | task-special ports = {
72 | 0x1c03 4 bootstrap com.apple.xpc.launchd.domain.system
73 | 0x2203 9 access com.apple.taskgated
74 | }
75 |
76 | disabled services = {
77 | "com.apple.CSCSupportd" => disabled
78 | "com.apple.ftpd" => disabled
79 | "com.apple.mdmclient.daemon.runatboot" => disabled
80 | }
81 |
82 | properties = uncorked | audit check done | bootcache hack
83 | }
84 | """
85 |
86 | let info = try OutputParser(string: output).services()
87 | XCTAssertEqual(Set(info), [
88 | "com.apple.lskdd",
89 | "com.apple.runningboardd",
90 | "com.apple.relatived",
91 | ])
92 | }
93 |
94 | func test_live() throws {
95 | try checkLiveTestingAllowed()
96 |
97 | let daemons = try Launchctl.system.list()
98 | XCTAssertFalse(daemons.isEmpty)
99 |
100 | let agents = try Launchctl.gui()?.list()
101 | XCTAssertFalse(agents?.isEmpty ?? true)
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/Tests/sLaunchctlTests/LiveTesting.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | extension XCTestCase {
4 | internal func checkLiveTestingAllowed() throws {
5 | try XCTSkipIf(true, "Live testing with real `launchctl` output is disabled by default")
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Tests/sLaunchctlTests/ServiceParsingTests.swift:
--------------------------------------------------------------------------------
1 | @testable import sLaunchctl
2 |
3 | import SpellbookFoundation
4 | import XCTest
5 |
6 | class ServiceParsingTests: XCTestCase {
7 | func test_daemon() throws {
8 | let output = """
9 | system/com.apple.akd = {
10 | active count = 7
11 | path = /System/Library/LaunchDaemons/com.apple.akd.plist
12 | type = LaunchDaemon
13 | state = running
14 |
15 | program = /System/Library/PrivateFrameworks/AuthKit.framework/Versions/A/Support/akd
16 | arguments = {
17 | /System/Library/PrivateFrameworks/AuthKit.framework/Versions/A/Support/akd
18 | }
19 |
20 | default environment = {
21 | PATH => /usr/bin:/bin:/usr/sbin:/sbin
22 | }
23 |
24 | environment = {
25 | MallocSpaceEfficient => 1
26 | XPC_SERVICE_NAME => com.apple.akd
27 | }
28 |
29 | domain = system
30 | minimum runtime = 10
31 | base minimum runtime = 10
32 | exit timeout = 5
33 | runs = 1
34 | pid = 928
35 | immediate reason = ipc (mach)
36 | forks = 0
37 | execs = 1
38 | initialized = 1
39 | trampolined = 1
40 | started suspended = 0
41 | proxy started suspended = 0
42 | last exit code = (never exited)
43 |
44 | endpoints = {
45 | "com.apple.ak.auth.xpc" = {
46 | port = 0x3a777
47 | active = 1
48 | managed = 1
49 | reset = 0
50 | hide = 0
51 | watching = 0
52 | }
53 | "com.apple.ak.anisette.xpc" = {
54 | port = 0x39003
55 | active = 1
56 | managed = 1
57 | reset = 0
58 | hide = 0
59 | watching = 0
60 | }
61 | }
62 |
63 | dynamic endpoints = {
64 | "com.apple.ak.aps" = {
65 | port = 0x30d33
66 | active = 1
67 | managed = 0
68 | reset = 0
69 | hide = 0
70 | watching = 0
71 | }
72 | }
73 |
74 | event channels = {
75 | "com.apple.rapport.matching" = {
76 | port = 0x2f103
77 | active = 1
78 | managed = 1
79 | reset = 0
80 | hide = 0
81 | watching = 0
82 | }
83 | "com.apple.notifyd.matching" = {
84 | port = 0x38d03
85 | active = 1
86 | managed = 1
87 | reset = 0
88 | hide = 0
89 | watching = 0
90 | }
91 | "com.apple.xpc.activity" = {
92 | port = 0x2f003
93 | active = 1
94 | managed = 1
95 | reset = 0
96 | hide = 0
97 | watching = 0
98 | }
99 | }
100 |
101 | spawn type = adaptive (6)
102 | jetsam priority = 40
103 | jetsam memory limit (active, soft) = 50 MB
104 | jetsam memory limit (inactive, soft) = 50 MB
105 | jetsamproperties category = daemon
106 | jetsam thread limit = 32
107 | cpumon = default
108 | job state = running
109 | probabilistic guard malloc policy = {
110 | activation rate = 1/1000
111 | sample rate = 1/0
112 | }
113 |
114 | properties = supports transactions | supports pressured exit | inferred program | system service | exponential throttling
115 | }
116 | """
117 |
118 | let info = try OutputParser(string: output).serviceInfo()
119 | let daemon = try info.daemon.get(name: "daemon")
120 | XCTAssertEqual(info.pid, 928)
121 | XCTAssertEqual(daemon, .init(
122 | plistPath: "/System/Library/LaunchDaemons/com.apple.akd.plist",
123 | program: "/System/Library/PrivateFrameworks/AuthKit.framework/Versions/A/Support/akd",
124 | arguments: ["/System/Library/PrivateFrameworks/AuthKit.framework/Versions/A/Support/akd"],
125 | bundleID: nil
126 | ))
127 | XCTAssertEqual(info.endpoints.flatMap(Set.init), [
128 | "com.apple.ak.auth.xpc",
129 | "com.apple.ak.anisette.xpc",
130 | ])
131 | XCTAssertEqual(info.environment, .init(
132 | generic: [
133 | "MallocSpaceEfficient": "1",
134 | "XPC_SERVICE_NAME": "com.apple.akd",
135 | ],
136 | default: ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin"],
137 | inherited: nil
138 | ))
139 | XCTAssertEqual(info.lastExitReason, nil)
140 | }
141 |
142 | func test_loginItem() throws {
143 | let output = """
144 | gui/501/com.vendor.helper = {
145 | active count = 0
146 | path = (submitted by smd.500)
147 | type = Submitted
148 | state = not running
149 |
150 | program identifier = com.vendor.helper (mode: 1)
151 | parent bundle identifier = com.vendor.vendor
152 | parent bundle version = 93002
153 | BTM uuid = 2149970E-6C68-4C98-8D3A-EBA52AC7B12F
154 | inherited environment = {
155 | SSH_AUTH_SOCK => /private/tmp/com.apple.launchd.jAKz5dz9eY/Listeners
156 | }
157 |
158 | default environment = {
159 | PATH => /usr/bin:/bin:/usr/sbin:/sbin
160 | }
161 |
162 | environment = {
163 | XPC_SERVICE_NAME => com.vendor.helper
164 | }
165 |
166 | domain = gui/501 [100015]
167 | asid = 100015
168 | minimum runtime = 10
169 | exit timeout = 5
170 | runs = 1
171 | last exit code = 0
172 |
173 | semaphores = {
174 | successful exit => 0
175 | }
176 |
177 | endpoints = {
178 | "com.vendor.helper" = {
179 | port = 0x9a403
180 | active = 0
181 | managed = 1
182 | reset = 0
183 | hide = 0
184 | watching = 1
185 | }
186 | }
187 |
188 | spawn type = interactive (4)
189 | jetsam priority = 40
190 | jetsam memory limit (active) = (unlimited)
191 | jetsam memory limit (inactive) = (unlimited)
192 | jetsamproperties category = daemon
193 | submitted job. ignore execute allowed
194 | jetsam thread limit = 32
195 | cpumon = default
196 | job state = exited
197 | probabilistic guard malloc policy = {
198 | activation rate = 1/1000
199 | sample rate = 1/0
200 | }
201 |
202 | properties = supports transactions | resolve program | has LWCR
203 | }
204 | """
205 |
206 | let info = try OutputParser(string: output).serviceInfo()
207 | let loginItem = try info.loginItem.get(name: "loginItem")
208 | XCTAssertEqual(info.pid, nil)
209 | XCTAssertEqual(loginItem, .init(
210 | identifier: "com.vendor.helper",
211 | parentIdentifier: "com.vendor.vendor"
212 | ))
213 | XCTAssertEqual(info.endpoints.flatMap(Set.init), [
214 | "com.vendor.helper",
215 | ])
216 | XCTAssertEqual(info.environment, .init(
217 | generic: ["XPC_SERVICE_NAME": "com.vendor.helper"],
218 | default: ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin"],
219 | inherited: ["SSH_AUTH_SOCK": "/private/tmp/com.apple.launchd.jAKz5dz9eY/Listeners"]
220 | ))
221 | XCTAssertEqual(info.lastExitReason, .exitCode(0))
222 | }
223 |
224 | func test_live() throws {
225 | try checkLiveTestingAllowed()
226 |
227 | let daemons = try Launchctl.system.list()
228 | try daemons.forEach { _ = try $0.info() }
229 |
230 | let agents = try Launchctl.gui().get(name: "current session agents").list()
231 | try agents.forEach { _ = try $0.info() }
232 | }
233 | }
234 |
--------------------------------------------------------------------------------