feat: add robust scheduler with modern explicit permissions UI

This commit is contained in:
nimbold
2026-06-02 10:48:41 +03:30
parent 2dbb169c39
commit e920eb544d
5 changed files with 433 additions and 1 deletions
+2
View File
@@ -24,6 +24,8 @@ struct ContentView: View {
downloadsView(filter: filter)
case .queue(let queueID):
queueView(queueID: queueID)
case .scheduler:
SchedulerView()
case .settings:
SettingsView()
}
+5 -1
View File
@@ -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)
+206
View File
@@ -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()
}
}
+214
View File
@@ -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<SchedulerDay> = []
@State private var postQueueAction: PostQueueAction = .doNothing
@State private var targetQueueIDs: Set<UUID> = []
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()
}
}
+6
View File
@@ -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(