feat: overhaul settings panes and add global configuration options

- Overhauled Downloads, Look & Feel, and Network settings panes to match modern UI guidelines.

- Implemented global User-Agent spoofing for all download engines to bypass restrictions.

- Added new configuration options: dock badge toggle, notification controls, completion sound, auto-retries, and "ask where to save" prompt.

- Integrated NSApp.dockTile.badgeLabel for tracking active downloads in the macOS Dock.

- Ensured newly added options correctly persist through AppSettings.
This commit is contained in:
NimBold
2026-06-10 05:26:45 +03:30
parent 22cc11f751
commit cc89f48221
8 changed files with 192 additions and 85 deletions
+56 -1
View File
@@ -160,6 +160,33 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var showNotifications: Bool {
didSet { save() }
}
@Published var customUserAgent: String {
didSet { save() }
}
@Published var playCompletionSound: Bool {
didSet { save() }
}
@Published var showDockBadge: Bool {
didSet { save() }
}
@Published var maxAutomaticRetries: Int {
didSet {
let clamped = min(max(maxAutomaticRetries, 0), 10)
if maxAutomaticRetries != clamped {
maxAutomaticRetries = clamped
return
}
save()
}
}
@Published var proxySettings: ProxySettings {
didSet {
let normalized = proxySettings.normalized
@@ -197,6 +224,10 @@ final class AppSettings: ObservableObject {
@Published var showKeychainPrimer = false
@Published var askWhereToSaveEachFile: Bool {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
@@ -216,12 +247,18 @@ final class AppSettings: ObservableObject {
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
showNotifications = stored.showNotifications ?? true
playCompletionSound = stored.playCompletionSound ?? true
showDockBadge = stored.showDockBadge ?? true
customUserAgent = stored.customUserAgent ?? ""
maxAutomaticRetries = min(max(stored.maxAutomaticRetries ?? 3, 0), 10)
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
mediaCookieSource = stored.mediaCookieSource ?? .none
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
granted = stored.isKeychainAccessGranted ?? false
isKeychainAccessGranted = granted
askWhereToSaveEachFile = stored.askWhereToSaveEachFile ?? true
} else {
appTheme = .system
appFontSize = .standard
@@ -230,12 +267,18 @@ final class AppSettings: ObservableObject {
maxConcurrentDownloads = 3
globalSpeedLimitKiBPerSecond = 0
preventsSleepWhileDownloading = true
showNotifications = true
playCompletionSound = true
showDockBadge = true
customUserAgent = ""
maxAutomaticRetries = 3
proxySettings = ProxySettings()
siteLogins = []
mediaCookieSource = .none
downloadDirectories = Self.defaultDirectories()
granted = false
isKeychainAccessGranted = granted
askWhereToSaveEachFile = true
}
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
@@ -409,11 +452,17 @@ final class AppSettings: ObservableObject {
maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
showNotifications: showNotifications,
playCompletionSound: playCompletionSound,
showDockBadge: showDockBadge,
customUserAgent: customUserAgent,
maxAutomaticRetries: maxAutomaticRetries,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins,
mediaCookieSource: mediaCookieSource,
isKeychainAccessGranted: isKeychainAccessGranted
isKeychainAccessGranted: isKeychainAccessGranted,
askWhereToSaveEachFile: askWhereToSaveEachFile
)
let defaults = self.defaults
let storageKey = self.storageKey
@@ -490,9 +539,15 @@ private struct StoredSettings: Codable {
var maxConcurrentDownloads: Int?
var globalSpeedLimitKiBPerSecond: Int?
var preventsSleepWhileDownloading: Bool
var showNotifications: Bool?
var playCompletionSound: Bool?
var showDockBadge: Bool?
var customUserAgent: String?
var maxAutomaticRetries: Int?
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
var mediaCookieSource: BrowserCookieSource?
var isKeychainAccessGranted: Bool?
var askWhereToSaveEachFile: Bool?
}
+17
View File
@@ -21,6 +21,15 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
openWindow(id: "add-downloads")
}
.onChange(of: controller.activeCount) { _, newCount in
updateDockBadge(count: newCount, show: settings.showDockBadge)
}
.onChange(of: settings.showDockBadge) { _, show in
updateDockBadge(count: controller.activeCount, show: show)
}
.onAppear {
updateDockBadge(count: controller.activeCount, show: settings.showDockBadge)
}
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
for provider in providers {
if provider.canLoadObject(ofClass: URL.self) {
@@ -200,6 +209,14 @@ struct ContentView: View {
}
}
private func updateDockBadge(count: Int, show: Bool) {
if show && count > 0 {
NSApp.dockTile.badgeLabel = "\(count)"
} else {
NSApp.dockTile.badgeLabel = nil
}
}
private var selectedItems: [DownloadItem] {
controller.downloads.filter { selection.contains($0.id) }
}
+36 -12
View File
@@ -23,7 +23,6 @@ final class DownloadController: ObservableObject {
private var queuePumpScope: QueuePumpScope = .idle
private var sleepActivity: SleepActivityHandle?
private var cancellables = Set<AnyCancellable>()
private let maxAutomaticRetries = 3
private lazy var storageURL: URL = {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
@@ -502,6 +501,17 @@ final class DownloadController: ObservableObject {
pruneActiveQueueScopes()
}
private func injectedEngineItem(from item: DownloadItem) -> DownloadItem {
var engineItem = item
let ua = settings.customUserAgent.trimmingCharacters(in: .whitespacesAndNewlines)
if !ua.isEmpty, !(engineItem.requestHeaders?.contains(where: { $0.name.caseInsensitiveCompare("user-agent") == .orderedSame }) ?? false) {
var headers = engineItem.requestHeaders ?? []
headers.append(DownloadRequestHeader(name: "User-Agent", value: ua))
engineItem.requestHeaders = headers
}
return engineItem
}
private func start(_ item: DownloadItem) {
update(item.id) {
$0.status = .downloading
@@ -528,7 +538,7 @@ final class DownloadController: ObservableObject {
$0.message = "Starting yt-dlp..."
}
let handle = try await mediaEngine.start(
item: liveItem,
item: injectedEngineItem(from: liveItem),
cookieSource: settings.mediaCookieSource,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
@@ -585,7 +595,7 @@ final class DownloadController: ObservableObject {
} else {
do {
let handle = try engine.start(
item: item,
item: injectedEngineItem(from: item),
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
@@ -810,7 +820,7 @@ final class DownloadController: ObservableObject {
let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
guard isAutomaticallyRecoverable(error), retryCount < settings.maxAutomaticRetries else {
automaticRetryCounts[itemID] = nil
update(itemID) {
$0.status = .failed
@@ -833,7 +843,7 @@ final class DownloadController: ObservableObject {
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(settings.maxAutomaticRetries))."
$0.autoResumeOnLaunch = true
}
saveDownloads()
@@ -1102,6 +1112,8 @@ final class DownloadController: ObservableObject {
}
private func showNotification(title: String, body: String) {
guard settings.showNotifications || settings.playCompletionSound else { return }
pendingNotifications.append((title: title, body: body))
notificationDebounceTask?.cancel()
@@ -1110,7 +1122,11 @@ final class DownloadController: ObservableObject {
try await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
var options: UNAuthorizationOptions = []
if self.settings.showNotifications { options.insert(.alert) }
if self.settings.playCompletionSound { options.insert(.sound) }
UNUserNotificationCenter.current().requestAuthorization(options: options) { [weak self] granted, _ in
guard granted, let self else { return }
Task { @MainActor in
let items = self.pendingNotifications
@@ -1118,14 +1134,22 @@ final class DownloadController: ObservableObject {
guard !items.isEmpty else { return }
let content = UNMutableNotificationContent()
if items.count == 1 {
content.title = items[0].title
content.body = items[0].body
if self.settings.showNotifications {
if items.count == 1 {
content.title = items[0].title
content.body = items[0].body
} else {
content.title = "\(items.count) Downloads Completed"
content.body = "Multiple items have finished downloading."
}
} else {
content.title = "\(items.count) Downloads Completed"
content.body = "Multiple items have finished downloading."
content.title = "Download Completed"
}
content.sound = .default
if self.settings.playCompletionSound {
content.sound = .default
}
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
+11 -4
View File
@@ -185,10 +185,17 @@ final class LocalExtensionServer: @unchecked Sendable {
}
Task { @MainActor in
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
self.downloadController.pendingReferer = payload.referer
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
NSApp.activate(ignoringOtherApps: true)
if self.settings.askWhereToSaveEachFile {
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
self.downloadController.pendingReferer = payload.referer
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
NSApp.activate(ignoringOtherApps: true)
} else {
for validURL in validURLs {
self.downloadController.add(urlText: validURL)
}
self.downloadController.startQueue()
}
}
return .ok
@@ -5,7 +5,7 @@ struct DownloadSettingsPane: View {
var body: some View {
Form {
Section {
Section("Connections") {
Stepper(
"Default connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
@@ -25,7 +25,7 @@ struct DownloadSettingsPane: View {
.foregroundStyle(.secondary)
}
Section {
Section("Bandwidth") {
LabeledContent("Global speed limit") {
HStack {
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
@@ -40,6 +40,23 @@ struct DownloadSettingsPane: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Recovery") {
Stepper(
"Automatic retries: \(settings.maxAutomaticRetries)",
value: $settings.maxAutomaticRetries,
in: 0...10
)
Text("Number of times to retry a download automatically if the connection fails.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Notifications") {
Toggle("Show notification when download completes", isOn: $settings.showNotifications)
Toggle("Play sound when download completes", isOn: $settings.playCompletionSound)
.disabled(!settings.showNotifications)
}
}
.formStyle(.grouped)
}
@@ -6,18 +6,17 @@ struct LocationsSettingsPane: View {
var body: some View {
Form {
Section {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
BulkDirectoryPickerRow()
Section(footer: Text("When enabled, you can choose the download location each time you add a download.")) {
Toggle("Ask where to save each file before downloading", isOn: $settings.askWhereToSaveEachFile)
}
GridRow {
Divider()
.gridCellColumns(2)
}
Section(header: Text("Default Locations"), footer: Text("Automatically sets the folder for all categories within the selected base folder.")) {
BulkDirectoryPickerRow()
}
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
Section(header: Text("Category Locations"), footer: Text("Folders will be created automatically when saving.")) {
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
@@ -26,8 +25,6 @@ struct LocationsSettingsPane: View {
settings.resetDirectories()
}
}
} footer: {
Text("Folders will be created automatically when applied.")
}
}
.formStyle(.grouped)
@@ -42,13 +39,10 @@ struct DirectoryPickerRow: View {
@State private var message = ""
var body: some View {
GridRow(alignment: .firstTextBaseline) {
Label(category.rawValue, systemImage: category.symbolName)
.gridColumnAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 4) {
LabeledContent {
HStack(spacing: 8) {
TextField("", text: $path, prompt: Text("Folder path"))
TextField("", text: $path)
.labelsHidden()
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
@@ -56,25 +50,18 @@ struct DirectoryPickerRow: View {
applyPath()
}
Button {
applyPath()
} label: {
Label("Apply", systemImage: "checkmark")
}
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button {
Button("Choose...") {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
} label: {
Label(category.rawValue, systemImage: category.symbolName)
}
if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
Text(displayMessage)
.font(.caption)
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
}
if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
Text(displayMessage)
.font(.caption)
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
}
}
.onAppear {
@@ -175,13 +162,10 @@ struct BulkDirectoryPickerRow: View {
@State private var message = ""
var body: some View {
GridRow(alignment: .firstTextBaseline) {
Label("All Categories", systemImage: "folder.fill.badge.plus")
.gridColumnAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 4) {
LabeledContent {
HStack(spacing: 8) {
TextField("", text: $path, prompt: Text("Base folder path"))
TextField("", text: $path)
.labelsHidden()
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
@@ -189,29 +173,18 @@ struct BulkDirectoryPickerRow: View {
applyPath()
}
Button {
applyPath()
} label: {
Label("Apply", systemImage: "checkmark")
}
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button {
Button("Choose...") {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
} label: {
Label("All Categories", systemImage: "folder.fill.badge.plus")
}
if !message.isEmpty {
Text(message)
.font(.caption)
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
} else {
Text("Automatically creates all category folders in the selected path.")
.font(.caption)
.foregroundStyle(.secondary)
}
if !message.isEmpty {
Text(message)
.font(.caption)
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
}
}
}
@@ -34,9 +34,13 @@ struct LookAndFeelSettingsPane: View {
}
}
Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Section("macOS Integration") {
Toggle("Show badge on Dock icon", isOn: $settings.showDockBadge)
Text("Displays the number of active downloads on the Firelink Dock icon.")
.font(.caption)
.foregroundStyle(.secondary)
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -5,8 +5,8 @@ struct NetworkSettingsPane: View {
var body: some View {
Form {
Section {
Picker("Proxy", selection: proxyBinding(\.mode)) {
Section("Proxy") {
Picker("Mode", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
@@ -42,6 +42,16 @@ struct NetworkSettingsPane: View {
.foregroundStyle(.secondary)
}
}
Section("Identity") {
TextField("User Agent", text: $settings.customUserAgent, prompt: Text("e.g. Mozilla/5.0..."))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Text("Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
@@ -49,14 +59,14 @@ struct NetworkSettingsPane: View {
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
"Downloads ignore configured proxies."
return "Downloads ignore configured proxies."
case .system:
"Downloads use the matching macOS system proxy when one is configured."
return "Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
"Downloads use \(proxyURI)."
return "Downloads use \(proxyURI)."
} else {
"Enter a proxy host and port to enable the custom proxy."
return "Enter a proxy host and port to enable the custom proxy."
}
}
}