From 3b695169d6e6429e49c163b4d9ac4c97801ac212 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 10 Jun 2026 12:40:31 +0330 Subject: [PATCH] feat(ui): add stop time to scheduler and unit picker to speed limiter This commit overhauls the UI of the Scheduler and Speed Limiter tools using modern GroupBox designs. It also adds a highly-requested Stop Time feature to the scheduler to pause queues, and introduces a dynamic unit selection (KB/s, MB/s) to the Speed Limiter. --- Sources/Firelink/SchedulerController.swift | 37 ++++- Sources/Firelink/SchedulerView.swift | 185 +++++++++++++-------- Sources/Firelink/SpeedLimiterView.swift | 97 +++++++---- 3 files changed, 217 insertions(+), 102 deletions(-) diff --git a/Sources/Firelink/SchedulerController.swift b/Sources/Firelink/SchedulerController.swift index 588b2f1..12ce84c 100644 --- a/Sources/Firelink/SchedulerController.swift +++ b/Sources/Firelink/SchedulerController.swift @@ -32,6 +32,8 @@ enum SchedulerDay: Int, Codable, CaseIterable, Identifiable { struct SchedulerSettings: Codable, Equatable { var isEnabled: Bool = false var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date() + var stopTimeEnabled: Bool = false + var stopTime: Date = Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: Date()) ?? Date() var isEveryday: Bool = true var selectedDays: Set = Set(SchedulerDay.allCases) var postQueueAction: PostQueueAction = .doNothing @@ -117,19 +119,36 @@ final class SchedulerController: ObservableObject { 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 - } + let shouldRunToday: Bool + if settings.isEveryday { + shouldRunToday = true + } else { + let day = SchedulerDay(rawValue: currentWeekday) + shouldRunToday = day.map { settings.selectedDays.contains($0) } ?? false + } - if shouldRun { + if shouldRunToday { + if startHour == currentHour && startMinute == currentMinute { lastTriggeredMinute = now triggerQueues() } + + if settings.stopTimeEnabled { + let stopHour = calendar.component(.hour, from: settings.stopTime) + let stopMinute = calendar.component(.minute, from: settings.stopTime) + + if stopHour == currentHour && stopMinute == currentMinute { + lastTriggeredMinute = now + pauseQueues() + } + } + } + } + + private func pauseQueues() { + let targetQueueIDs = effectiveTargetQueueIDs() + for queueID in targetQueueIDs { + downloadController.pauseActiveDownloads(queueID: queueID) } } diff --git a/Sources/Firelink/SchedulerView.swift b/Sources/Firelink/SchedulerView.swift index e9ff851..94011c9 100644 --- a/Sources/Firelink/SchedulerView.swift +++ b/Sources/Firelink/SchedulerView.swift @@ -8,6 +8,8 @@ struct SchedulerView: View { // Local state to hold edits before saving @State private var isEnabled: Bool = false @State private var startTime: Date = Date() + @State private var stopTimeEnabled: Bool = false + @State private var stopTime: Date = Date() @State private var isEveryday: Bool = true @State private var selectedDays: Set = [] @State private var postQueueAction: PostQueueAction = .doNothing @@ -19,8 +21,8 @@ struct SchedulerView: View { Divider() ScrollView { - VStack(alignment: .leading, spacing: 24) { - VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 20) { + Group { timeSelectionSection queueSelectionSection postActionSection @@ -28,7 +30,6 @@ struct SchedulerView: View { .opacity(isEnabled ? 1.0 : 0.5) .disabled(!isEnabled) - Divider() permissionsSection } .padding(24) @@ -78,99 +79,149 @@ struct SchedulerView: View { } private var timeSelectionSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Start Time") - .font(.headline) + GroupBox { + VStack(alignment: .leading, spacing: 16) { + HStack { + Label("Timing", systemImage: "clock") + .font(.headline) + Spacer() + } - DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute]) - .datePickerStyle(.stepperField) - .labelsHidden() + HStack(spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("Start Time") + .foregroundStyle(.secondary) + DatePicker("Start", selection: $startTime, displayedComponents: [.hourAndMinute]) + .datePickerStyle(.stepperField) + .labelsHidden() + } - Toggle("Everyday", isOn: $isEveryday) + VStack(alignment: .leading, spacing: 8) { + Toggle("Stop Time", isOn: $stopTimeEnabled) + .foregroundStyle(.secondary) + DatePicker("Stop", selection: $stopTime, displayedComponents: [.hourAndMinute]) + .datePickerStyle(.stepperField) + .labelsHidden() + .disabled(!stopTimeEnabled) + .opacity(stopTimeEnabled ? 1.0 : 0.5) + } + } - 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 { + Divider() + + Toggle("Run Every Day", isOn: $isEveryday) + + if !isEveryday { + HStack(spacing: 8) { + ForEach(SchedulerDay.allCases) { day in + Button(action: { + if selectedDays.contains(day) { selectedDays.remove(day) + } else { + selectedDays.insert(day) } + }) { + Text(day.shortName) + .fontWeight(.medium) + .frame(width: 28, height: 28) } - )) - .toggleStyle(.button) + .buttonStyle(.borderless) + .background(selectedDays.contains(day) ? Color.accentColor : Color(nsColor: .controlBackgroundColor)) + .foregroundStyle(selectedDays.contains(day) ? Color.white : Color.primary) + .clipShape(Circle()) + } } } } + .padding(8) } } private var queueSelectionSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Queues to Start") - .font(.headline) + GroupBox { + VStack(alignment: .leading, spacing: 16) { + HStack { + Label("Queues to Schedule", systemImage: "list.bullet.rectangle") + .font(.headline) + Spacer() + } - 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) + if downloadController.queues.isEmpty { + Text("No queues available") + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 12) { + 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) + } } - } - )) + )) + } } } } + .padding(8) } } 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) + GroupBox { + VStack(alignment: .leading, spacing: 16) { + HStack { + Label("After Completion", systemImage: "powersleep") + .font(.headline) + Spacer() } + + Text("Choose what happens after scheduled downloads finish.") + .font(.subheadline) + .foregroundStyle(.secondary) + + Picker("Action", selection: $postQueueAction) { + ForEach(PostQueueAction.allCases) { action in + Text(action.rawValue).tag(action) + } + } + .labelsHidden() + .pickerStyle(.radioGroup) } - .labelsHidden() - .pickerStyle(.radioGroup) + .padding(8) } } private var permissionsSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("System Permissions") - .font(.headline) - - Text("Firelink needs Automation permission to control Finder 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 Permissions") { - schedulerController.openAutomationPermissionSettings() + GroupBox { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("System Permissions", systemImage: "lock.shield") + .font(.headline) + Spacer() } - .buttonStyle(.bordered) - } else { - Button("Grant Permission") { - schedulerController.requestAutomationPermission() + + Text("Firelink needs Automation permission to control Finder 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 Permissions") { + schedulerController.openAutomationPermissionSettings() + } + .buttonStyle(.bordered) + } else { + Button("Grant Permission") { + schedulerController.requestAutomationPermission() + } + .buttonStyle(.borderedProminent) } - .buttonStyle(.borderedProminent) } + .padding(8) } } @@ -196,6 +247,8 @@ struct SchedulerView: View { private func loadState() { isEnabled = schedulerController.settings.isEnabled startTime = schedulerController.settings.startTime + stopTimeEnabled = schedulerController.settings.stopTimeEnabled + stopTime = schedulerController.settings.stopTime isEveryday = schedulerController.settings.isEveryday selectedDays = schedulerController.settings.selectedDays postQueueAction = schedulerController.settings.postQueueAction @@ -207,6 +260,8 @@ struct SchedulerView: View { private func saveState() { schedulerController.settings.isEnabled = isEnabled schedulerController.settings.startTime = startTime + schedulerController.settings.stopTimeEnabled = stopTimeEnabled + schedulerController.settings.stopTime = stopTime schedulerController.settings.isEveryday = isEveryday schedulerController.settings.selectedDays = selectedDays schedulerController.settings.postQueueAction = postQueueAction diff --git a/Sources/Firelink/SpeedLimiterView.swift b/Sources/Firelink/SpeedLimiterView.swift index 03c88a8..fe73c16 100644 --- a/Sources/Firelink/SpeedLimiterView.swift +++ b/Sources/Firelink/SpeedLimiterView.swift @@ -1,12 +1,19 @@ import SwiftUI +enum SpeedUnit: String, CaseIterable, Identifiable { + case kbs = "KB/s" + case mbs = "MB/s" + var id: String { rawValue } +} + struct SpeedLimiterView: View { @EnvironmentObject private var settings: AppSettings @State private var showSaveToast: Bool = false // Local state to hold edits before saving @State private var isEnabled: Bool = false - @State private var speedLimitKiBPerSecond: Int = 1024 + @State private var displayedSpeedValue: Int = 1 + @State private var limitUnit: SpeedUnit = .mbs var body: some View { VStack(spacing: 0) { @@ -14,10 +21,13 @@ struct SpeedLimiterView: View { Divider() ScrollView { - VStack(alignment: .leading, spacing: 24) { - limitSelectionSection - .opacity(isEnabled ? 1.0 : 0.5) - .disabled(!isEnabled) + VStack(alignment: .leading, spacing: 20) { + GroupBox { + limitSelectionSection + .padding(8) + } + .opacity(isEnabled ? 1.0 : 0.5) + .disabled(!isEnabled) } .padding(24) } @@ -62,38 +72,62 @@ struct SpeedLimiterView: View { } private var limitSelectionSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Global Speed Limit") - .font(.headline) + VStack(alignment: .leading, spacing: 20) { + HStack { + Label("Global Speed Limit", systemImage: "speedometer") + .font(.headline) + Spacer() + } Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.") .font(.subheadline) .foregroundStyle(.secondary) - HStack { - Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) { - Text("Maximum Speed:") - } - - TextField("Speed", value: $speedLimitKiBPerSecond, format: .number) + HStack(spacing: 16) { + TextField("Speed", value: $displayedSpeedValue, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 80) + .multilineTextAlignment(.trailing) - Text("KiB/s") + Picker("Unit", selection: $limitUnit) { + ForEach(SpeedUnit.allCases) { unit in + Text(unit.rawValue).tag(unit) + } + } + .pickerStyle(.segmented) + .frame(width: 140) + } + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Quick Presets") + .font(.subheadline) .foregroundStyle(.secondary) + + HStack(spacing: 12) { + presetButton(title: "1 MB/s", value: 1, unit: .mbs) + presetButton(title: "5 MB/s", value: 5, unit: .mbs) + presetButton(title: "10 MB/s", value: 10, unit: .mbs) + } } - - // Helpful presets - HStack(spacing: 12) { - Button("1 MB/s") { speedLimitKiBPerSecond = 1024 } - Button("5 MB/s") { speedLimitKiBPerSecond = 5120 } - Button("10 MB/s") { speedLimitKiBPerSecond = 10240 } - } - .buttonStyle(.bordered) - .padding(.top, 8) } } + private func presetButton(title: String, value: Int, unit: SpeedUnit) -> some View { + Button(action: { + displayedSpeedValue = value + limitUnit = unit + }) { + Text(title) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + private var toastView: some View { VStack { Spacer() @@ -118,13 +152,20 @@ struct SpeedLimiterView: View { private func loadState() { let currentLimit = settings.globalSpeedLimitKiBPerSecond isEnabled = currentLimit > 0 - speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit + let effectiveLimit = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit + + if effectiveLimit % 1024 == 0 && effectiveLimit >= 1024 { + displayedSpeedValue = effectiveLimit / 1024 + limitUnit = .mbs + } else { + displayedSpeedValue = effectiveLimit + limitUnit = .kbs + } } private func saveState() { - // Clamp to ensure it doesn't break aria2 - let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1) - speedLimitKiBPerSecond = clampedSpeed + let valueInKbs = limitUnit == .mbs ? displayedSpeedValue * 1024 : displayedSpeedValue + let clampedSpeed = max(min(valueInKbs, 10_485_760), 1) lastCustomSpeedLimit = clampedSpeed settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0