mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 10:49:29 +00:00
feat: add robust scheduler with modern explicit permissions UI
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum PostQueueAction: String, Codable, CaseIterable, Identifiable {
|
||||
case doNothing = "Do nothing"
|
||||
case sleep = "Sleep"
|
||||
case restart = "Restart"
|
||||
case shutdown = "Shut down"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
|
||||
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var shortName: String {
|
||||
switch self {
|
||||
case .sunday: "S"
|
||||
case .monday: "M"
|
||||
case .tuesday: "T"
|
||||
case .wednesday: "W"
|
||||
case .thursday: "T"
|
||||
case .friday: "F"
|
||||
case .saturday: "S"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SchedulerSettings: Codable, Equatable {
|
||||
var isEnabled: Bool = false
|
||||
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
var isEveryday: Bool = true
|
||||
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
|
||||
var postQueueAction: PostQueueAction = .doNothing
|
||||
var targetQueueIDs: Set<UUID> = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SchedulerController: ObservableObject {
|
||||
@Published var settings: SchedulerSettings
|
||||
@Published var isRunning: Bool = false
|
||||
@Published var hasAutomationPermission: Bool = false
|
||||
private let downloadController: DownloadController
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var timer: Timer?
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "Firelink.SchedulerSettings.v1"
|
||||
|
||||
// We only trigger once per minute to prevent multiple triggers in the same minute
|
||||
private var lastTriggeredMinute: Date?
|
||||
|
||||
init(downloadController: DownloadController) {
|
||||
self.downloadController = downloadController
|
||||
|
||||
if let data = defaults.data(forKey: "Firelink.SchedulerSettings.v1"),
|
||||
let stored = try? JSONDecoder().decode(SchedulerSettings.self, from: data) {
|
||||
self.settings = stored
|
||||
} else {
|
||||
self.settings = SchedulerSettings()
|
||||
}
|
||||
|
||||
checkAutomationPermission()
|
||||
startTimer()
|
||||
|
||||
$settings
|
||||
.dropFirst()
|
||||
.sink { _ in
|
||||
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe downloads to check if we should trigger post-action
|
||||
downloadController.$downloads
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.checkIfRunningFinished()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func saveSettings() {
|
||||
if let data = try? JSONEncoder().encode(settings) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.checkSchedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkSchedule() {
|
||||
guard settings.isEnabled else { return }
|
||||
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Check if we already triggered in this exact minute
|
||||
if let last = lastTriggeredMinute, calendar.isDate(last, equalTo: now, toGranularity: .minute) {
|
||||
return
|
||||
}
|
||||
|
||||
let startHour = calendar.component(.hour, from: settings.startTime)
|
||||
let startMinute = calendar.component(.minute, from: settings.startTime)
|
||||
|
||||
let currentHour = calendar.component(.hour, from: now)
|
||||
let currentMinute = calendar.component(.minute, from: now)
|
||||
let currentWeekday = calendar.component(.weekday, from: now)
|
||||
|
||||
if startHour == currentHour && startMinute == currentMinute {
|
||||
let shouldRun: Bool
|
||||
if settings.isEveryday {
|
||||
shouldRun = true
|
||||
} else {
|
||||
let day = SchedulerDay(rawValue: currentWeekday)
|
||||
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
|
||||
}
|
||||
|
||||
if shouldRun {
|
||||
lastTriggeredMinute = now
|
||||
triggerQueues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerQueues() {
|
||||
guard !settings.targetQueueIDs.isEmpty else { return }
|
||||
|
||||
isRunning = true
|
||||
|
||||
for queueID in settings.targetQueueIDs {
|
||||
// Check if queue still exists
|
||||
if downloadController.queues.contains(where: { $0.id == queueID }) {
|
||||
downloadController.startQueue(queueID: queueID)
|
||||
}
|
||||
}
|
||||
|
||||
checkIfRunningFinished()
|
||||
}
|
||||
|
||||
private func checkIfRunningFinished() {
|
||||
guard isRunning else { return }
|
||||
|
||||
// A queue is finished if there are no items in .queued or .downloading state for that queue.
|
||||
// Wait, what if the queue is empty? We don't trigger anything.
|
||||
var hasActiveItems = false
|
||||
for item in downloadController.downloads {
|
||||
if let qid = item.queueID, settings.targetQueueIDs.contains(qid) {
|
||||
if item.status == .queued || item.status == .downloading {
|
||||
hasActiveItems = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasActiveItems {
|
||||
isRunning = false
|
||||
performPostAction()
|
||||
}
|
||||
}
|
||||
|
||||
private func performPostAction() {
|
||||
guard settings.postQueueAction != .doNothing else { return }
|
||||
|
||||
var scriptCode = ""
|
||||
switch settings.postQueueAction {
|
||||
case .sleep:
|
||||
scriptCode = "tell application \"Finder\" to sleep"
|
||||
case .restart:
|
||||
scriptCode = "tell application \"Finder\" to restart"
|
||||
case .shutdown:
|
||||
scriptCode = "tell application \"Finder\" to shut down"
|
||||
case .doNothing:
|
||||
break
|
||||
}
|
||||
|
||||
guard !scriptCode.isEmpty else { return }
|
||||
|
||||
var error: NSDictionary?
|
||||
if let script = NSAppleScript(source: scriptCode) {
|
||||
script.executeAndReturnError(&error)
|
||||
if let error {
|
||||
print("Failed to perform scheduler post action: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAutomationPermission() {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
|
||||
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, false)
|
||||
hasAutomationPermission = (status == noErr)
|
||||
}
|
||||
|
||||
func requestAutomationPermission() {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
|
||||
_ = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
|
||||
checkAutomationPermission()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user