From e920eb544dabbd011c69920ba12382dce202f456 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:48:41 +0330 Subject: [PATCH] feat: add robust scheduler with modern explicit permissions UI --- Sources/Firelink/ContentView.swift | 2 + Sources/Firelink/FirelinkApp.swift | 6 +- Sources/Firelink/SchedulerController.swift | 206 ++++++++++++++++++++ Sources/Firelink/SchedulerView.swift | 214 +++++++++++++++++++++ Sources/Firelink/SidebarView.swift | 6 + 5 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 Sources/Firelink/SchedulerController.swift create mode 100644 Sources/Firelink/SchedulerView.swift diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 25007e2..e3a5a8d 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -24,6 +24,8 @@ struct ContentView: View { downloadsView(filter: filter) case .queue(let queueID): queueView(queueID: queueID) + case .scheduler: + SchedulerView() case .settings: SettingsView() } diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index 90c7fed..d8ae891 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -4,11 +4,14 @@ import SwiftUI struct FirelinkApp: App { @StateObject private var settings: AppSettings @StateObject private var controller: DownloadController + @StateObject private var schedulerController: SchedulerController init() { let settings = AppSettings() + let controller = DownloadController(settings: settings) _settings = StateObject(wrappedValue: settings) - _controller = StateObject(wrappedValue: DownloadController(settings: settings)) + _controller = StateObject(wrappedValue: controller) + _schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller)) } var body: some Scene { @@ -16,6 +19,7 @@ struct FirelinkApp: App { ContentView() .environmentObject(controller) .environmentObject(settings) + .environmentObject(schedulerController) .frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760) } .windowStyle(.titleBar) diff --git a/Sources/Firelink/SchedulerController.swift b/Sources/Firelink/SchedulerController.swift new file mode 100644 index 0000000..2c09a69 --- /dev/null +++ b/Sources/Firelink/SchedulerController.swift @@ -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 = Set(SchedulerDay.allCases) + var postQueueAction: PostQueueAction = .doNothing + var targetQueueIDs: Set = [] +} + +@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() + 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() + } +} diff --git a/Sources/Firelink/SchedulerView.swift b/Sources/Firelink/SchedulerView.swift new file mode 100644 index 0000000..fbb47d3 --- /dev/null +++ b/Sources/Firelink/SchedulerView.swift @@ -0,0 +1,214 @@ +import SwiftUI + +struct SchedulerView: View { + @EnvironmentObject private var downloadController: DownloadController + @EnvironmentObject private var schedulerController: SchedulerController + @State private var showSaveToast: Bool = false + + // Local state to hold edits before saving + @State private var isEnabled: Bool = false + @State private var startTime: Date = Date() + @State private var isEveryday: Bool = true + @State private var selectedDays: Set = [] + @State private var postQueueAction: PostQueueAction = .doNothing + @State private var targetQueueIDs: Set = [] + + var body: some View { + VStack(spacing: 0) { + headerView + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + timeSelectionSection + queueSelectionSection + postActionSection + + Divider() + permissionsSection + } + .padding(24) + } + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(isEnabled ? 1.0 : 0.5) + .disabled(!isEnabled) + } + .onAppear { + loadState() + schedulerController.checkAutomationPermission() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + schedulerController.checkAutomationPermission() + } + .overlay { + if showSaveToast { + toastView + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + + private var headerView: some View { + HStack { + Toggle(isOn: $isEnabled) { + Text("Scheduler") + .font(.title2.weight(.bold)) + } + .toggleStyle(.switch) + + Spacer() + + Button("Save Settings") { + saveState() + withAnimation(.spring()) { + showSaveToast = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + withAnimation { + showSaveToast = false + } + } + } + .buttonStyle(.borderedProminent) + } + .padding(24) + } + + private var timeSelectionSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Start Time") + .font(.headline) + + DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute]) + .datePickerStyle(.stepperField) + .labelsHidden() + + Toggle("Everyday", isOn: $isEveryday) + + if !isEveryday { + HStack(spacing: 12) { + ForEach(SchedulerDay.allCases) { day in + Toggle(day.shortName, isOn: Binding( + get: { selectedDays.contains(day) }, + set: { isSelected in + if isSelected { + selectedDays.insert(day) + } else { + selectedDays.remove(day) + } + } + )) + .toggleStyle(.button) + } + } + } + } + } + + private var queueSelectionSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Queues to Start") + .font(.headline) + + if downloadController.queues.isEmpty { + Text("No queues available") + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(downloadController.queues) { queue in + Toggle(queue.name, isOn: Binding( + get: { targetQueueIDs.contains(queue.id) }, + set: { isSelected in + if isSelected { + targetQueueIDs.insert(queue.id) + } else { + targetQueueIDs.remove(queue.id) + } + } + )) + } + } + } + } + } + + private var postActionSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("After Completion") + .font(.headline) + + Picker("Action", selection: $postQueueAction) { + ForEach(PostQueueAction.allCases) { action in + Text(action.rawValue).tag(action) + } + } + .labelsHidden() + .pickerStyle(.radioGroup) + } + } + + private var permissionsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("System Permissions") + .font(.headline) + + Text("Firelink needs permission to control System Events in order to automatically sleep, restart, or shut down your Mac after downloads finish.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if schedulerController.hasAutomationPermission { + Button("Revoke Permission") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(.bordered) + } else { + Button("Grant Permission") { + schedulerController.requestAutomationPermission() + } + .buttonStyle(.borderedProminent) + } + } + } + + private var toastView: some View { + VStack { + Spacer() + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Settings Saved") + .fontWeight(.medium) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial) + .clipShape(Capsule()) + .shadow(radius: 4, y: 2) + .padding(.bottom, 30) + } + .allowsHitTesting(false) + } + + private func loadState() { + isEnabled = schedulerController.settings.isEnabled + startTime = schedulerController.settings.startTime + isEveryday = schedulerController.settings.isEveryday + selectedDays = schedulerController.settings.selectedDays + postQueueAction = schedulerController.settings.postQueueAction + targetQueueIDs = schedulerController.settings.targetQueueIDs + } + + private func saveState() { + schedulerController.settings.isEnabled = isEnabled + schedulerController.settings.startTime = startTime + schedulerController.settings.isEveryday = isEveryday + schedulerController.settings.selectedDays = selectedDays + schedulerController.settings.postQueueAction = postQueueAction + schedulerController.settings.targetQueueIDs = targetQueueIDs + schedulerController.saveSettings() + } +} diff --git a/Sources/Firelink/SidebarView.swift b/Sources/Firelink/SidebarView.swift index f0cf9cf..6f5593d 100644 --- a/Sources/Firelink/SidebarView.swift +++ b/Sources/Firelink/SidebarView.swift @@ -24,6 +24,7 @@ enum DownloadSidebarFilter: Hashable { enum SidebarSelection: Hashable { case downloads(DownloadSidebarFilter) case queue(UUID) + case scheduler case settings } @@ -70,6 +71,11 @@ struct SidebarView: View { } .buttonStyle(.plain) } + + Section("Tools") { + Label("Scheduler", systemImage: "calendar.badge.clock") + .tag(SidebarSelection.scheduler) + } } .listStyle(.sidebar) .alert("Rename Queue", isPresented: Binding(