├── .gitignore ├── Tests ├── LinuxMain.swift └── LaunchAgentTests │ ├── LaunchAgentTests.swift │ ├── StartCalendarIntervalTests.swift │ ├── ProgramTests.swift │ ├── FilePermissionsTests.swift │ ├── LaunchControlTests.swift │ └── LaunchAgentValiditytests.swift ├── .jazzy.yaml ├── .github └── workflows │ ├── swift.yml │ └── jazzy.yml ├── LaunchAgent.podspec ├── CHANGELOG.md ├── Sources └── LaunchAgent │ ├── Key Types │ ├── inetdCompatibility.swift │ ├── ProcessType.swift │ ├── MachService.swift │ ├── ResourceLimits.swift │ ├── StartCalendarInterval.swift │ └── File Permissions.swift │ ├── LaunchAgent+Status.swift │ ├── String Extensions.swift │ ├── LaunchControl.swift │ └── LaunchAgent.swift ├── LICENSE.md ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | *xcuserdatad* 5 | /.swiftpm 6 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import LaunchAgentTests 3 | 4 | XCTMain([ 5 | testCase(LaunchAgentTests.allTests), 6 | ]) 7 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | author_url: http://emorydunn.com 2 | author: Emory Dunn 3 | github_url: https://github.com/emorydunn/LaunchAgent 4 | exclude: 5 | - Sources/LaunchAgent/String Extensions.swift 6 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /.github/workflows/jazzy.yml: -------------------------------------------------------------------------------- 1 | name: PublishDocumentation 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | deploy_docs: 7 | runs-on: macOS-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Publish Jazzy Docs 11 | uses: steven0351/publish-jazzy-docs@v1 12 | with: 13 | personal_access_token: ${{ secrets.ACCESS_TOKEN }} 14 | config: .jazzy.yaml 15 | -------------------------------------------------------------------------------- /LaunchAgent.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = "LaunchAgent" 3 | spec.version = "0.3.0" 4 | spec.summary = "Programmatically create and maintain launchd agents and daemons without manually building Property Lists. " 5 | spec.homepage = "https://github.com/emorydunn/LaunchAgent" 6 | spec.license = { :type => "MIT", :file => "LICENSE.md" } 7 | spec.author = "Emory Dunn" 8 | spec.source = { :git => "https://github.com/emorydunn/LaunchAgent.git", :tag => "#{spec.version}" } 9 | spec.platform = :macos, '10.9' 10 | 11 | spec.source_files = "Sources/**/*.swift" 12 | 13 | spec.swift_version = '4.0' 14 | end 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # LaunchAgent 2 | 3 | ## 0.3.0 4 | 5 | ### New 6 | 7 | - Support for bootstrap & bootout introduced in macOS 10.10 8 | - Support for CocoaPods 9 | 10 | ## 0.2.3 11 | 12 | ### New 13 | 14 | - Fully [documented](https://emorydunn.github.io/LaunchAgent/)! 15 | 16 | ### Changed 17 | 18 | - Setting `program` and `programArguments` no longer changes the other's value. 19 | 20 | ## 0.2.2 21 | 22 | ### New 23 | 24 | - Add write method that defaults to agent label (#1) 25 | 26 | ## 0.2.1 27 | 28 | ### Updates 29 | - Set the agents URL when writing 30 | - Append `.plist` to URL if needed when writing 31 | 32 | ## 0.2.0 33 | 34 | ### Updates 35 | - Expand tests 36 | - Fix umask 37 | 38 | ### New Keys 39 | - sessionCreate 40 | - groupName 41 | - userName 42 | - initGroups 43 | - rootDirectory 44 | 45 | 46 | ## 0.1.0 47 | 48 | - Initial release 49 | 50 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/Key Types/inetdCompatibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // inetdCompatibility.swift 3 | // LaunchAgent 4 | // 5 | // Created by Emory Dunn on 2018-02-20. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The presence of this key specifies that the daemon expects to be run as 11 | /// if it were launched from inetd. 12 | /// 13 | /// - Important: For new projects, this key should be avoided. 14 | public class inetdCompatibility: Codable { 15 | 16 | /// This flag corresponds to the "wait" or "nowait" option of inetd. 17 | /// 18 | /// If true, then the listening socket is passed via the `stdio(3)` file 19 | /// descriptors. If false, then `accept(2)` is called on behalf of the 20 | /// job, and the result is passed via the `stdio(3)` descriptors. 21 | public var wait: Bool 22 | 23 | /// Instantiate a new object 24 | public init(wait: Bool) { 25 | self.wait = wait 26 | } 27 | 28 | /// launchd.plist keys 29 | public enum CodingKeys: String, CodingKey { 30 | /// Wait 31 | case wait = "Wait" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Emory Dunn 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. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 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: "LaunchAgent", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "LaunchAgent", 12 | targets: ["LaunchAgent"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 21 | .target( 22 | name: "LaunchAgent", 23 | dependencies: []), 24 | .testTarget( 25 | name: "LaunchAgentTests", 26 | dependencies: ["LaunchAgent"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/Key Types/ProcessType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProcessType.swift 3 | // LaunchAgent 4 | // 5 | // Created by Emory Dunn on 2/19/18. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This optional key describes, at a high level, the intended purpose of the 11 | /// job. 12 | /// 13 | /// The system will apply resource limits based on what kind of job it 14 | /// is. If left unspecified, the system will apply light resource limits to 15 | /// the job, throttling its CPU usage and I/O bandwidth. This classification 16 | /// is preferable to using the HardResourceLimits, SoftResourceLimits and 17 | /// Nice keys. 18 | public enum ProcessType: String, Codable { 19 | 20 | /// Standard jobs are equivalent to no ProcessType being set. 21 | case standard = "Standard" 22 | 23 | /// Background jobs are generally processes that do work that was not 24 | /// directly requested by the user. 25 | /// 26 | /// The resource limits applied to 27 | /// Background jobs are intended to prevent them from disrupting the 28 | /// user experience. 29 | case background = "Background" 30 | 31 | /// Adaptive jobs move between the Background and Interactive 32 | /// classifications based on activity over XPC connections. 33 | /// 34 | /// See `xpc_transaction_begin(3)` for details. 35 | case adaptive = "Adaptive" 36 | 37 | /// Interactive jobs run with the same resource limitations as apps, 38 | /// that is to say, none. 39 | /// 40 | /// Interactive jobs are critical to maintaining 41 | /// a responsive user experience, and this key should only be used if 42 | /// an app's ability to be responsive depends on it, and cannot be made 43 | /// Adaptive. 44 | case interactive = "Interactive" 45 | } 46 | -------------------------------------------------------------------------------- /Tests/LaunchAgentTests/LaunchAgentTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import LaunchAgent 3 | 4 | class LaunchAgentTests: XCTestCase { 5 | 6 | func testNice() { 7 | let agent = LaunchAgent(label: "NiceTest") 8 | 9 | // Test minimum value 10 | agent.nice = -20 11 | XCTAssertEqual(agent.nice, -20) 12 | 13 | // Test maximum value 14 | agent.nice = 20 15 | XCTAssertEqual(agent.nice, 20) 16 | 17 | // Test less than minimum value 18 | agent.nice = -21 19 | XCTAssertEqual(agent.nice, -20) 20 | 21 | // Test greater than maximum value 22 | agent.nice = 21 23 | XCTAssertEqual(agent.nice, 20) 24 | } 25 | 26 | } 27 | 28 | 29 | class LaunchAgentControlTests: XCTestCase { 30 | let agent = LaunchAgent(label: "TestAgent.PythonServer", program: "python", "-m", "SimpleHTTPServer", "8000") 31 | 32 | override func setUp() { 33 | try! LaunchControl.shared.write(agent, called: "TestAgent.plist") 34 | } 35 | 36 | override func tearDown() { 37 | agent.stop() 38 | try! agent.unload() 39 | sleep(1) 40 | try! FileManager.default.removeItem(at: agent.url!) 41 | } 42 | 43 | func testLoad() { 44 | XCTAssertNoThrow(try agent.load()) 45 | sleep(1) 46 | XCTAssertEqual(agent.status(), AgentStatus.loaded) 47 | } 48 | 49 | func testUnload() { 50 | XCTAssertNoThrow(try agent.load()) 51 | sleep(1) 52 | XCTAssertNoThrow(try agent.unload()) 53 | sleep(1) 54 | 55 | XCTAssertEqual(agent.status(), AgentStatus.unloaded) 56 | } 57 | 58 | func testStartStop() { 59 | XCTAssertNoThrow(try agent.load()) 60 | sleep(1) 61 | XCTAssertNoThrow(agent.start()) 62 | sleep(1) 63 | 64 | switch agent.status() { 65 | case .running(_): 66 | XCTAssert(true) 67 | default: 68 | XCTAssert(false) 69 | } 70 | 71 | agent.stop() 72 | sleep(1) 73 | 74 | XCTAssertEqual(agent.status(), AgentStatus.loaded) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Tests/LaunchAgentTests/StartCalendarIntervalTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StartCalendarIntervalTests.swift 3 | // LaunchAgentTests 4 | // 5 | // Created by Emory Dunn on 2018-02-19. 6 | // 7 | 8 | import XCTest 9 | import LaunchAgent 10 | 11 | class StartCalendarIntervalTests: XCTestCase { 12 | 13 | func testInit() { 14 | let interval = StartCalendarInterval() 15 | 16 | XCTAssertNil(interval.day) 17 | XCTAssertNil(interval.hour) 18 | XCTAssertNil(interval.minute) 19 | XCTAssertNil(interval.month) 20 | XCTAssertNil(interval.weekday) 21 | } 22 | 23 | func testDayRange() { 24 | let interval = StartCalendarInterval() 25 | 26 | // Test minimum value 27 | interval.day = 1 28 | XCTAssertEqual(interval.day, 1) 29 | 30 | // Test maximum value 31 | interval.day = 31 32 | XCTAssertEqual(interval.day, 31) 33 | 34 | // Test less than minimum value 35 | interval.day = 0 36 | XCTAssertEqual(interval.day, 1) 37 | 38 | // Test greater than maximum value 39 | interval.day = 32 40 | XCTAssertEqual(interval.day, 31) 41 | } 42 | 43 | func testHourRange() { 44 | let interval = StartCalendarInterval() 45 | 46 | // Test minimum value 47 | interval.hour = 0 48 | XCTAssertEqual(interval.hour, 0) 49 | 50 | // Test maximum value 51 | interval.hour = 23 52 | XCTAssertEqual(interval.hour, 23) 53 | 54 | // Test less than minimum value 55 | interval.hour = -1 56 | XCTAssertEqual(interval.hour, 0) 57 | 58 | // Test greater than maximum value 59 | interval.hour = 24 60 | XCTAssertEqual(interval.hour, 23) 61 | } 62 | 63 | func testMinuteRange() { 64 | let interval = StartCalendarInterval() 65 | 66 | // Test minimum value 67 | interval.minute = 0 68 | XCTAssertEqual(interval.minute, 0) 69 | 70 | // Test maximum value 71 | interval.minute = 59 72 | XCTAssertEqual(interval.minute, 59) 73 | 74 | // Test less than minimum value 75 | interval.minute = -1 76 | XCTAssertEqual(interval.minute, 0) 77 | 78 | // Test greater than maximum value 79 | interval.minute = 60 80 | XCTAssertEqual(interval.minute, 59) 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/LaunchAgent+Status.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchAgent+Status.swift 3 | // LaunchAgent 4 | // 5 | // Created by Emory Dunn on 2018-02-19. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The status of a job given by `launchctl list` 11 | public enum AgentStatus: Equatable { 12 | 13 | /// Indicates the job is running, with the given PID/ 14 | case running(pid: Int) 15 | 16 | /// Indicates the job is loaded, but not running. 17 | case loaded 18 | 19 | /// Indicates the job is unloaded. 20 | case unloaded 21 | 22 | public static func ==(lhs: AgentStatus, rhs: AgentStatus) -> Bool { 23 | switch (lhs, rhs) { 24 | case ( let .running(lhpid), let .running(rhpid) ): 25 | return lhpid == rhpid 26 | case (.loaded, .loaded): 27 | return true 28 | case (.unloaded, .unloaded): 29 | return true 30 | default: 31 | return false 32 | } 33 | } 34 | 35 | } 36 | 37 | extension LaunchAgent { 38 | 39 | // MARK: LaunchControl 40 | 41 | /// Run `launchctl start` on the agent 42 | /// 43 | /// Check the status of the job with `.status()` 44 | public func start() { 45 | LaunchControl.shared.start(self) 46 | } 47 | 48 | /// Run `launchctl stop` on the agent 49 | /// 50 | /// Check the status of the job with `.status()` 51 | public func stop() { 52 | LaunchControl.shared.stop(self) 53 | } 54 | 55 | /// Run `launchctl load` on the agent 56 | /// 57 | /// Check the status of the job with `.status()` 58 | @available(macOS, deprecated: 10.11) 59 | public func load() throws { 60 | try LaunchControl.shared.load(self) 61 | } 62 | 63 | /// Run `launchctl unload` on the agent 64 | /// 65 | /// Check the status of the job with `.status()` 66 | @available(macOS, deprecated: 10.11) 67 | public func unload() throws { 68 | try LaunchControl.shared.unload(self) 69 | } 70 | 71 | /// Run `launchctl bootstrap` on the agent 72 | /// 73 | /// Check the status of the job with `.status()` 74 | @available(macOS, introduced: 10.11) 75 | public func bootstrap() throws { 76 | try LaunchControl.shared.bootstrap(self) 77 | } 78 | 79 | /// Run `launchctl bootout` on the agent 80 | /// 81 | /// Check the status of the job with `.status()` 82 | @available(macOS, introduced: 10.11) 83 | public func bootout() throws { 84 | try LaunchControl.shared.bootout(self) 85 | } 86 | 87 | /// Retreives the status of the LaunchAgent from `launchctl` 88 | /// 89 | /// - Returns: the agent's status 90 | public func status() -> AgentStatus { 91 | return LaunchControl.shared.status(self) 92 | } 93 | 94 | } 95 | 96 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/Key Types/MachService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MachService.swift 3 | // LaunchAgent 4 | // 5 | // Created by Emory Dunn on 2018-02-20. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This optional key is used to specify Mach services to be registered with 11 | /// the Mach bootstrap namespace. 12 | /// 13 | /// Each key in this dictionary should be the 14 | /// name of a service to be advertised. The value of the key must be a 15 | /// boolean and set to true or a dictionary in order for the service to be 16 | /// advertised. 17 | public class MachService: Codable { 18 | 19 | /// Reserve the name in the namespace, but cause `bootstrap_look_up()` to 20 | /// fail until the job has checked in with launchd. 21 | /// 22 | /// This option is incompatible with` xpc(3)`, which relies on the con- 23 | /// stant availability of services. This option also encourages polling 24 | /// for service availability and is therefore generally discouraged. 25 | /// Future implementations will penalize use of this option in subtle 26 | /// and creative ways. 27 | /// 28 | /// Jobs can dequeue messages from the MachServices they advertised 29 | /// with `xpc_connection_create_mach_service(3)` or `bootstrap_check_in()` 30 | /// API (to obtain the underlying port's receive right) and the Mach 31 | /// APIs to dequeue messages from that port. 32 | public var hideUntilCheckIn: Bool 33 | 34 | /// The default value for this key is false, and so the port is recy- 35 | /// cled, thus leaving clients to remain oblivious to the demand nature 36 | /// of the job. 37 | /// 38 | /// If the value is set to true, clients receive port death 39 | /// notifications when the job lets go of the receive right. The port 40 | /// will be recreated atomically with respect to `bootstrap_look_up()` 41 | /// calls, so that clients can trust that after receiving a port-death 42 | /// notification, the new port will have already been recreated. Set- 43 | /// ting the value to true should be done with care. Not all clients 44 | /// may be able to handle this behavior. The default value is false. 45 | 46 | /// - Note: This option is not compatible with `xpc(3)`, which automat- 47 | /// ically handles notifying clients of interrupted connections and 48 | /// server death. 49 | public var resetAtClose: Bool 50 | 51 | /// Instantiate a new object 52 | public init(hideUntilCheckIn: Bool, resetAtClose: Bool ) { 53 | self.hideUntilCheckIn = hideUntilCheckIn 54 | self.resetAtClose = resetAtClose 55 | } 56 | 57 | /// launchd.plist keys 58 | public enum CodingKeys: String, CodingKey { 59 | /// HideUntilCheckIn 60 | case hideUntilCheckIn = "HideUntilCheckIn" 61 | /// ResetAtClose 62 | case resetAtClose = "ResetAtClose" 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Tests/LaunchAgentTests/ProgramTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgramTests.swift 3 | // LaunchAgentTests 4 | // 5 | // Created by Emory Dunn on 2018-02-19. 6 | // 7 | 8 | import XCTest 9 | import LaunchAgent 10 | 11 | class ProgramTests: XCTestCase { 12 | 13 | // MARK: Test Init 14 | 15 | func test_Init_StringArray() { 16 | let testProgram = LaunchAgent(label: "Launch Agent Test", program: ["Program", "--arg"]) 17 | 18 | XCTAssertNil(testProgram.program) 19 | XCTAssertNotNil(testProgram.programArguments) 20 | XCTAssertEqual(testProgram.programArguments!, ["Program", "--arg"]) 21 | } 22 | 23 | func test_Init_SingleStringArray() { 24 | let testProgram = LaunchAgent(label: "Launch Agent Test", program: ["SingleString"]) 25 | 26 | XCTAssertEqual(testProgram.program, "SingleString") 27 | XCTAssertNil(testProgram.programArguments) 28 | } 29 | 30 | func test_Init_SingleString() { 31 | let testProgram = LaunchAgent(label: "Launch Agent Test", program: "SingleString") 32 | 33 | XCTAssertEqual(testProgram.program, "SingleString") 34 | XCTAssertNil(testProgram.programArguments) 35 | } 36 | 37 | func test_Init_VariadicString() { 38 | let testProgram = LaunchAgent(label: "Launch Agent Test", program: "Program", "--arg") 39 | 40 | XCTAssertNil(testProgram.program) 41 | XCTAssertNotNil(testProgram.programArguments) 42 | XCTAssertEqual(testProgram.programArguments!, ["Program", "--arg"]) 43 | } 44 | 45 | // Mark: Test Description 46 | 47 | // func test_Description_Program() { 48 | // let testProgram = LaunchAgent(label: "Launch Agent Test", program: "SingleString") 49 | // 50 | // XCTAssertEqual(testProgram.description, "SingleString") 51 | // } 52 | // 53 | // func test_Description_ProgramArgumanets() { 54 | // let testProgram = LaunchAgent(label: "Launch Agent Test", program: "Program", "--arg") 55 | // 56 | // XCTAssertEqual(testProgram.description, "Program --arg") 57 | // } 58 | // 59 | // func test_Description_NilProgram() { 60 | // let testProgram = LaunchAgent(label: "Launch Agent Test", program: "") 61 | // 62 | // testProgram.program = nil 63 | // testProgram.programArguments = nil 64 | // 65 | // XCTAssertEqual(testProgram.description, "") 66 | // 67 | // } 68 | 69 | // MARK: Test Program didSet 70 | 71 | func test_SetSingleProgram() { 72 | let testProgram = LaunchAgent(label: "Launch Agent Test", program: ["Many", "Args"]) 73 | 74 | XCTAssertNil(testProgram.program) 75 | XCTAssertNotNil(testProgram.programArguments) 76 | 77 | testProgram.program = "SingleProgram" 78 | 79 | XCTAssertEqual(testProgram.program, "SingleProgram") 80 | 81 | } 82 | 83 | 84 | func test_SetProgramArguments_Array() { 85 | let testProgram = LaunchAgent(label: "Launch Agent Test", program: "SingleProgram") 86 | 87 | XCTAssertNotNil(testProgram.program) 88 | XCTAssertNil(testProgram.programArguments) 89 | 90 | testProgram.programArguments = ["NewProgram", "--arg"] 91 | 92 | XCTAssertEqual(testProgram.program, "SingleProgram") 93 | XCTAssertNotNil(testProgram.programArguments) 94 | XCTAssertEqual(testProgram.programArguments!, ["NewProgram", "--arg"]) 95 | 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/String Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Subscript.swift 3 | // LaunchAgent 4 | // 5 | // Created by Emory Dunn on 2018-04-06. 6 | // 7 | 8 | import Foundation 9 | 10 | /// From https://stackoverflow.com/a/24144365 11 | extension String { 12 | /// :nodoc: 13 | subscript (i: Int) -> Character { 14 | return self[index(startIndex, offsetBy: i)] 15 | } 16 | /// :nodoc: 17 | subscript (bounds: CountableRange) -> Substring { 18 | let start = index(startIndex, offsetBy: bounds.lowerBound) 19 | let end = index(startIndex, offsetBy: bounds.upperBound) 20 | return self[start ..< end] 21 | } 22 | /// :nodoc: 23 | subscript (bounds: CountableClosedRange) -> Substring { 24 | let start = index(startIndex, offsetBy: bounds.lowerBound) 25 | let end = index(startIndex, offsetBy: bounds.upperBound) 26 | return self[start ... end] 27 | } 28 | /// :nodoc: 29 | subscript (bounds: CountablePartialRangeFrom) -> Substring { 30 | let start = index(startIndex, offsetBy: bounds.lowerBound) 31 | let end = index(endIndex, offsetBy: -1) 32 | return self[start ... end] 33 | } 34 | /// :nodoc: 35 | subscript (bounds: PartialRangeThrough) -> Substring { 36 | let end = index(startIndex, offsetBy: bounds.upperBound) 37 | return self[startIndex ... end] 38 | } 39 | /// :nodoc: 40 | subscript(bounds: PartialRangeUpTo) -> Substring { 41 | let end = index(startIndex, offsetBy: bounds.upperBound) 42 | return self[startIndex ..< end] 43 | } 44 | } 45 | 46 | /// From https://stackoverflow.com/a/24144365 47 | /// :nodoc: 48 | extension Substring { 49 | /// :nodoc: 50 | subscript (i: Int) -> Character { 51 | return self[index(startIndex, offsetBy: i)] 52 | } 53 | /// :nodoc: 54 | subscript (bounds: CountableRange) -> Substring { 55 | let start = index(startIndex, offsetBy: bounds.lowerBound) 56 | let end = index(startIndex, offsetBy: bounds.upperBound) 57 | return self[start ..< end] 58 | } 59 | /// :nodoc: 60 | subscript (bounds: CountableClosedRange) -> Substring { 61 | let start = index(startIndex, offsetBy: bounds.lowerBound) 62 | let end = index(startIndex, offsetBy: bounds.upperBound) 63 | return self[start ... end] 64 | } 65 | /// :nodoc: 66 | subscript (bounds: CountablePartialRangeFrom) -> Substring { 67 | let start = index(startIndex, offsetBy: bounds.lowerBound) 68 | let end = index(endIndex, offsetBy: -1) 69 | return self[start ... end] 70 | } 71 | /// :nodoc: 72 | subscript (bounds: PartialRangeThrough) -> Substring { 73 | let end = index(startIndex, offsetBy: bounds.upperBound) 74 | return self[startIndex ... end] 75 | } 76 | /// :nodoc: 77 | subscript (bounds: PartialRangeUpTo) -> Substring { 78 | let end = index(startIndex, offsetBy: bounds.upperBound) 79 | return self[startIndex ..< end] 80 | } 81 | } 82 | 83 | /// From https://stackoverflow.com/a/39215372 84 | /// :nodoc: 85 | extension String { 86 | /// :nodoc: 87 | func leftPadding(toLength: Int, withPad character: Character) -> String { 88 | let stringLength = self.count 89 | if stringLength < toLength { 90 | return String(repeatElement(character, count: toLength - stringLength)) + self 91 | } else { 92 | return String(self.suffix(toLength)) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/Key Types/ResourceLimits.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SoftResourceLimits.swift 3 | // LaunchAgent 4 | // 5 | // Created by Emory Dunn on 2018-02-20. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Resource limits to be imposed on the job. These adjust variables set with setrlimit(2). 11 | public class ResourceLimits: Codable { 12 | 13 | /// The maximum amount of cpu time (in seconds) to be used by each process. 14 | public var cpu: Int? 15 | 16 | /// The largest size (in bytes) core file that may be created. 17 | public var core: Int? 18 | 19 | /// The maximum size (in bytes) of the data segment for a process; this 20 | /// defines how far a program may extend its break with the sbrk(2) 21 | /// system call. 22 | public var data: Int? 23 | 24 | /// The largest size (in bytes) file that may be created. 25 | public var fileSize: Int? 26 | 27 | /// The maximum size (in bytes) which a process may lock into memory 28 | /// using the mlock(2) function. 29 | public var memoryLock: Int? 30 | 31 | /// The maximum number of open files for this process. 32 | /// 33 | /// Setting this value in a system wide daemon will set the sysctl(3) kern.maxfiles 34 | /// (SoftResourceLimits) or kern.maxfilesperproc (HardResourceLimits) 35 | /// value in addition to the setrlimit(2) values. 36 | public var numberOfFiles: Int? 37 | 38 | /// The maximum number of simultaneous processes for this UID. 39 | /// 40 | /// Setting this value in a system wide daemon will set the sysctl(3) kern.max- 41 | /// proc (SoftResourceLimits) or kern.maxprocperuid (HardResourceLimits) 42 | /// value in addition to the setrlimit(2) values. 43 | public var numberOfProcesses: Int? 44 | 45 | /// The maximum size (in bytes) to which a process's resident set size 46 | /// may grow. 47 | /// 48 | /// This imposes a limit on the amount of physical memory to 49 | /// be given to a process; if memory is tight, the system will prefer 50 | /// to take memory from processes that are exceeding their declared 51 | /// resident set size. 52 | public var residentSetSize: Int? 53 | 54 | /// The maximum size (in bytes) of the stack segment for a process; 55 | /// this defines how far a program's stack segment may be extended. 56 | /// 57 | /// Stack extension is performed automatically by the system. 58 | public var stack: Int? 59 | 60 | /// Instantiate a new object. 61 | public init(cpu: Int? = nil, core: Int? = nil, data: Int? = nil, fileSize: Int? = nil, memoryLock: Int? = nil, numberOfFiles: Int? = nil, numberOfProcesses: Int? = nil, residentSetSize: Int? = nil, stack: Int? = nil) { 62 | self.cpu = cpu 63 | self.core = core 64 | self.data = data 65 | self.fileSize = fileSize 66 | self.memoryLock = memoryLock 67 | self.numberOfFiles = numberOfFiles 68 | self.numberOfProcesses = numberOfProcesses 69 | self.residentSetSize = residentSetSize 70 | self.stack = stack 71 | } 72 | 73 | /// launchd.plist keys 74 | public enum CodingKeys: String, CodingKey { 75 | /// CPU 76 | case cpu = "CPU" 77 | /// Core 78 | case core = "Core" 79 | /// Data 80 | case data = "Data" 81 | /// FileSize 82 | case fileSize = "FileSize" 83 | /// MemoryLock 84 | case memoryLock = "MemoryLock" 85 | /// NumberOfFiles 86 | case numberOfFiles = "NumberOfFiles" 87 | /// NumberOfProcesses 88 | case numberOfProcesses = "NumberOfProcesses" 89 | /// ResidentSetSize 90 | case residentSetSize = "ResidentSetSize" 91 | /// Stack 92 | case stack = "Stack" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Tests/LaunchAgentTests/FilePermissionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilePermissionsTests.swift 3 | // LaunchAgentTests 4 | // 5 | // Created by Emory Dunn on 2018-04-06. 6 | // 7 | 8 | import XCTest 9 | @testable import LaunchAgent 10 | 11 | 12 | class PermissionBitTests: XCTestCase { 13 | 14 | func test_init() { 15 | XCTAssertEqual(PermissionBits.init(rawValue: 5), [.read, .execute]) 16 | } 17 | 18 | func test_mac_init() { 19 | XCTAssertEqual(PermissionBits.init(umaskValue: 2), [.read, .execute]) 20 | } 21 | 22 | func test_mac_octal() { 23 | let permissions: PermissionBits = [.read, .write, .execute] 24 | XCTAssertEqual(permissions.umaskValue, 0) 25 | } 26 | 27 | func test_description() { 28 | let read: PermissionBits = [PermissionBits.read] 29 | let readWrite: PermissionBits = [PermissionBits.read, .write] 30 | let readWriteExecute: PermissionBits = [PermissionBits.read, .write, .execute] 31 | let readExecute: PermissionBits = [PermissionBits.read, .execute] 32 | let writeExecute: PermissionBits = [PermissionBits.write, .execute] 33 | 34 | XCTAssertEqual(read.description, "r--") 35 | XCTAssertEqual(readWrite.description, "rw-") 36 | XCTAssertEqual(readWriteExecute.description, "rwx") 37 | XCTAssertEqual(readExecute.description, "r-x") 38 | XCTAssertEqual(writeExecute.description, "-wx") 39 | 40 | } 41 | 42 | } 43 | 44 | class FilePermissionsTests: XCTestCase { 45 | 46 | func test_init() { 47 | let file = FilePermissions(user: .read, group: .write, other: .execute) 48 | 49 | XCTAssertEqual(file.user, .read) 50 | XCTAssertEqual(file.group, .write) 51 | XCTAssertEqual(file.other, .execute) 52 | } 53 | 54 | func test_octal_init() { 55 | let file = FilePermissions(0o750) 56 | 57 | XCTAssertEqual(file.user, [.read, .write, .execute]) 58 | XCTAssertEqual(file.group, [.read, .execute]) 59 | XCTAssertEqual(file.other, []) 60 | } 61 | 62 | func test_decimal_init() { 63 | let file = FilePermissions(488) 64 | 65 | XCTAssertEqual(file.user, [.read, .write, .execute]) 66 | XCTAssertEqual(file.group, [.read, .execute]) 67 | XCTAssertEqual(file.other, []) 68 | } 69 | 70 | func test_octal() { 71 | let file = FilePermissions(user: [], group: .read, other: .write) 72 | XCTAssertEqual(file.octal, "042") 73 | } 74 | 75 | func test_decimal() { 76 | let file = FilePermissions(0o750) 77 | XCTAssertEqual(file.decimal, 488) 78 | } 79 | 80 | func test_symbolic() { 81 | let file = FilePermissions(0o751) 82 | XCTAssertEqual(file.symbolic, "-rwxr-x--x") 83 | } 84 | 85 | func test_description() { 86 | let file = FilePermissions(0o750) 87 | XCTAssertEqual(file.description, "Permissions u+rwx, g+r-x, o+---") 88 | } 89 | 90 | } 91 | 92 | 93 | class MacFilePermissionsTests: XCTestCase { 94 | func test_octal_init() { 95 | let file = FilePermissions(umask: 0o027) 96 | 97 | XCTAssertEqual(file.user, [.read, .write, .execute]) 98 | XCTAssertEqual(file.group, [.read, .execute]) 99 | XCTAssertEqual(file.other, []) 100 | } 101 | 102 | func test_octal() { 103 | let file = FilePermissions(user: [.read, .write, .execute], group: [.write, .execute], other: [.read, .execute]) 104 | XCTAssertEqual(file.umaskOctal, "042") 105 | } 106 | 107 | func test_decimal() { 108 | let file = FilePermissions(0o750) 109 | XCTAssertEqual(file.umaskDecimal, 23) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/LaunchAgentTests/LaunchControlTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchControlTests.swift 3 | // LaunchAgentTests 4 | // 5 | // Created by Emory Dunn on 2018-02-19. 6 | // 7 | 8 | import XCTest 9 | @testable import LaunchAgent 10 | 11 | class LaunchControlTests: XCTestCase { 12 | 13 | func testWrite_withoutFilename() { 14 | let agent = LaunchAgent(label: "TestAgent") 15 | 16 | XCTAssertNoThrow(try LaunchControl.shared.write(agent)) 17 | XCTAssertTrue(FileManager.default.fileExists(atPath: agent.url!.path)) 18 | } 19 | 20 | func testWrite_withPlist() { 21 | let agent = LaunchAgent(label: "TestAgent") 22 | 23 | XCTAssertNoThrow(try LaunchControl.shared.write(agent, called: "TestAgent.plist")) 24 | 25 | } 26 | 27 | func testWrite_withoutPlist() { 28 | let agent = LaunchAgent(label: "TestAgent") 29 | 30 | XCTAssertNoThrow(try LaunchControl.shared.write(agent, called: "TestAgent")) 31 | XCTAssertEqual(agent.url?.lastPathComponent, "TestAgent.plist") 32 | 33 | } 34 | 35 | func testRead() { 36 | XCTAssertNoThrow(try LaunchControl.shared.read(agent: "TestAgent.plist")) 37 | } 38 | 39 | func testSetURL() { 40 | let agent = LaunchAgent(label: "TestAgent") 41 | 42 | XCTAssertNoThrow(try LaunchControl.shared.write(agent, called: "TestAgent.plist")) 43 | agent.url = nil 44 | 45 | XCTAssertNil(agent.url) 46 | 47 | XCTAssertNoThrow(try LaunchControl.shared.setURL(for: agent)) 48 | 49 | XCTAssertNotNil(agent.url) 50 | 51 | } 52 | 53 | let agent = LaunchAgent(label: "TestAgent.PythonServer", program: "python", "-m", "SimpleHTTPServer", "8000") 54 | 55 | override func setUp() { 56 | try! LaunchControl.shared.write(agent, called: "TestAgent.plist") 57 | } 58 | 59 | override func tearDown() { 60 | LaunchControl.shared.stop(agent) 61 | sleep(1) 62 | XCTAssertNoThrow(try LaunchControl.shared.unload(agent)) 63 | sleep(1) 64 | try! FileManager.default.removeItem(at: agent.url!) 65 | } 66 | 67 | func testLoad() { 68 | XCTAssertNoThrow(try LaunchControl.shared.load(agent)) 69 | sleep(1) 70 | 71 | XCTAssertEqual(agent.status(), AgentStatus.loaded) 72 | } 73 | 74 | func testLoadError() { 75 | let testAgent = LaunchAgent(label: "TestAgent") 76 | XCTAssertThrowsError(try LaunchControl.shared.load(testAgent)) 77 | } 78 | 79 | func testUnload() { 80 | XCTAssertNoThrow(try LaunchControl.shared.load(agent)) 81 | sleep(1) 82 | XCTAssertNoThrow(try LaunchControl.shared.unload(agent)) 83 | sleep(1) 84 | 85 | XCTAssertEqual(agent.status(), AgentStatus.unloaded) 86 | } 87 | 88 | func testUnloadError() { 89 | let testAgent = LaunchAgent(label: "TestAgent") 90 | XCTAssertThrowsError(try LaunchControl.shared.unload(testAgent)) 91 | } 92 | 93 | func testBootstrap() { 94 | XCTAssertNoThrow(try LaunchControl.shared.bootstrap(agent)) 95 | sleep(1) 96 | 97 | XCTAssertEqual(agent.status(), AgentStatus.loaded) 98 | } 99 | 100 | func testBootstrapError() { 101 | let testAgent = LaunchAgent(label: "TestAgent") 102 | XCTAssertThrowsError(try LaunchControl.shared.bootstrap(testAgent)) 103 | } 104 | 105 | func testBootout() { 106 | XCTAssertNoThrow(try LaunchControl.shared.load(agent)) 107 | sleep(1) 108 | XCTAssertNoThrow(try LaunchControl.shared.bootout(agent)) 109 | sleep(1) 110 | 111 | XCTAssertEqual(agent.status(), AgentStatus.unloaded) 112 | } 113 | 114 | func testBootoutError() { 115 | let testAgent = LaunchAgent(label: "TestAgent") 116 | XCTAssertThrowsError(try LaunchControl.shared.bootout(testAgent)) 117 | } 118 | 119 | func testStartStop() { 120 | XCTAssertNoThrow(try LaunchControl.shared.load(agent)) 121 | sleep(1) 122 | LaunchControl.shared.start(agent) 123 | sleep(1) 124 | 125 | switch agent.status() { 126 | case .running(_): 127 | XCTAssert(true) 128 | default: 129 | XCTAssert(false) 130 | } 131 | 132 | LaunchControl.shared.stop(agent) 133 | sleep(1) 134 | 135 | XCTAssertEqual(agent.status(), AgentStatus.loaded) 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/Key Types/StartCalendarInterval.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StartCalendarInterval.swift 3 | // LaunchAgentPackageDescription 4 | // 5 | // Created by Emory Dunn on 2018-02-19. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This optional key causes the job to be started every calendar interval as 11 | /// specified. 12 | /// 13 | /// Missing arguments are considered to be wildcard. The semantics 14 | /// are similar to crontab(5) in how firing dates are specified. Multiple 15 | /// dictionaries may be specified in an array to schedule multiple calendar intervals. 16 | /// 17 | /// Unlike cron which skips job invocations when the computer is asleep, 18 | /// launchd will start the job the next time the computer wakes up. If multiple 19 | /// intervals transpire before the computer is woken, those events will 20 | /// be coalesced into one event upon wake from sleep. 21 | /// 22 | /// - Note: StartInterval and StartCalendarInterval are not aware of each 23 | /// other. They are evaluated completely independently by the system. 24 | public class StartCalendarInterval: Codable { 25 | 26 | /// The month (1-12) on which this job will be run. 27 | public var month: StartMonth? 28 | 29 | /// The weekday on which this job will be run (0 and 7 are Sunday). 30 | /// 31 | /// If both Day and Weekday are specificed, then the job will be started 32 | /// if either one matches the current date. 33 | public var weekday: StartWeekday? 34 | 35 | /// Day of the month 36 | /// 37 | /// - Note: Bound to `1...31`. Values outside the range will be set to the closest valid value. 38 | public var day: Int? { 39 | didSet { 40 | guard let newInt = day else { 41 | return 42 | } 43 | if newInt < 1 { 44 | day = 1 45 | } else if newInt > 31 { 46 | day = 31 47 | } 48 | } 49 | } 50 | 51 | /// Hour of the day 52 | /// 53 | /// - Note: Bound to `0...23`. Values outside the range will be set to the closest valid value. 54 | public var hour: Int? { 55 | didSet { 56 | guard let newInt = hour else { 57 | return 58 | } 59 | if newInt < 0 { 60 | hour = 0 61 | } else if newInt > 23 { 62 | hour = 23 63 | } 64 | } 65 | } 66 | 67 | /// Minute of the hour 68 | /// 69 | /// - Note: Bound to `0...59`. Values outside the range will be set to the closest valid value. 70 | public var minute: Int? { 71 | didSet { 72 | guard let newInt = minute else { 73 | return 74 | } 75 | if newInt < 0 { 76 | minute = 0 77 | } else if newInt > 59 { 78 | minute = 59 79 | } 80 | } 81 | } 82 | 83 | /// Set a calendar interval on which to start the job. `nil` values represent any occurance of that key. 84 | /// 85 | /// - Parameters: 86 | /// - month: month of the year to run the job 87 | /// - weekday: day of the week to run the job 88 | /// - day: day of the month to run the job 89 | /// - hour: hour of the day to run the job 90 | /// - minute: minute of the hour to run the job 91 | public init(month: StartMonth? = nil, weekday: StartWeekday? = nil, day: Int? = nil, hour: Int? = nil, minute: Int? = nil) { 92 | self.month = month 93 | self.weekday = weekday 94 | self.day = day 95 | self.hour = hour 96 | self.minute = minute 97 | } 98 | 99 | /// launchd.plist keys 100 | public enum CodingKeys: String, CodingKey { 101 | /// Month 102 | case month = "Month" 103 | /// Weekday 104 | case weekday = "Weekday" 105 | 106 | /// Day 107 | case day = "Day" 108 | /// Hour 109 | case hour = "Hour" 110 | /// Minute 111 | case minute = "Minute" 112 | } 113 | 114 | } 115 | 116 | /// Represents the month in the StartCalendarInterval key 117 | public enum StartMonth: Int, Codable { 118 | /// January 119 | case january = 1 120 | /// February 121 | case february = 2 122 | /// March 123 | case march = 3 124 | /// April 125 | case april = 4 126 | /// May 127 | case may = 5 128 | /// June 129 | case june = 6 130 | /// July 131 | case july = 7 132 | /// August 133 | case august = 8 134 | /// September 135 | case september = 9 136 | /// October 137 | case october = 10 138 | /// November 139 | case november = 11 140 | /// December 141 | case december = 12 142 | } 143 | 144 | /// Represents the weekday in the StartCalendarInterval key 145 | public enum StartWeekday: Int, Codable { 146 | /// Monday 147 | case monday = 1 148 | /// Tuesday 149 | case tuesday = 2 150 | /// Wednesday 151 | case wednesday = 3 152 | /// Thursday 153 | case thursday = 4 154 | /// Friday 155 | case friday = 5 156 | /// Saturday 157 | case saturday = 6 158 | /// Sunday 159 | case sunday = 7 160 | } 161 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/Key Types/File Permissions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File Permissions.swift 3 | // LaunchAgent 4 | // 5 | // Created by Emory Dunn on 2018-04-06. 6 | // 7 | 8 | import Foundation 9 | 10 | /** Individual permission bits for read, write, and execute. 11 | 12 | ## Unix Permissions 13 | - Read: 4 14 | - Write: 2 15 | - Execute: 1 16 | 17 | In addition you can get the [umask](http://www.tutonics.com/2012/12/linux-file-permissions-chmod-umask.html) value. 18 | 19 | */ 20 | public struct PermissionBits: OptionSet, Codable, CustomStringConvertible { 21 | 22 | /// Raw value 23 | public let rawValue: Int 24 | 25 | /// Read permission bit 26 | public static let read = PermissionBits(rawValue: 4) 27 | 28 | /// Write permission bit 29 | public static let write = PermissionBits(rawValue: 2) 30 | 31 | /// Execute permission bit 32 | public static let execute = PermissionBits(rawValue: 1) 33 | 34 | 35 | /// `umask` value 36 | public var umaskValue: Int { 37 | return 7 - self.rawValue 38 | } 39 | 40 | /// Set permissions from a permission octal digit 41 | /// 42 | /// - Parameter rawValue: permission octal digit 43 | public init(rawValue: Int) { 44 | self.rawValue = rawValue 45 | } 46 | 47 | /// Set permissions from a umask octal digit 48 | /// 49 | /// - Parameter umaskValue: umask octal digit 50 | public init(umaskValue: Int) { 51 | self.rawValue = (7 - umaskValue) 52 | } 53 | 54 | /// The symbolic representation of the permissions 55 | public var description: String { 56 | let r = self.contains(.read) ? "r" : "-" 57 | let w = self.contains(.write) ? "w" : "-" 58 | let x = self.contains(.execute) ? "x" : "-" 59 | return r + w + x 60 | 61 | } 62 | } 63 | 64 | /// Represents the permissions on a file 65 | public class FilePermissions: CustomStringConvertible { 66 | 67 | /// User permissions 68 | public var user: PermissionBits 69 | 70 | /// Group permissions 71 | public var group: PermissionBits 72 | 73 | /// Other permissions 74 | public var other: PermissionBits 75 | 76 | /// Init from `PermissionBit`s 77 | /// 78 | /// - Parameters: 79 | /// - user: user permissions 80 | /// - group: group permissions 81 | /// - other: other permissions 82 | public required init(user: PermissionBits, group: PermissionBits, other: PermissionBits) { 83 | self.user = user 84 | self.group = group 85 | self.other = other 86 | } 87 | 88 | /** Init from an integer, either decimal or octal. 89 | 90 | To use an octal representation, such as 755 enter `0o755`. 91 | 92 | To use an decimal representation, such as 488 enter `488`. 93 | 94 | - Parameter number: number representation of the permissions 95 | */ 96 | public convenience init(_ number: Int) { 97 | let octal = String(number, radix: 8) 98 | let padded = octal.leftPadding(toLength: 3, withPad: "0") 99 | 100 | let userDigit = String(padded[0]) 101 | let user = PermissionBits(rawValue: Int(userDigit)!) 102 | 103 | let groupDigit = String(padded[1]) 104 | let group = PermissionBits(rawValue: Int(groupDigit)!) 105 | 106 | let otherDigit = String(padded[2]) 107 | let other = PermissionBits(rawValue: Int(otherDigit)!) 108 | 109 | self.init(user: user, group: group, other: other) 110 | 111 | } 112 | 113 | /// The permissions octal, e.g. 755 114 | public var octal: String { 115 | let octal = String(decimal, radix: 8) 116 | return octal.leftPadding(toLength: 3, withPad: "0") 117 | } 118 | 119 | /// Decimal representation of the permissions, e.g. 488 120 | public var decimal: Int { 121 | let userO = user.rawValue * 64 122 | let groupO = group.rawValue * 8 123 | let otherO = other.rawValue * 1 124 | 125 | return userO + groupO + otherO 126 | } 127 | 128 | /// Symbolic representation of the permissions, e.g. -rwxr-xr-x 129 | public var symbolic: String { 130 | return "-" + user.description + group.description + other.description 131 | } 132 | 133 | /// The symbolic representation of the permissions 134 | public var description: String { 135 | return "Permissions u+\(user), g+\(group), o+\(other)" 136 | } 137 | } 138 | 139 | extension FilePermissions { 140 | 141 | /** Init from an umask integer, either decimal or octal. 142 | 143 | Each digit in the octal is subtracted from 7 to get its value. 144 | 145 | To use an octal representation, such as 027 enter `0o027`. 146 | 147 | To use an decimal representation, such as 23 enter `23`. 148 | 149 | - Parameter number: number representation of the permissions 150 | */ 151 | public convenience init(umask number: Int) { 152 | let octal = String(number, radix: 8) 153 | let padded = octal.leftPadding(toLength: 3, withPad: "0") 154 | 155 | let userDigit = String(padded[0]) 156 | let user = PermissionBits(umaskValue: Int(userDigit)!) 157 | 158 | let groupDigit = String(padded[1]) 159 | let group = PermissionBits(umaskValue: Int(groupDigit)!) 160 | 161 | let otherDigit = String(padded[2]) 162 | let other = PermissionBits(umaskValue: Int(otherDigit)!) 163 | 164 | self.init(user: user, group: group, other: other) 165 | 166 | } 167 | 168 | /// The umask octal, e.g. 027 169 | public var umaskOctal: String { 170 | let octal = String(umaskDecimal, radix: 8) 171 | return octal.leftPadding(toLength: 3, withPad: "0") 172 | } 173 | 174 | /// Decimal representation of the umask octal, e.g. 23 175 | public var umaskDecimal: Int { 176 | let userO = user.umaskValue * 64 177 | let groupO = group.umaskValue * 8 178 | let otherO = other.umaskValue * 1 179 | 180 | return userO + groupO + otherO 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /Tests/LaunchAgentTests/LaunchAgentValiditytests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchAgentValiditytests.swift 3 | // LaunchAgentTests 4 | // 5 | // Created by Emory Dunn on 2018-02-20. 6 | // 7 | 8 | import XCTest 9 | import LaunchAgent 10 | 11 | extension LaunchAgent { 12 | func checksum() -> String { 13 | let agent = self 14 | 15 | let encoder = PropertyListEncoder() 16 | encoder.outputFormat = .xml 17 | 18 | guard let data = try? encoder.encode(agent) else { 19 | XCTAssert(false, "Could not encode LaunchAgent") 20 | return "" 21 | } 22 | 23 | guard let string = String(data: data, encoding: .utf8) else { 24 | XCTAssert(false, "Could not encode plist as string") 25 | return "" 26 | } 27 | 28 | let md5er = Process() 29 | let stdOutPipe = Pipe() 30 | 31 | md5er.launchPath = "/sbin/md5" 32 | md5er.arguments = ["-q", "-s", string] 33 | md5er.standardOutput = stdOutPipe 34 | 35 | md5er.launch() 36 | 37 | // Process Pipe into a String 38 | let stdOutputData = stdOutPipe.fileHandleForReading.readDataToEndOfFile() 39 | let stdOutString = String(bytes: stdOutputData, encoding: String.Encoding.utf8)?.replacingOccurrences(of: "\n", with: "") 40 | 41 | md5er.waitUntilExit() 42 | 43 | return stdOutString ?? "" 44 | } 45 | } 46 | 47 | class LaunchAgentValiditytests: XCTestCase { 48 | 49 | 50 | func testBasicConfig() { 51 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 52 | 53 | launchAgent.disabled = false 54 | launchAgent.enableGlobbing = true 55 | 56 | XCTAssertEqual(launchAgent.checksum(), "2ae0c4badbcbf024c4b2f611a8e9d9b2") 57 | } 58 | 59 | func testProgram() { 60 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 61 | 62 | launchAgent.workingDirectory = "/" 63 | launchAgent.standardInPath = "/tmp/LaunchAgentTest.stdin" 64 | launchAgent.standardOutPath = "/tmp/LaunchAgentTest.stdout" 65 | launchAgent.standardErrorPath = "/tmp/LaunchAgentTest.stderr" 66 | launchAgent.environmentVariables = ["envVar": "test"] 67 | 68 | XCTAssertEqual(launchAgent.checksum(), "938144a1ef925797bb644687877fd128") 69 | } 70 | 71 | func testRunConditions() { 72 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 73 | 74 | let interval = StartCalendarInterval( 75 | month: .january, 76 | weekday: .monday, 77 | day: 1, 78 | hour: 1, 79 | minute: 1 80 | ) 81 | 82 | launchAgent.runAtLoad = true 83 | launchAgent.startInterval = 300 84 | launchAgent.startCalendarInterval = interval 85 | launchAgent.startOnMount = true 86 | launchAgent.onDemand = true 87 | launchAgent.keepAlive = false 88 | launchAgent.watchPaths = ["/"] 89 | launchAgent.queueDirectories = ["/"] 90 | 91 | XCTAssertEqual(launchAgent.checksum(), "5965ee695f88a12c7992b07ecda43199") 92 | } 93 | 94 | func testSecurity() { 95 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 96 | 97 | launchAgent.umask = 18 98 | 99 | XCTAssertEqual(launchAgent.checksum(), "ba2edc2f91676815fc0c0d5326a28653") 100 | } 101 | 102 | func testRunConstriants() { 103 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 104 | 105 | launchAgent.launchOnlyOnce = false 106 | launchAgent.limitLoadToSessionType = ["Aqua", "LoginWindow"] 107 | launchAgent.limitLoadToHosts = ["testHost"] 108 | launchAgent.limitLoadFromHosts = ["testHost II"] 109 | 110 | XCTAssertEqual(launchAgent.checksum(), "0848687c86bd4f91c008e927f62996d6") 111 | } 112 | 113 | func testControl() { 114 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 115 | 116 | let softResource = ResourceLimits(cpu: 1, core: 1, data: 1, fileSize: 1, memoryLock: 1, numberOfFiles: 1, numberOfProcesses: 1, residentSetSize: 1, stack: 1) 117 | 118 | let hardResource = ResourceLimits(cpu: 2, core: 2, data: 2, fileSize: 2, memoryLock: 2, numberOfFiles: 2, numberOfProcesses: 2, residentSetSize: 2, stack: 2) 119 | 120 | launchAgent.abandonProcessGroup = true 121 | launchAgent.enablePressuredExit = true 122 | launchAgent.enableTransactions = true 123 | launchAgent.exitTimeOut = 30 124 | launchAgent.inetdCompatibility = inetdCompatibility(wait: true) 125 | launchAgent.softResourceLimits = softResource 126 | launchAgent.hardResourceLimits = hardResource 127 | launchAgent.timeOut = 30 128 | launchAgent.throttleInterval = 30 129 | 130 | XCTAssertEqual(launchAgent.checksum(), "056be4d7e234fc3d17b6f849f8f3b412") 131 | } 132 | 133 | func testIPC() { 134 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 135 | 136 | launchAgent.machServices = [ 137 | "local.svc": MachService(hideUntilCheckIn: true, resetAtClose: true) 138 | ] 139 | 140 | 141 | XCTAssertEqual(launchAgent.checksum(), "f183b615ad0dee50a5790af4b7d773ed") 142 | } 143 | 144 | func testDebug() { 145 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 146 | 147 | launchAgent.debug = true 148 | launchAgent.waitForDebugger = true 149 | 150 | XCTAssertEqual(launchAgent.checksum(), "a6018f910f828883096f789d4293db67") 151 | } 152 | 153 | func testPerformance() { 154 | let launchAgent = LaunchAgent(label: "Launch Agent Test", program: "/bin/echo", "LaunchAgentTests") 155 | 156 | launchAgent.legacyTimers = true 157 | launchAgent.lowPriorityIO = true 158 | launchAgent.lowPriorityBackgroundIO = true 159 | launchAgent.nice = 10 160 | launchAgent.processType = .background 161 | 162 | XCTAssertEqual(launchAgent.checksum(), "82cef72d83be053985d51db4c33565b9") 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LaunchAgent 2 | 3 | ![SwiftPM] ![Swift5.2] ![license] ![build] ![coverage] 4 | 5 | [SwiftPM]: https://img.shields.io/badge/SwiftPM-compatible-success.svg 6 | [swift]: https://developer.apple.com/swift/ 7 | [Swift5.2]: https://img.shields.io/badge/swift-5.2-orange.svg?style=flat 8 | [spm]: https://swift.org/package-manager/ 9 | [license]: https://img.shields.io/github/license/emorydunn/LaunchAgent.svg?style=flat 10 | [build]: https://github.com/emorydunn/LaunchAgent/workflows/Swift/badge.svg 11 | [docs]: https://emorydunn.github.io/LaunchAgent/ 12 | [coverage]: https://emorydunn.github.io/LaunchAgent/badge.svg 13 | 14 | LaunchAgent provides an easy way to pragmatically create and maintain [`launchd`][launchd] agents and daemons without needing to manually build Property Lists. 15 | 16 | Take a look at the full [documentation][docs]. 17 | 18 | [launchd]: http://www.launchd.info 19 | 20 | ## LaunchAgent 21 | 22 | A LaunchAgent can be created with either an array of program arguments: 23 | ```swift 24 | LaunchAgent(label: "local.PythonServer", program: ["python", "-m", "SimpleHTTPServer", "8000"]) 25 | ``` 26 | 27 | or with variadic parameters: 28 | ```swift 29 | LaunchAgent(label: "local.PythonServer", program: "python", "-m", "SimpleHTTPServer", "8000") 30 | ``` 31 | 32 | The agent can also be created with only a label, but will be invalid if loaded: 33 | ```swift 34 | LaunchAgent(label: "local.PythonServer") 35 | ``` 36 | 37 | When creating a new agent it needs to be written to disk: 38 | 39 | ```swift 40 | let agent = LaunchAgent(label: "local.PythonServer", program: "python", "-m", "SimpleHTTPServer", "8000") 41 | 42 | do { 43 | try LaunchControl.shared.write(agent) 44 | try agent.load() 45 | agent.start() 46 | } catch { 47 | print("Unexpected error:" error) 48 | } 49 | ``` 50 | 51 | ### Using LaunchControl to read and write LaunchAgents 52 | 53 | The `LaunchControl` class can read agents from and write agents to `~/Library/LaunchAgents`. 54 | When using either method the `url` of the loaded agent will be set. 55 | 56 | ### Controlling LaunchAgents 57 | 58 | LaunchAgent has `load()`, `unload()`, `start()`, `stop()`, and `status()` methods which do what they say on the tin. 59 | 60 | Load & unload require the agent's URL parameter to be set, or `launchctl` won't be able to locate them. 61 | Start, stop, and status are called based on the label. 62 | 63 | 64 | ## Supported Keys 65 | 66 | LaunchAgent does not currently support all keys, and there are some caveats to some keys it does support. 67 | 68 | Most parameters in the `LaunchAgent` class are optional, and setting `nil` will remove the key from the encoded plist. 69 | Some keys use their own type to encapsulate a complex dictionary value. 70 | 71 | ## Basic Config 72 | | Key Name | Key type | Supported | Notes | 73 | |------------------|----------|-----------|-------| 74 | | Label | String | true | | 75 | | Disabled | String | true | | 76 | | Program | String | true | | 77 | | ProgramArguments | [String] | true | | 78 | | EnableGlobbing | Bool | true | deprecated in macOS | 79 | 80 | ### Program 81 | | Key Name | Key type | Supported | Notes | 82 | |----------------------|------------------|-----------|-------| 83 | | workingDirectory | String | true | | 84 | | standardInPath | String | true | | 85 | | standardOutPath | String | true | | 86 | | standardErrorPath | String | true | | 87 | | environmentVariables | [String: String] | true | | 88 | 89 | ### Run Conditions 90 | | Key Name | Key type | Supported | Notes | 91 | |-----------------------|-----------------------|-----------|-------| 92 | | runAtLoad | Bool | true | | 93 | | startInterval | Int | true | | 94 | | startCalendarInterval | StartCalendarInterval | true | | 95 | | startOnMount | Bool | true | | 96 | | onDemand | Bool | true | | 97 | | keepAlive | Bool | true | | 98 | | watchPaths | [String] | true | | 99 | | queueDirectories | [String] | true | | 100 | 101 | ### Security 102 | | Key Name | Key type | Supported | Notes | 103 | |---------------|----------|-----------|-------| 104 | | umask | Int | true | Use `FilePermissions.umaskDecimal` to get a valid value | 105 | | sessionCreate | Bool | true | | 106 | | groupName | String | true | | 107 | | userName | String | true | | 108 | | initGroups | Bool | true | | 109 | | rootDirectory | String | true | | 110 | 111 | ### Run Constraints 112 | | Key Name | Key type | Supported | Notes | 113 | |------------------------|----------|-----------|-------| 114 | | launchOnlyOnce | Bool | true | | 115 | | limitLoadToSessionType | [String] | true | Always encodes as an array | 116 | | limitLoadToHosts | [String] | true | | 117 | | limitLoadFromHosts | [String] | true | | 118 | 119 | 120 | ### Control 121 | | Key Name | Key type | Supported | Notes | 122 | |---------------------|--------------------|-----------|-------| 123 | | AbandonProcessGroup | Bool | true | | 124 | | EnablePressuredExit | Bool | true | | 125 | | EnableTransactions | Bool | true | | 126 | | ExitTimeOut | Int | true | | 127 | | inetdCompatibility | inetdCompatibility | true | | 128 | | HardResourceLimits | ResourceLimits | true | | 129 | | SoftResourceLimits | ResourceLimits | true | | 130 | | TimeOut | Int | true | | 131 | | ThrottleInterval | Int | true | | 132 | 133 | ### IPC 134 | | Key Name | Key type | Supported | Notes | 135 | |--------------|------------------------|-----------|-------| 136 | | MachServices | [String: MachService] | true | | 137 | | Sockets | | false | | 138 | 139 | 140 | ### Debug 141 | | Key Name | Key type | Supported | Notes | 142 | |-----------------|----------|-----------|-------| 143 | | Debug | Bool | true | Deprecated | 144 | | WaitForDebugger | Bool | true | | 145 | 146 | ### Performance 147 | | Key Name | Key type | Supported | Notes | 148 | |-------------------------|------------------|-----------|-------| 149 | | LegacyTimers | Bool | true | | 150 | | LowPriorityIO | Bool | true | | 151 | | LowPriorityBackgroundIO | Bool | true | | 152 | | Nice | Int | true | | 153 | | ProcessType | ProcessType enum | true | | 154 | 155 | 156 | ## Custom Key Classes 157 | 158 | ### StartCalendarInterval 159 | 160 | The `StartCalendarInterval` encapsulates the dictionary for setting calendar-based job intervals. 161 | By default all values are set to `nil`, meaning the job will run on any occurrence of that value. 162 | 163 | The Month and Weekday keys are represented by enums for each month and week, respectively. 164 | Day, Hour, and Minute values are simply integers. They are checked for validity in their 165 | respective time ranges, and will be set to the minimum or maximum value depending on which way they were out of bounds. 166 | 167 | ## inetdCompatibility 168 | 169 | Encapsulates the `inetdCompatibility` `wait` key. 170 | 171 | ## ResourceLimits 172 | 173 | Encapsulates the SoftResourceLimits and HardResourceLimits keys: 174 | 175 | - cpu 176 | - core 177 | - data 178 | - fileSize 179 | - memoryLock 180 | - numberOfFiles 181 | - numberOfProcesses 182 | - residentSetSize 183 | - stack 184 | 185 | ## FilePermissions 186 | 187 | Individual permission Unix bits for read, write, and execute. 188 | 189 | ## Unix Permissions 190 | - Read: 4 191 | - Write: 2 192 | - Execute: 1 193 | 194 | In addition you can get the [umask](https://ss64.com/osx/umask.html) value. 195 | 196 | When setting permissions for a LaunchAgent use `.umaskDecimal` to get the value. 197 | If you're reading a LaunchAgent `FilePermissions(umask:)` will read in the decimal so the permissions can be updated. 198 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/LaunchControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchControl.swift 3 | // LaunchAgent 4 | // 5 | // Created by Emory Dunn on 2018-02-19. 6 | // 7 | 8 | import Foundation 9 | import SystemConfiguration 10 | 11 | /// Errors related to controlling jobs 12 | public enum LaunchControlError: Error, LocalizedError { 13 | 14 | /// The URL is not set for the specified agent 15 | case urlNotSet(label: String) 16 | 17 | /// Description of the error 18 | public var localizedDescription: String { 19 | switch self { 20 | case .urlNotSet(let label): 21 | return "The URL is not set for agent \(label)" 22 | } 23 | } 24 | } 25 | 26 | /// Control agents and daemons. 27 | public class LaunchControl { 28 | 29 | /// The shared instance 30 | public static let shared = LaunchControl() 31 | 32 | static let launchctl = "/bin/launchctl" 33 | 34 | 35 | let encoder = PropertyListEncoder() 36 | let decoder = PropertyListDecoder() 37 | 38 | private var uid: uid_t = 0 39 | private var gid: gid_t = 0 40 | 41 | init() { 42 | SCDynamicStoreCopyConsoleUser(nil, &uid, &gid) 43 | 44 | encoder.outputFormat = .xml 45 | } 46 | 47 | /// Provides the user's LaunchAgent directory 48 | /// 49 | /// - Note: If run in a sandbox the directory returned will be inside the application's container 50 | /// 51 | /// - Returns: ~/Library/LaunchAgent 52 | /// - Throws: FileManager errors 53 | func launchAgentsURL() throws -> URL { 54 | let library = try FileManager.default.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: false) 55 | 56 | return library.appendingPathComponent("LaunchAgents") 57 | } 58 | 59 | /// Read a LaunchAgent from the user's LaunchAgents directory 60 | /// 61 | /// - Parameter called: file name of the job 62 | /// - Returns: a LaunchAgent instance 63 | /// - Throws: errors on decoding the property list 64 | public func read(agent called: String) throws -> LaunchAgent { 65 | let url = try launchAgentsURL().appendingPathComponent(called) 66 | 67 | return try read(from: url) 68 | } 69 | 70 | /// Read a LaunchAgent from disk 71 | /// 72 | /// - Parameter url: url of the property list 73 | /// - Returns:a LaunchAgent instance 74 | /// - Throws: errors on decoding the property list 75 | public func read(from url: URL) throws -> LaunchAgent { 76 | let agent = try decoder.decode(LaunchAgent.self, from: Data(contentsOf: url)) 77 | agent.url = url 78 | return agent 79 | } 80 | 81 | /// Writes a LaunchAgent to disk as a property list into the user's LaunchAgents directory 82 | /// 83 | /// The agent's label will be used as the filename with a `.plist` extension. 84 | /// 85 | /// - Parameters: 86 | /// - agent: the agent to encode 87 | /// - Throws: errors on encoding the property list 88 | public func write(_ agent: LaunchAgent) throws { 89 | let url = try launchAgentsURL().appendingPathComponent("\(agent.label).plist") 90 | 91 | try write(agent, to: url) 92 | } 93 | 94 | /// Writes a LaunchAgent to disk as a property list into the user's LaunchAgents directory 95 | /// 96 | /// - Parameters: 97 | /// - agent: the agent to encode 98 | /// - called: the file name of the job 99 | /// - Throws: errors on encoding the property list 100 | public func write(_ agent: LaunchAgent, called: String) throws { 101 | let url = try launchAgentsURL().appendingPathComponent(called) 102 | 103 | try write(agent, to: url) 104 | } 105 | 106 | /// Writes a LaunchAgent to disk as a property list to the specified URL 107 | /// 108 | /// `.plist` will be appended to the URL if needed 109 | /// 110 | /// - Parameters: 111 | /// - agent: the agent to encode 112 | /// - called: the url at which to write 113 | /// - Throws: errors on encoding the property list 114 | public func write(_ agent: LaunchAgent, to url: URL) throws { 115 | var url = url 116 | if url.pathExtension != "plist" { 117 | url.appendPathExtension("plist") 118 | } 119 | try encoder.encode(agent).write(to: url) 120 | 121 | agent.url = url 122 | } 123 | 124 | /// Sets the provided LaunchAgent's URL based on its `label` 125 | /// 126 | /// - Parameter agent: the LaunchAgent 127 | /// - Throws: errors when reading directory contents 128 | public func setURL(for agent: LaunchAgent) throws { 129 | let contents = try FileManager.default.contentsOfDirectory( 130 | at: try launchAgentsURL(), 131 | includingPropertiesForKeys: nil, 132 | options: [.skipsPackageDescendants, .skipsHiddenFiles, .skipsSubdirectoryDescendants] 133 | ) 134 | 135 | contents.forEach { url in 136 | let testAgent = try? self.read(from: url) 137 | 138 | if agent.label == testAgent?.label { 139 | agent.url = url 140 | return 141 | } 142 | } 143 | 144 | 145 | } 146 | 147 | } 148 | 149 | // MARK: - Job control 150 | extension LaunchControl { 151 | /// Run `launchctl start` on the agent 152 | /// 153 | /// Check the status of the job with `.status(_: LaunchAgent)` 154 | public func start(_ agent: LaunchAgent) { 155 | let arguments = ["start", agent.label] 156 | Process.launchedProcess(launchPath: LaunchControl.launchctl, arguments: arguments) 157 | } 158 | 159 | /// Run `launchctl stop` on the agent 160 | /// 161 | /// Check the status of the job with `.status(_: LaunchAgent)` 162 | public func stop(_ agent: LaunchAgent) { 163 | let arguments = ["stop", agent.label] 164 | Process.launchedProcess(launchPath: LaunchControl.launchctl, arguments: arguments) 165 | } 166 | 167 | /// Run `launchctl load` on the agent 168 | /// 169 | /// Check the status of the job with `.status(_: LaunchAgent)` 170 | @available(macOS, deprecated: 10.11) 171 | public func load(_ agent: LaunchAgent) throws { 172 | guard let agentURL = agent.url else { 173 | throw LaunchControlError.urlNotSet(label: agent.label) 174 | } 175 | 176 | let arguments = ["load", agentURL.path] 177 | Process.launchedProcess(launchPath: LaunchControl.launchctl, arguments: arguments) 178 | } 179 | 180 | /// Run `launchctl unload` on the agent 181 | /// 182 | /// Check the status of the job with `.status(_: LaunchAgent)` 183 | @available(macOS, deprecated: 10.11) 184 | public func unload(_ agent: LaunchAgent) throws { 185 | guard let agentURL = agent.url else { 186 | throw LaunchControlError.urlNotSet(label: agent.label) 187 | } 188 | 189 | let arguments = ["unload", agentURL.path] 190 | Process.launchedProcess(launchPath: LaunchControl.launchctl, arguments: arguments) 191 | } 192 | 193 | /// Run `launchctl bootstrap` on the agent 194 | /// 195 | /// Check the status of the job with `.status(_: LaunchAgent)` 196 | @available(macOS, introduced: 10.11) 197 | public func bootstrap(_ agent: LaunchAgent) throws { 198 | guard let agentURL = agent.url else { 199 | throw LaunchControlError.urlNotSet(label: agent.label) 200 | } 201 | 202 | let arguments = ["bootstrap", "gui/\(uid)", agentURL.path] 203 | Process.launchedProcess(launchPath: LaunchControl.launchctl, arguments: arguments) 204 | } 205 | 206 | /// Run `launchctl bootout` on the agent 207 | /// 208 | /// Check the status of the job with `.status(_: LaunchAgent)` 209 | @available(macOS, introduced: 10.11) 210 | public func bootout(_ agent: LaunchAgent) throws { 211 | guard let agentURL = agent.url else { 212 | throw LaunchControlError.urlNotSet(label: agent.label) 213 | } 214 | 215 | let arguments = ["bootout", "gui/\(uid)", agentURL.path] 216 | Process.launchedProcess(launchPath: LaunchControl.launchctl, arguments: arguments) 217 | } 218 | 219 | /// Retreives the status of the LaunchAgent from `launchctl` 220 | /// 221 | /// - Returns: the agent's status 222 | public func status(_ agent: LaunchAgent) -> AgentStatus { 223 | // Adapted from https://github.com/zenonas/barmaid/blob/master/Barmaid/LaunchControl.swift 224 | 225 | let launchctlTask = Process() 226 | let grepTask = Process() 227 | let cutTask = Process() 228 | 229 | launchctlTask.launchPath = "/bin/launchctl" 230 | launchctlTask.arguments = ["list"] 231 | 232 | grepTask.launchPath = "/usr/bin/grep" 233 | grepTask.arguments = [agent.label] 234 | 235 | cutTask.launchPath = "/usr/bin/cut" 236 | cutTask.arguments = ["-f1"] 237 | 238 | let pipeLaunchCtlToGrep = Pipe() 239 | launchctlTask.standardOutput = pipeLaunchCtlToGrep 240 | grepTask.standardInput = pipeLaunchCtlToGrep 241 | 242 | let pipeGrepToCut = Pipe() 243 | grepTask.standardOutput = pipeGrepToCut 244 | cutTask.standardInput = pipeGrepToCut 245 | 246 | let pipeCutToFile = Pipe() 247 | cutTask.standardOutput = pipeCutToFile 248 | 249 | let fileHandle: FileHandle = pipeCutToFile.fileHandleForReading as FileHandle 250 | 251 | launchctlTask.launch() 252 | grepTask.launch() 253 | cutTask.launch() 254 | 255 | 256 | let data = fileHandle.readDataToEndOfFile() 257 | let stringResult = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .newlines) ?? "" 258 | 259 | switch stringResult { 260 | case "-": 261 | return .loaded 262 | case "": 263 | return .unloaded 264 | default: 265 | return .running(pid: Int(stringResult)!) 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /Sources/LaunchAgent/LaunchAgent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchAgentswift 3 | // LaunchAgentPackageDescription 4 | // 5 | // Created by Emory Dunn on 2018-02-19. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The primary class used to control a Launch Agent 11 | /// 12 | /// For detailed information on agents see [https://www.launchd.info](https://www.launchd.info). 13 | public class LaunchAgent: Codable { 14 | 15 | /// Location on disk of the agent 16 | /// 17 | /// `LaunchControl` can be used to attempt to set the URL automatically: 18 | /// 19 | /// ``` 20 | /// LaunchControl.shared.setURL(for: agent) 21 | /// ``` 22 | public var url: URL? = nil 23 | 24 | // Basic Properties 25 | /// Contains a unique string that identifies your daemon to launchd. 26 | public var label: String 27 | 28 | /// Whether the agent is enabled. 29 | /// 30 | /// This must be `true` in order to load or start the agent. 31 | public var disabled: Bool? = nil 32 | 33 | /// Whether to enable shell globbing. 34 | /// 35 | /// - Important: Support for globbing was removed in macOS 10.10 36 | public var enableGlobbing: Bool? = nil 37 | 38 | /// The program to be executed 39 | public var program: String? = nil 40 | 41 | /// Contains the arguments used to launch your daemon 42 | /// 43 | /// - Note: If `program` is empty the first item is used as the path to the executable. 44 | public var programArguments: [String]? = nil 45 | 46 | // MARK: Program 47 | /// This optional key is used to specify a directory to chdir(2) to before 48 | /// running the job. 49 | public var workingDirectory: String? = nil 50 | 51 | /// This optional key specifies that the given path should be mapped to the 52 | /// job's stdin(4), and that the contents of that file will be readable from 53 | /// the job's stdin(4). 54 | /// 55 | /// If the file does not exist, no data will be deliv- 56 | /// ered to the process' stdin(4) 57 | public var standardInPath: String? = nil 58 | 59 | /// This optional key specifies that the given path should be mapped to the 60 | /// job's stdout(4), and that any writes to the job's stdout(4) will go to 61 | /// the given file. 62 | /// 63 | /// If the file does not exist, it will be created with 64 | /// writable permissions and ownership reflecting the user and/or group specified 65 | /// as the UserName and/or GroupName, respectively (if set) and permissions 66 | /// reflecting the umask(2) specified by the Umask key, if set. 67 | public var standardOutPath: String? = nil 68 | 69 | /// This optional key specifies that the given path should be mapped to the 70 | /// job's stderr(4), and that any writes to the job's stderr(4) will go to 71 | /// the given file. 72 | /// 73 | /// Note that this file is opened as readable and writable as 74 | /// mandated by the POSIX specification for unclear reasons. If the file 75 | /// does not exist, it will be created with ownership reflecting the user 76 | /// and/or group specified as the UserName and/or GroupName, respectively (if 77 | /// set) and permissions reflecting the umask(2) specified by the Umask key, 78 | /// if set. 79 | public var standardErrorPath: String? = nil 80 | 81 | /// This optional key is used to specify additional environmental variables 82 | /// to be set before running the job. Each key in the dictionary is the name 83 | /// of an environment variable, with the corresponding value being a string 84 | /// representing the desired value. NOTE: Values other than strings will be 85 | /// ignored. 86 | public var environmentVariables: [String: String]? = nil 87 | 88 | // MARK: Run Conditions 89 | /// This optional key is used to control whether your job is launched once at 90 | /// the time the job is loaded. 91 | public var runAtLoad: Bool? = nil 92 | 93 | /// This optional key causes the job to be started every N seconds. 94 | /// 95 | /// If the system is asleep during the time of the next scheduled interval firing, 96 | /// that interval will be missed due to shortcomings in kqueue(3). If the 97 | /// job is running during an interval firing, that interval firing will like-wise be missed. 98 | public var startInterval: Int? = nil 99 | 100 | /// This optional key causes the job to be started every calendar interval as 101 | /// specified. 102 | /// 103 | /// Missing arguments are considered to be wildcard. The semantics 104 | /// are similar to crontab(5) in how firing dates are specified. Multiple 105 | /// dictionaries may be specified in an array to schedule multiple calendar 106 | /// intervals. 107 | public var startCalendarInterval: StartCalendarInterval? = nil 108 | 109 | /// This optional key causes the job to be started every time a filesystem is 110 | /// mounted. 111 | public var startOnMount: Bool? = nil 112 | 113 | /// This key does nothing if set to true. If set to false, this key is equiv- 114 | /// alent to specifying a true value for the KeepAlive key. This key should 115 | /// not be used. Please remove this key from your launchd.plist. 116 | public var onDemand: Bool? = nil 117 | 118 | /// This key specifies whether your daemon launches on-demand or must always be running. 119 | /// It is recommended that you design your daemon to be launched on-demand. 120 | public var keepAlive: Bool? = nil 121 | 122 | /// This optional key causes the job to be started if any one of the listed 123 | /// paths are modified. 124 | public var watchPaths: [String]? = nil 125 | 126 | /// This optional key keeps the job alive as long as the directory or direc- 127 | /// tories specified are not empty. 128 | public var queueDirectories: [String]? = nil 129 | 130 | // MARK: Security 131 | /// This optional key specifies what value should be passed to umask(2) 132 | /// before running the job. 133 | /// 134 | /// If the value specified is an integer, it must be 135 | /// a decimal representation of the desired umask, as property lists do not 136 | /// support encoding integers in octal. If a string is given, the string will 137 | /// be converted into an integer as per the rules described in strtoul(3), 138 | /// and an octal value may be specified by prefixing the string with a '0'. 139 | /// If a string that does not cleanly convert to an integer is specified, the 140 | /// behavior will be to set a umask(2) according to the strtoul(3) parsing 141 | /// rules. 142 | public var umask: Int? = nil 143 | 144 | // MARK: System Daemon Security 145 | 146 | /// This key specifies that the job should be spawned into a new security 147 | /// audit session rather than the default session for the context is belongs 148 | /// to. See `auditon(2)` for details. 149 | public var sessionCreate: Bool? = nil 150 | 151 | /// This optional key specifies the group to run the job as. 152 | /// 153 | /// This key is only applicable for services that are loaded into the privileged system 154 | /// domain. If UserName is set and GroupName is not, then the group will be 155 | /// set to the primary group of the user. 156 | public var groupName: String? = nil 157 | 158 | /// This optional key specifies the user to run the job as. 159 | /// 160 | /// This key is only applicable for services that are loaded into the privileged system 161 | /// domain. 162 | public var userName: String? = nil 163 | 164 | /// This optional key specifies whether initgroups(3) to initialize the group 165 | /// list for the job. 166 | /// 167 | /// The default is true. This key will be ignored if the 168 | /// UserName key is not set. Note that for agents, the UserName key is ignored. 169 | public var initGroups: Bool? = nil 170 | 171 | /// This optional key is used to specify a directory to chroot(2) to before 172 | /// running the job. 173 | public var rootDirectory: String? = nil 174 | 175 | 176 | // Run Constriants 177 | /// This optional key specifies whether the job can only be run once and only 178 | /// once. 179 | /// 180 | /// In other words, if the job cannot be safely respawned without a 181 | /// full machine reboot, then set this key to be true. 182 | public var launchOnlyOnce: Bool? = nil 183 | 184 | /// This configuration file only applies to sessions of the type(s) speci- 185 | /// fied. 186 | /// 187 | /// This key only applies to jobs which are agents. There are no distinct 188 | /// sessions in the privileged system context. 189 | public var limitLoadToSessionType: [String]? = nil 190 | 191 | /// This configuration file only applies to the hosts listed with this key. 192 | /// 193 | /// - Important: This key is no longer supported. 194 | public var limitLoadToHosts: [String]? = nil 195 | 196 | /// This configuration file only applies to hosts NOT listed with this key. 197 | /// 198 | /// - Important: This key is no longer supported. 199 | public var limitLoadFromHosts: [String]? = nil 200 | 201 | 202 | // MARK: Control 203 | /// When a job dies, launchd kills any remaining processes with the same 204 | /// process group ID as the job. 205 | /// 206 | /// Setting this key to true disables that behavior. 207 | public var abandonProcessGroup: Bool? = nil 208 | 209 | 210 | /// This key opts the job into the system's Pressured Exit facility. 211 | /// 212 | /// Use of this key implies EnableTransactions , and also lets the system consider 213 | /// process eligible for reclamation under memory pressure when it's inac- 214 | /// tive. See `xpc_main(3)` for details. Jobs that opt into Pressured Exit will 215 | /// be automatically relaunched if they exit or crash while holding open 216 | /// transactions. 217 | /// 218 | /// - NOTE: `launchd(8)` does not respect EnablePressuredExit for jobs that have 219 | /// KeepAlive set to true. 220 | /// - IMPORTANT: Jobs which opt into Pressured Exit will ignore SIGTERM rather 221 | /// than exiting by default, so a `dispatch(3)` source must be used when han- 222 | /// dling this signal. 223 | public var enablePressuredExit: Bool? = nil 224 | 225 | /// This key instructs launchd that the job uses `xpc_transaction_begin(3)` and 226 | /// `xpc_transaction_end(3)` to track outstanding transactions. 227 | /// 228 | /// When a process has an outstanding transaction, it is considered active, otherwise inac- 229 | /// tive. A transaction is automatically created when an XPC message expect- 230 | /// ing a reply is received, until the reply is sent or the request message 231 | /// is discarded. When launchd stops an active process, it sends SIGTERM 232 | /// first, and then SIGKILL after a reasonable timeout. If the process is 233 | /// inactive, SIGKILL is sent immediately. 234 | public var enableTransactions: Bool? = nil 235 | 236 | /// The amount of time launchd waits between sending the SIGTERM signal and 237 | /// before sending a SIGKILL signal when the job is to be stopped. 238 | /// 239 | /// The default value is system-defined. The value zero is interpreted as infinity and 240 | /// should not be used, as it can stall system shutdown forever. 241 | public var exitTimeOut: Int? = nil 242 | 243 | /// The presence of this key specifies that the daemon expects to be run as 244 | /// if it were launched from inetd. 245 | /// 246 | /// - Important: For new projects, this key should be avoided. 247 | public var inetdCompatibility: inetdCompatibility? = nil 248 | 249 | /// Resource limits to be imposed on the job. These adjust variables set with setrlimit(2). 250 | public var hardResourceLimits: ResourceLimits? = nil 251 | 252 | /// Resource limits to be imposed on the job. These adjust variables set with setrlimit(2). 253 | public var softResourceLimits: ResourceLimits? = nil 254 | 255 | /// The recommended idle time out (in seconds) to pass to the job. 256 | /// 257 | /// This key never did anything interesting and is no longer implemented. Jobs seeking 258 | /// to exit when idle should use the EnablePressuredExit key to opt into the 259 | /// system mechanism for reclaiming killable jobs under memory pressure. 260 | public var timeOut: Int? = nil 261 | 262 | /// This key lets one override the default throttling policy imposed on jobs 263 | /// by launchd. 264 | /// 265 | /// The value is in seconds, and by default, jobs will not be 266 | /// spawned more than once every 10 seconds. The principle behind this is 267 | /// that jobs should linger around just in case they are needed again in the 268 | /// near future. This not only reduces the latency of responses, but it 269 | /// encourages developers to amortize the cost of program invocation. 270 | public var throttleInterval: Int? = nil 271 | 272 | // MARK: IPC 273 | /// This optional key is used to specify Mach services to be registered with 274 | /// the Mach bootstrap namespace. 275 | /// 276 | /// Each key in this dictionary should be the 277 | /// name of a service to be advertised. The value of the key must be a 278 | /// boolean and set to true or a dictionary in order for the service to be 279 | /// advertised. 280 | public var machServices: [String: MachService]? = nil 281 | 282 | // MARK: Debug 283 | /// This optional key specifies that launchd should adjust its log mask temporarily 284 | /// to LOG_DEBUG while dealing with this job. 285 | public var debug: Bool? = nil 286 | 287 | /// This optional key specifies that launchd should launch the job in a suspended 288 | /// state so that a debugger can be attached to the process as early as possible (at the first instruction). 289 | public var waitForDebugger: Bool? = nil 290 | 291 | // MARK: Performance 292 | 293 | /// This optional key controls the behavior of timers created by the job. 294 | /// 295 | /// By default on OS X Mavericks version 10.9 and later, timers created by 296 | /// launchd jobs are coalesced. Batching the firing of timers with similar 297 | /// deadlines improves the overall energy efficiency of the system. If this 298 | /// key is set to true, timers created by the job will opt into less effi- 299 | /// cient but more precise behavior and not be coalesced with other timers. 300 | /// This key may have no effect if the job's ProcessType is not set to Interactive. 301 | public var legacyTimers: Bool? = nil 302 | 303 | /// This optional key specifies whether the kernel should consider this 304 | /// daemon to be low priority when doing filesystem I/O. 305 | public var lowPriorityIO: Bool? = nil 306 | 307 | /// This optional key specifies whether the kernel should consider this 308 | /// daemon to be low priority when doing filesystem I/O when the process is 309 | /// throttled with the Darwin-background classification. 310 | public var lowPriorityBackgroundIO: Bool? = nil 311 | 312 | /// This optional key specifies what nice(3) value should be applied to the daemon. 313 | public var nice: Int? = nil { 314 | didSet { 315 | guard let newInt = nice else { 316 | return 317 | } 318 | if newInt < -20 { 319 | nice = -20 320 | } else if newInt > 20 { 321 | nice = 20 322 | } 323 | } 324 | } 325 | 326 | /// This optional key describes, at a high level, the intended purpose of the 327 | /// job. 328 | /// 329 | /// The system will apply resource limits based on what kind of job it 330 | /// is. If left unspecified, the system will apply light resource limits to 331 | /// the job, throttling its CPU usage and I/O bandwidth. This classification 332 | /// is preferable to using the HardResourceLimits, SoftResourceLimits and 333 | /// Nice keys. 334 | public var processType: ProcessType? = nil 335 | 336 | /// Instantiate a new LaunchAgent 337 | /// 338 | /// - Note: Globbing was deprecated in macOS 10.10, so full paths must be provided 339 | /// 340 | /// - Parameters: 341 | /// - label: the job's label 342 | /// - program: the job's program arguments 343 | public init(label: String, program: [String]) { 344 | self.label = label 345 | if program.count == 1 { 346 | self.program = program.first 347 | } else { 348 | self.programArguments = program 349 | } 350 | 351 | } 352 | 353 | /// Instantiate a new LaunchAgent 354 | /// 355 | /// - Note: Globbing was deprecated in macOS 10.10, so full paths must be provided 356 | /// 357 | /// - Parameters: 358 | /// - label: the job's label 359 | /// - program: the job's program arguments 360 | public convenience init(label: String, program: String...) { 361 | self.init(label: label, program: program) 362 | } 363 | 364 | // MARK: - Codable 365 | /// launchd.plist keys 366 | public enum CodingKeys: String, CodingKey { 367 | /// Label 368 | case label = "Label" 369 | /// Disabled 370 | case disabled = "Disabled" 371 | /// EnableGlobbing 372 | case enableGlobbing = "EnableGlobbing" 373 | /// Program 374 | case program = "Program" 375 | /// ProgramArguments 376 | case programArguments = "ProgramArguments" 377 | 378 | // Program 379 | /// WorkingDirectory 380 | case workingDirectory = "WorkingDirectory" 381 | /// StandardInPath 382 | case standardInPath = "StandardInPath" 383 | /// StandardOutPath 384 | case standardOutPath = "StandardOutPath" 385 | /// StandardErrorPath 386 | case standardErrorPath = "StandardErrorPath" 387 | /// EnvironmentVariables 388 | case environmentVariables = "EnvironmentVariables" 389 | 390 | // Run Conditions 391 | /// RunAtLoad 392 | case runAtLoad = "RunAtLoad" 393 | /// StartInterval 394 | case startInterval = "StartInterval" 395 | /// StartCalendarInterval 396 | case startCalendarInterval = "StartCalendarInterval" 397 | /// StartOnMount 398 | case startOnMount = "StartOnMount" 399 | /// OnDemand 400 | case onDemand = "OnDemand" 401 | /// KeepAlive 402 | case keepAlive = "KeepAlive" 403 | /// WatchPaths 404 | case watchPaths = "WatchPaths" 405 | /// QueueDirectories 406 | case queueDirectories = "QueueDirectories" 407 | 408 | // Security 409 | /// Umask 410 | case umask = "Umask" 411 | /// SessionCreate 412 | case sessionCreate = "SessionCreate" 413 | /// GroupName 414 | case groupName = "GroupName" 415 | /// UserName 416 | case userName = "UserName" 417 | /// InitGroups 418 | case initGroups = "InitGroups" 419 | /// RootDirectory 420 | case rootDirectory = "RootDirectory" 421 | 422 | // Run Constriants 423 | /// LaunchOnlyOnce 424 | case launchOnlyOnce = "LaunchOnlyOnce" 425 | /// LimitLoadToSessionType 426 | case limitLoadToSessionType = "LimitLoadToSessionType" 427 | /// LimitLoadToHosts 428 | case limitLoadToHosts = "LimitLoadToHosts" 429 | /// LimitLoadFromHosts 430 | case limitLoadFromHosts = "LimitLoadFromHosts" 431 | 432 | // Control 433 | /// AbandonProcessGroup 434 | case abandonProcessGroup = "AbandonProcessGroup" 435 | /// EnablePressuredExit 436 | case enablePressuredExit = "EnablePressuredExit" 437 | /// EnableTransactions 438 | case enableTransactions = "EnableTransactions" 439 | /// ExitTimeOut 440 | case exitTimeOut = "ExitTimeOut" 441 | /// inetdCompatibility 442 | case inetdCompatibility = "inetdCompatibility" 443 | /// HardResourceLimits 444 | case hardResourceLimits = "HardResourceLimits" 445 | /// SoftResourceLimits 446 | case softResourceLimits = "SoftResourceLimits" 447 | /// TimeOut 448 | case timeOut = "TimeOut" 449 | /// ThrottleInterval 450 | case throttleInterval = "ThrottleInterval" 451 | 452 | // IPC 453 | /// MachServices 454 | case machServices = "MachServices" 455 | 456 | // Debug 457 | /// Debug 458 | case debug = "Debug" 459 | /// WaitForDebugger 460 | case waitForDebugger = "WaitForDebugger" 461 | 462 | // Performance 463 | /// LegacyTimers 464 | case legacyTimers = "LegacyTimers" 465 | /// LowPriorityIO 466 | case lowPriorityIO = "LowPriorityIO" 467 | /// LowPriorityBackgroundIO 468 | case lowPriorityBackgroundIO = "LowPriorityBackgroundIO" 469 | /// Nice 470 | case nice = "Nice" 471 | /// ProcessType 472 | case processType = "ProcessType" 473 | 474 | } 475 | 476 | } 477 | 478 | --------------------------------------------------------------------------------