Skip to content

Conversation

Copy link

Copilot AI commented Oct 15, 2025

  • Review current GlobalTimer.swift implementation
  • Replace GlobalTimer.swift with the new implementation that includes:
    • Per-schedule tolerance with proper clamping (at most half the interval)
    • Safe execution using snapshot of due items
    • Wake debounce of ~3 seconds
    • Proper actor isolation for Swift 6
  • Verify the changes don't break existing callers (ObservableFutureSchedules.swift)
  • Build the project to ensure no compilation errors
  • Validate that timer and schedules remain properly isolated
Original prompt

Refactor GlobalTimer to add per‑schedule tolerance, safe iteration during callbacks, and wake debounce (Swift 6).

Context
The project already contains a GlobalTimer implementation used by the macOS app target. The current version runs a one‑shot foreground timer and reacts on system wake. We want to keep this simpler approach (no NSBackgroundActivityScheduler), but make it safer and a bit more precise.

Required changes

  1. Locate GlobalTimer.swift in the repository (search by filename). If multiple copies exist, update the one used by the macOS app target. Replace its contents with the implementation below.
  2. Keep GlobalTimer as @observable @mainactor public final class with a singleton.
  3. Per‑schedule tolerance: add a tolerance parameter to addSchedule(profile:time:tolerance:callback:) and store it with each scheduled item. If nil is provided, compute a default tolerance of ~10% of the interval clamped to [1s, 60s], then further clamp to at most half of the remaining interval at scheduling time.
  4. Safe execution: when executing due schedules, first snapshot the due items to avoid mutating the schedules dictionary while iterating (because callbacks may schedule/remove items).
  5. One‑shot timer: always schedule a single timer for the nearest due item using its tolerance; when the timer fires, execute all items that are now due in one pass, then reschedule the next timer if any remain.
  6. Wake handling: register for NSWorkspace.didWakeNotification. After wake, debounce by ~3 seconds, then run the due‑check and reschedule the next timer. Ensure all @mainactor calls from nonisolated contexts hop via Task { @mainactor in ... }.
  7. Keep timer private and ignored by Observation. Schedules remain internal. No other files should change.
  8. Swift 6/macOS 13+: ensure actor‑isolation rules are respected.

File to replace
Paste the following full content into GlobalTimer.swift.

import AppKit
import Foundation
import Observation
import OSLog

@Observable
@MainActor
public final class GlobalTimer {

    public static let shared = GlobalTimer()

    // MARK: - Types

    private struct ScheduledItem {
        let time: Date
        let tolerance: TimeInterval
        let callback: () -> Void
    }

    // MARK: - Properties

    /// Foreground one-shot timer for the nearest due schedule.
    @ObservationIgnored
    private var timer: Timer?

    /// Observer token for system wake notifications.
    @ObservationIgnored
    private var wakeObserver: NSObjectProtocol?

    /// All scheduled tasks keyed by profile name.
    private var schedules: [String: ScheduledItem] = [:]

    // MARK: - Initialization

    private init() {
        setupWakeNotification()
    }

    // MARK: - Public API

    /// Schedule a task for a profile to run at an exact time.
    /// - Parameters:
    ///   - profile: Profile identifier (defaults to "Default").
    ///   - time: Target execution time.
    ///   - tolerance: Timing tolerance in seconds. Pass 0 for maximum precision.
    ///                Defaults to ~10% of interval (min 1s, max 60s) for power efficiency.
    ///   - callback: Closure executed when due.
    public func addSchedule(
        profile: String = "Default",
        time: Date,
        tolerance: TimeInterval? = nil,
        callback: @escaping () -> Void
    ) {
        Logger.process.info("GlobalTimer: Adding schedule for '\(profile, privacy: .public)' at \(time, privacy: .public)")

        let resolvedTolerance = resolveToleranceForSchedule(requested: tolerance, time: time)

        // Store or replace the schedule.
        schedules[profile] = ScheduledItem(
            time: time,
            tolerance: resolvedTolerance,
            callback: callback
        )

        // Refresh the foreground timer to account for new schedule.
        scheduleNextForegroundTimer()
    }

