diff --git a/AutoRun/CountdownView.swift b/AutoRun/CountdownView.swift index 6b4266f..f68ef64 100644 --- a/AutoRun/CountdownView.swift +++ b/AutoRun/CountdownView.swift @@ -12,6 +12,7 @@ struct CountdownView: View { //@Binding var duration:TimeInterval? var finish:Date? + var repeats: Bool = false @State var remaining:Double = 0.0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @State var remainingString:String = "" @@ -26,7 +27,7 @@ struct CountdownView: View { Text(remainingString) .onReceive(timer) { time in - remaining = finish?.timeIntervalSince(Date()) ?? 0.0 + remaining = remainingTime(until: finish, from: Date()) let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] @@ -44,6 +45,17 @@ struct CountdownView: View { return 1.0 - (remaining / duration) } + + private func remainingTime(until finish: Date?, from date: Date) -> Double { + guard let finish else { return 0.0 } + var remaining = finish.timeIntervalSince(date) + guard repeats, let duration, duration > 0 else { return remaining } + + while remaining <= 0 { + remaining += duration + } + return remaining + } } #Preview { diff --git a/AutoRun/LaunchAgentScheduler.swift b/AutoRun/LaunchAgentScheduler.swift new file mode 100644 index 0000000..17fe065 --- /dev/null +++ b/AutoRun/LaunchAgentScheduler.swift @@ -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 + } +} diff --git a/AutoRun/TimerItem.swift b/AutoRun/TimerItem.swift index be29fd1..74e3f3f 100644 --- a/AutoRun/TimerItem.swift +++ b/AutoRun/TimerItem.swift @@ -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 + } 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") diff --git a/AutoRun/TimerItemList.swift b/AutoRun/TimerItemList.swift index 9e21f68..38cbe13 100644 --- a/AutoRun/TimerItemList.swift +++ b/AutoRun/TimerItemList.swift @@ -28,6 +28,7 @@ struct TimerItemList: View { TimerSummaryView(timer: timer, isActive: timer.timer?.isValid ?? false) .onAppear(){ + timer.reconcileSystemScheduleState() if timer.launchType == .app { // Version 1.0 was saving the file to launch in the timer.launchValue = timer.fileName?.absoluteString ?? "" diff --git a/AutoRun/TimerSummaryView.swift b/AutoRun/TimerSummaryView.swift index 70f87fe..b0783c1 100644 --- a/AutoRun/TimerSummaryView.swift +++ b/AutoRun/TimerSummaryView.swift @@ -24,6 +24,7 @@ struct TimerSummaryView: View { @ObservedObject var timer:TimerItem @Binding var isActive:Bool @State private var maxWidth: CGFloat = .zero + private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack{ @@ -76,12 +77,17 @@ struct TimerSummaryView: View { if $isActive.wrappedValue == true{ if timer.doesRepeat{ Text("App is launched every \(timer.durationDescription)") + if timer.isSystemScheduled { + Text("Scheduled by macOS launchd") + .font(.caption) + .foregroundStyle(.secondary) + } }else{ } if let fireDate = timer.nextFireDate{ - CountdownView(duration:timer.interval, finish: fireDate) + CountdownView(duration:timer.interval, finish: fireDate, repeats: timer.isSystemScheduled) .help( Text("Next run: \(fireDate.formatted(date: .abbreviated, time: .standard))") ) @@ -127,6 +133,13 @@ struct TimerSummaryView: View { isActive = active } + .onReceive(clock) { date in + guard timer.isSystemScheduled, + let nextFireDate = timer.nextFireDate, + nextFireDate <= date else { return } + + timer.nextFireDate = timer.nextSystemFireDate(after: date) + } } private func rectReader(_ binding: Binding) -> some View { diff --git a/README.md b/README.md index 2d283fc..ffc7c0a 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,8 @@ This Menu Bar App helps you open programms regularly. it is designed to run apps the app is now available in the [Mac App Store](https://apps.apple.com/de/app/autorun-run-apps/id6739644500?l=en-GB&mt=12) Mac App Store + + +## Scheduling + +AutoRun uses macOS LaunchAgents (`launchd`) for repeating timers when possible. This lets macOS own the recurring schedule instead of relying on an in-app countdown timer. One-shot timers still use AutoRun's in-app timer because `launchd` is designed around persistent jobs rather than single relative countdowns.