-
Notifications
You must be signed in to change notification settings - Fork 0
Add launchd-based system scheduling and UI support for system-scheduled timers #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| // | ||
| // LaunchAgentScheduler.swift | ||
| // AutoRun | ||
| // | ||
| // Created by OpenAI on 21.07.26. | ||
| // | ||
|
|
||
| import Foundation | ||
| import Darwin | ||
|
|
||
| /// Installs per-user LaunchAgent jobs so repeating timers can run even when | ||
| /// AutoRun is not actively counting down in the foreground. | ||
| enum LaunchAgentScheduler { | ||
| enum SchedulerError: Error { | ||
| case invalidInterval | ||
| case invalidLaunchValue | ||
| case unsupportedOneShotTimer | ||
| case unsupportedHomeDirectory | ||
| } | ||
|
|
||
| static func isSupported(timer: TimerItem) -> Bool { | ||
| timer.doesRepeat && timer.interval >= 1 | ||
| } | ||
|
|
||
| static func install(timer: TimerItem) throws { | ||
| guard isSupported(timer: timer) else { throw SchedulerError.unsupportedOneShotTimer } | ||
| guard timer.interval >= 1 else { throw SchedulerError.invalidInterval } | ||
|
|
||
| let launchAgentsDirectory = try launchAgentsDirectory() | ||
| try FileManager.default.createDirectory( | ||
| at: launchAgentsDirectory, | ||
| withIntermediateDirectories: true, | ||
| attributes: nil | ||
| ) | ||
|
|
||
| let plistURL = plistURL(for: timer) | ||
| let plist = try propertyList(for: timer) | ||
| let data = try PropertyListSerialization.data( | ||
| fromPropertyList: plist, | ||
| format: .xml, | ||
| options: 0 | ||
| ) | ||
| try data.write(to: plistURL, options: .atomic) | ||
|
|
||
| _ = runLaunchctl(arguments: ["bootout", "gui/\(getuid())", plistURL.path], allowFailure: true) | ||
| try runLaunchctl(arguments: ["bootstrap", "gui/\(getuid())", plistURL.path]) | ||
| try runLaunchctl(arguments: ["enable", "gui/\(getuid())/\(label(for: timer))"]) | ||
| } | ||
|
|
||
| static func uninstall(timer: TimerItem) { | ||
| let plistURL = plistURL(for: timer) | ||
| _ = runLaunchctl(arguments: ["bootout", "gui/\(getuid())", plistURL.path], allowFailure: true) | ||
| try? FileManager.default.removeItem(at: plistURL) | ||
| } | ||
|
|
||
| static func isInstalled(timer: TimerItem) -> Bool { | ||
| FileManager.default.fileExists(atPath: plistURL(for: timer).path) | ||
| } | ||
|
|
||
| static func label(for timer: TimerItem) -> String { | ||
| let identifier = timer.persistentModelID.id.description | ||
| .replacingOccurrences(of: "[^A-Za-z0-9.-]", with: "-", options: .regularExpression) | ||
| return "de.holgerkrupp.AutoRun.timer.\(identifier)" | ||
| } | ||
|
|
||
| private static func propertyList(for timer: TimerItem) throws -> [String: Any] { | ||
| let arguments = try programArguments(for: timer) | ||
| return [ | ||
| "Label": label(for: timer), | ||
| "ProgramArguments": arguments, | ||
| "StartInterval": Int(timer.interval.rounded()), | ||
| "RunAtLoad": false, | ||
| "StandardOutPath": logURL(for: timer, suffix: "out").path, | ||
| "StandardErrorPath": logURL(for: timer, suffix: "err").path | ||
| ] | ||
| } | ||
|
|
||
| private static func programArguments(for timer: TimerItem) throws -> [String] { | ||
| switch timer.launchType { | ||
| case .app: | ||
| guard let url = URL(string: timer.launchValue) else { throw SchedulerError.invalidLaunchValue } | ||
| return ["/usr/bin/open", url.path] | ||
| case .script: | ||
| guard timer.launchValue.isEmpty == false else { throw SchedulerError.invalidLaunchValue } | ||
| return ["/bin/zsh", "-c", timer.launchValue] | ||
| } | ||
| } | ||
|
|
||
| private static func launchAgentsDirectory() throws -> URL { | ||
| guard let home = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else { | ||
| throw SchedulerError.unsupportedHomeDirectory | ||
| } | ||
| return home.appendingPathComponent("LaunchAgents", isDirectory: true) | ||
| } | ||
|
|
||
| private static func plistURL(for timer: TimerItem) -> URL { | ||
| (try? launchAgentsDirectory())?.appendingPathComponent("\(label(for: timer)).plist") | ||
| ?? URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("\(label(for: timer)).plist") | ||
| } | ||
|
|
||
| private static func logURL(for timer: TimerItem, suffix: String) -> URL { | ||
| FileManager.default.temporaryDirectory.appendingPathComponent("\(label(for: timer)).\(suffix).log") | ||
| } | ||
|
|
||
| @discardableResult | ||
| private static func runLaunchctl(arguments: [String], allowFailure: Bool = false) throws -> String { | ||
| let process = Process() | ||
| let output = Pipe() | ||
| let error = Pipe() | ||
| process.executableURL = URL(fileURLWithPath: "/bin/launchctl") | ||
| process.arguments = arguments | ||
| process.standardOutput = output | ||
| process.standardError = error | ||
| try process.run() | ||
| process.waitUntilExit() | ||
|
|
||
| let outputText = String(data: output.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" | ||
| let errorText = String(data: error.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" | ||
| if process.terminationStatus != 0 && allowFailure == false { | ||
| throw NSError( | ||
| domain: "LaunchAgentScheduler", | ||
| code: Int(process.terminationStatus), | ||
| userInfo: [NSLocalizedDescriptionKey: errorText.isEmpty ? outputText : errorText] | ||
| ) | ||
| } | ||
| return outputText | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,8 @@ final class TimerItem: Codable, ObservableObject { | |
| var interval: TimeInterval = 0.0 | ||
| var doesRepeat: Bool = false | ||
| var order: Int? = 0 | ||
| var isSystemScheduleEnabled: Bool = false | ||
| var systemScheduleStartDate: Date? | ||
|
|
||
| @Transient var fileIcon: NSImage? { | ||
| if let fileString = fileName?.absoluteString{ | ||
|
|
@@ -59,6 +61,10 @@ final class TimerItem: Codable, ObservableObject { | |
|
|
||
| @Transient @Published var timer: Timer? | ||
|
|
||
| @Transient var isSystemScheduled: Bool { | ||
| isSystemScheduleEnabled && isActive && timer == nil && LaunchAgentScheduler.isSupported(timer: self) | ||
| } | ||
|
|
||
|
|
||
| @Transient var durationDescription:String { | ||
| let formatter = DateComponentsFormatter() | ||
|
|
@@ -75,7 +81,7 @@ final class TimerItem: Codable, ObservableObject { | |
| } | ||
|
|
||
| enum CodingKeys: CodingKey{ | ||
| case creationDate, name, active, fileName, fireDate, interval, doesRepeat, order, launchItem, launchType | ||
| case creationDate, name, active, fileName, fireDate, interval, doesRepeat, order, launchItem, launchType, isSystemScheduleEnabled, systemScheduleStartDate | ||
| } | ||
|
|
||
| func encode(to encoder: Encoder) throws { | ||
|
|
@@ -92,6 +98,8 @@ final class TimerItem: Codable, ObservableObject { | |
|
|
||
| try container.encode(launchValue, forKey: .launchItem) | ||
| try container.encode(launchType, forKey: .launchType) | ||
| try container.encode(isSystemScheduleEnabled, forKey: .isSystemScheduleEnabled) | ||
| try container.encode(systemScheduleStartDate, forKey: .systemScheduleStartDate) | ||
|
|
||
| } | ||
|
|
||
|
|
@@ -109,17 +117,19 @@ final class TimerItem: Codable, ObservableObject { | |
|
|
||
| doesRepeat = try container.decode(Bool.self, forKey: .doesRepeat) | ||
| order = try container.decode(Int.self, forKey: .order) | ||
| isSystemScheduleEnabled = try container.decodeIfPresent(Bool.self, forKey: .isSystemScheduleEnabled) ?? false | ||
| systemScheduleStartDate = try container.decodeIfPresent(Date.self, forKey: .systemScheduleStartDate) | ||
| } | ||
|
|
||
| func delete(){ | ||
| timer?.invalidate() | ||
| stopTimer() | ||
| if let modelContext { | ||
| modelContext.delete(self) | ||
| } | ||
| } | ||
|
|
||
| func startStop(){ | ||
| if let timer, timer.isValid == true{ | ||
| if isActive == true || timer?.isValid == true{ | ||
| stopTimer() | ||
|
|
||
| }else{ | ||
|
|
@@ -130,6 +140,9 @@ final class TimerItem: Codable, ObservableObject { | |
| func stopTimer(){ | ||
| print("timer invalidate") | ||
| timer?.invalidate() | ||
| LaunchAgentScheduler.uninstall(timer: self) | ||
| isSystemScheduleEnabled = false | ||
| systemScheduleStartDate = nil | ||
| nextFireDate = nil | ||
| isActive = false | ||
| } | ||
|
|
@@ -141,6 +154,21 @@ final class TimerItem: Codable, ObservableObject { | |
| launchValue = fileName.absoluteString | ||
| } | ||
| guard launchValue != "" else { print("error - nothing to launch "); return false } | ||
| if LaunchAgentScheduler.isSupported(timer: self) { | ||
| do { | ||
| try LaunchAgentScheduler.install(timer: self) | ||
| systemScheduleStartDate = Date() | ||
| isSystemScheduleEnabled = true | ||
| nextFireDate = nextSystemFireDate() | ||
| isActive = true | ||
| return true | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a timer previously used the in-app path (for example a one-shot timer or a launchd fallback), Useful? React with 👍 / 👎. |
||
| } catch { | ||
| print("launchd scheduling failed; falling back to in-app timer: \(error)") | ||
| isSystemScheduleEnabled = false | ||
| systemScheduleStartDate = nil | ||
| } | ||
| } | ||
|
|
||
| timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: doesRepeat) { timer in | ||
| try? self.fireTimer() | ||
|
|
||
|
|
@@ -149,6 +177,31 @@ final class TimerItem: Codable, ObservableObject { | |
| isActive = timer?.isValid ?? false | ||
| return timer?.isValid ?? false | ||
| } | ||
|
|
||
| func reconcileSystemScheduleState(){ | ||
| guard isSystemScheduleEnabled else { return } | ||
|
|
||
| if LaunchAgentScheduler.isSupported(timer: self), LaunchAgentScheduler.isInstalled(timer: self) { | ||
| timer?.invalidate() | ||
| timer = nil | ||
| isActive = true | ||
| nextFireDate = nextSystemFireDate() | ||
| } else { | ||
| isSystemScheduleEnabled = false | ||
| systemScheduleStartDate = nil | ||
| nextFireDate = nil | ||
| isActive = false | ||
| } | ||
| } | ||
|
|
||
| func nextSystemFireDate(after date: Date = Date()) -> Date? { | ||
| guard let systemScheduleStartDate, interval > 0 else { return nil } | ||
| let firstFireDate = systemScheduleStartDate.addingTimeInterval(interval) | ||
| guard firstFireDate <= date else { return firstFireDate } | ||
|
|
||
| let elapsedIntervals = floor(date.timeIntervalSince(firstFireDate) / interval) + 1 | ||
| return firstFireDate.addingTimeInterval(elapsedIntervals * interval) | ||
| } | ||
|
|
||
| func fireTimer() throws{ | ||
|
|
||
|
|
@@ -208,6 +261,13 @@ final class TimerItem: Codable, ObservableObject { | |
| func calcProgress() -> Double? { | ||
| dump(timer) | ||
| print("calculating progress") | ||
| if isActive == true && timer == nil { | ||
| guard let nextFireDate else { return nil } | ||
| let lastDate = nextFireDate.addingTimeInterval(-interval) | ||
| let elapsedTime = Date().timeIntervalSince(lastDate) | ||
| return elapsedTime/interval | ||
| } | ||
|
|
||
| if timer?.isValid == true { | ||
| guard (nextFireDate != nil) else { | ||
| print("nextDate") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With AutoRun.entitlements enabling the App Sandbox,
FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)resolves inside the app container rather than the user's~/Library. That means these plists are written under the container'sLibrary/LaunchAgents, so after a logout/reboot launchd will not auto-load them from the standard per-user LaunchAgents directory, whilereconcileSystemScheduleState()can still mark the timer active just because the container plist exists; users then see an active system-scheduled timer that never fires.Useful? React with 👍 / 👎.