    /// Remove an existing schedule for a given profile, if any.
    public func removeSchedule(profile: String) {
        guard schedules.removeValue(forKey: profile) != nil else {
            Logger.process.info("GlobalTimer: No schedule to remove for '\(profile, privacy: .public)'")
            return
        }

        Logger.process.info("GlobalTimer: Removed schedule for '\(profile, privacy: .public)'")
        scheduleNextForegroundTimer()
    }

    /// Clears all scheduled tasks and invalidates the timer.
    public func clearSchedules() {
        guard !schedules.isEmpty else {
            Logger.process.info("GlobalTimer: No schedules to clear")
            return
        }

        Logger.process.info("GlobalTimer: Clearing all schedules")
        schedules.removeAll()
        timer?.invalidate()
        timer = nil
    }

    /// True when the one-shot timer is active.
    public func timerIsActive() -> Bool {
        timer != nil
    }

    /// Returns the date of the next scheduled task as a formatted string, or nil if no schedules exist.
    public func nextScheduleDate(format: Date.FormatS...

</details>

*This pull request was created as a result of the following prompt from Copilot chat.*
> Refactor GlobalTimer to add per‑schedule tolerance, safe iteration during callbacks, and wake debounce (Swift 6).
> 
> Context
> The project already contains a GlobalTimer implementation used by the macOS app target. The current version runs a one‑shot foreground timer and reacts on system wake. We want to keep this simpler approach (no NSBackgroundActivityScheduler), but make it safer and a bit more precise.
> 
> Required changes
> 1) Locate GlobalTimer.swift in the repository (search by filename). If multiple copies exist, update the one used by the macOS app target. Replace its contents with the implementation below.
> 2) Keep GlobalTimer as @Observable @MainActor public final class with a singleton.
> 3) Per‑schedule tolerance: add a tolerance parameter to addSchedule(profile:time:tolerance:callback:) and store it with each scheduled item. If nil is provided, compute a default tolerance of ~10% of the interval clamped to [1s, 60s], then further clamp to at most half of the remaining interval at scheduling time.
> 4) Safe execution: when executing due schedules, first snapshot the due items to avoid mutating the schedules dictionary while iterating (because callbacks may schedule/remove items).
> 5) One‑shot timer: always schedule a single timer for the nearest due item using its tolerance; when the timer fires, execute all items that are now due in one pass, then reschedule the next timer if any remain.
> 6) Wake handling: register for NSWorkspace.didWakeNotification. After wake, debounce by ~3 seconds, then run the due‑check and reschedule the next timer. Ensure all @MainActor calls from nonisolated contexts hop via Task { @MainActor in ... }.
> 7) Keep timer private and ignored by Observation. Schedules remain internal. No other files should change.
> 8) Swift 6/macOS 13+: ensure actor‑isolation rules are respected.
> 
> File to replace
> Paste the following full content into GlobalTimer.swift.
> 
> ```swift name=GlobalTimer.swift
> import AppKit
> import Foundation
> import Observation
> import OSLog
> 
> @Observable
> @MainActor
> public final class GlobalTimer {
> 
>     public static let shared = GlobalTimer()
> 
>     // MARK: - Types
> 
>     private struct ScheduledItem {
>         let time: Date
>         let tolerance: TimeInterval
>         let callback: () -> Void
>     }
> 
>     // MARK: - Properties
> 
>     /// Foreground one-shot timer for the nearest due schedule.
>     @ObservationIgnored
>     private var timer: Timer?
> 
>     /// Observer token for system wake notifications.
>     @ObservationIgnored
>     private var wakeObserver: NSObjectProtocol?
> 
>     /// All scheduled tasks keyed by profile name.
>     private var schedules: [String: ScheduledItem] = [:]
> 
>     // MARK: - Initialization
> 
>     private init() {
>         setupWakeNotification()
>     }
> 
>     // MARK: - Public API
> 
>     /// Schedule a task for a profile to run at an exact time.
>     /// - Parameters:
>     ///   - profile: Profile identifier (defaults to "Default").
>     ///   - time: Target execution time.
>     ///   - tolerance: Timing tolerance in seconds. Pass 0 for maximum precision.
>     ///                Defaults to ~10% of interval (min 1s, max 60s) for power efficiency.
>     ///   - callback: Closure executed when due.
>     public func addSchedule(
>         profile: String = "Default",
>         time: Date,
>         tolerance: TimeInterval? = nil,
>         callback: @escaping () -> Void
>     ) {
>         Logger.process.info("GlobalTimer: Adding schedule for '\(profile, privacy: .public)' at \(time, privacy: .public)")
> 
>         let resolvedTolerance = resolveToleranceForSchedule(requested: tolerance, time: time)
> 
>         // Store or replace the schedule.
>         schedules[profile] = ScheduledItem(
>             time: time,
>             tolerance: resolvedTolerance,
>             callback: callback
>         )
> 
>         // Refresh the foreground timer to account for new schedule.
>         scheduleNextForegroundTimer()
>     }
> 
>     /// Remove an existing schedule for a given profile, if any.
>     public func removeSchedule(profile: String) {
>         guard schedules.removeValue(forKey: profile) != nil else {
>             Logger.process.info("GlobalTimer: No schedule to remove for '\(profile, privacy: .public)'")
>             return
>         }
> 
>         Logger.process.info("GlobalTimer: Removed schedule for '\(profile, privacy: .public)'")
>         scheduleNextForegroundTimer()
>     }
> 
>     /// Clears all scheduled tasks and invalidates the timer.
>     public func clearSchedules() {
>         guard !schedules.isEmpty else {
>             Logger.process.info("GlobalTimer: No schedules to clear")
>             return
>         }
> 
>         Logger.process.info("GlobalTimer: Clearing all schedules")
>         schedules.removeAll()
>         timer?.invalidate()
>         timer = nil
>     }
> 
>     /// True when the one-shot timer is active.
>     public func timerIsActive() -> Bool {
>         timer != nil
>     }
> 
>     /// Returns the date of the next scheduled task as a formatted string, or nil if no schedules exist.
>     public func nextScheduleDate(format: Date.FormatStyle = .dateTime) -> String? {
>         guard let nextItem = schedules.values.min(by: { $0.time < $1.time }) else {
>             return nil
>         }
>         return nextItem.time.formatted(format)
>     }
> 
>     /// Call on app termination to clean up observers and timers.
>     public func cleanup() {
>         if let observer = wakeObserver {
>             Logger.process.info("GlobalTimer: Removing wake notification observer")
>             NSWorkspace.shared.notificationCenter.removeObserver(observer)
>             wakeObserver = nil
>         }
>         timer?.invalidate()
>         timer = nil
>         schedules.removeAll()
>     }
> 
>     // MARK: - Private
> 
>     /// Schedules a one-shot foreground timer for the nearest due schedule.
>     /// If already due, executes all due items immediately and reschedules.
>     private func scheduleNextForegroundTimer() {
>         // Always invalidate any existing timer before scheduling a new one.
>         timer?.invalidate()
>         timer = nil
> 
>         guard let (nextProfile, nextItem) = schedules.min(by: { $0.value.time < $1.value.time }) else {
>             Logger.process.info("GlobalTimer: No schedules remaining")
>             return
>         }
> 
>         let now = Date.now
>         let interval = nextItem.time.timeIntervalSince(now)
> 
>         // If already due, execute all due schedules now to avoid tight loops.
>         if interval <= 0 {
>             Logger.process.info("GlobalTimer: Next schedule already due, executing all due schedules now")
>             checkAndExecuteDueSchedules()
>             if !schedules.isEmpty {
>                 scheduleNextForegroundTimer()
>             }
>             return
>         }
> 
>         // Use per-schedule tolerance, bounded to half the remaining interval.
>         let boundedTolerance = min(nextItem.tolerance, max(0, interval / 2))
> 
>         Logger.process.info(
>             "GlobalTimer: Scheduling timer for '\(nextProfile, privacy: .public)' in \(interval, privacy: .public)s (tolerance \(boundedTolerance, privacy: .public)s)"
>         )
> 
>         let t = Timer(timeInterval: interval, repeats: false) { [weak self] _ in
>             Task { @MainActor in
>                 self?.checkAndExecuteDueSchedules()
>             }
>         }
>         t.tolerance = boundedTolerance
>         RunLoop.main.add(t, forMode: .common)
>         timer = t
>     }
> 
>     /// Checks all schedules and executes any that are due.
>     private func checkAndExecuteDueSchedules() {
>         let now = Date.now
> 
>         // Snapshot due items first to avoid mutating while iterating the dictionary
>         let due: [(profile: String, item: ScheduledItem)] = schedules
>             .filter { _, item in now >= item.time }
>             .map { (key: String, value: ScheduledItem) in (profile: key, item: value) }
> 
>         guard !due.isEmpty else { return }
> 
>         // Execute callbacks
>         for (profile, item) in due {
>             Logger.process.info("GlobalTimer: Executing schedule for '\(profile, privacy: .public)'")
>             executeSchedule(profile: profile, item: item)
>         }
> 
>         // Remove executed schedules (ok if callback already removed one)
>         for (profile, _) in due {
>             schedules.removeValue(forKey: profile)
>         }
> 
>         // Schedule next timer if schedules remain.
>         if !schedules.isEmpty {
>             scheduleNextForegroundTimer()
>         }
>     }
> 
>     /// Executes a schedule's callback.
>     private func executeSchedule(profile: String, item: ScheduledItem) {
>         item.callback()
>     }
> 
>     // MARK: - Wake handling
> 
>     private func setupWakeNotification() {
>         guard wakeObserver == nil else { return }
> 
>         wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
>             forName: NSWorkspace.didWakeNotification,
>             object: nil,
>             queue: .main
>         ) { [weak self] _ in
>             // Debounce a bit after wake to let the system settle.
>             DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
>                 guard let self else { return }
>                 Logger.process.info("GlobalTimer: After wake, checking for missed schedules")
>                 self.checkAndExecuteDueSchedules()
>                 if !self.schedules.isEmpty {
>                     self.scheduleNextForegroundTimer()
>                 }
>             }
>         }
> 
>         Logger.process.info("GlobalTimer: Wake notification observer registered")
>     }
> 
>     // MARK: - Tolerance calculation
> 
>     /// Resolves the tolerance to use for a schedule.
>     private func resolveToleranceForSchedule(requested: TimeInterval?, time: Date) -> TimeInterval {
>         // If explicitly requested, use it (clamped to non-negative).
>         if let requested = requested { return max(0, requested) }
> 
>         // Otherwise calculate a reasonable default based on time until execution.
>         let interval = time.timeIntervalSince(Date.now)
>         guard interval > 0 else { return 0 }
> 
>         // Default: 10% of interval, capped between 1s and 60s, and at most half the interval.
>         let defaultTolerance = min(60, max(1, interval * 0.1))
>         return min(defaultTolerance, max(0, interval / 2))
>     }
> }
> ```

<!-- START COPILOT CODING AGENT TIPS -->
---

✨ Let Copilot coding agent [set things up for you](https://github.com/rsyncOSX/RsyncUI/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.

@rsyncOSX rsyncOSX marked this pull request as ready for review October 15, 2025 13:03
@rsyncOSX rsyncOSX merged commit bfbba6e into globaltimer Oct 15, 2025
1 check failed
Copilot AI requested a review from rsyncOSX October 15, 2025 13:04
@rsyncOSX rsyncOSX deleted the copilot/refactor-globaltimer-implementation branch October 15, 2025 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants