Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion AutoRun/CountdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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]
Expand All @@ -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 {
Expand Down
128 changes: 128 additions & 0 deletions AutoRun/LaunchAgentScheduler.swift
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a real per-user LaunchAgents location

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's Library/LaunchAgents, so after a logout/reboot launchd will not auto-load them from the standard per-user LaunchAgents directory, while reconcileSystemScheduleState() 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 👍 / 👎.

}

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
}
}
66 changes: 63 additions & 3 deletions AutoRun/TimerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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)

}

Expand All @@ -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{
Expand All @@ -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
}
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stale in-app timers after launchd install

When a timer previously used the in-app path (for example a one-shot timer or a launchd fallback), stopTimer() only invalidates the Timer and leaves the non-nil object in timer. This success path returns without clearing it, but isSystemScheduled requires timer == nil, so the UI will not show the launchd state and the clock handler will not advance nextFireDate even though the LaunchAgent was installed.

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()

Expand All @@ -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{

Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions AutoRun/TimerItemList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ""
Expand Down
15 changes: 14 additions & 1 deletion AutoRun/TimerSummaryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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))")
)
Expand Down Expand Up @@ -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<CGFloat>) -> some View {
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<a href="https://apps.apple.com/de/app/autorun-run-apps/id6739644500?l=en-GB&mt=12"><img src="mac-app-store-badge.svg" alt="Mac App Store" height="50"/></a>


## 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.