mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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.
This commit is contained in:
@@ -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<SchedulerDay> = 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SchedulerDay> = []
|
||||
@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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user