├── .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 | --------------------------------------------------------------------------------