diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 60a30e1..e345ce9 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -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? } diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 3aaa253..04116ae 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -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) } } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 709a789..2b1f3c6 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -23,7 +23,6 @@ final class DownloadController: ObservableObject { private var queuePumpScope: QueuePumpScope = .idle private var sleepActivity: SleepActivityHandle? private var cancellables = Set() - 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) } diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index 185f0a6..3a39f0b 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -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 diff --git a/Sources/Firelink/Settings/DownloadSettingsPane.swift b/Sources/Firelink/Settings/DownloadSettingsPane.swift index abb9a9a..89d055e 100644 --- a/Sources/Firelink/Settings/DownloadSettingsPane.swift +++ b/Sources/Firelink/Settings/DownloadSettingsPane.swift @@ -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) } diff --git a/Sources/Firelink/Settings/LocationsSettingsPane.swift b/Sources/Firelink/Settings/LocationsSettingsPane.swift index 1b0a371..f7f08f7 100644 --- a/Sources/Firelink/Settings/LocationsSettingsPane.swift +++ b/Sources/Firelink/Settings/LocationsSettingsPane.swift @@ -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) } } } diff --git a/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift b/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift index 382a671..a922a97 100644 --- a/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift +++ b/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift @@ -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) diff --git a/Sources/Firelink/Settings/NetworkSettingsPane.swift b/Sources/Firelink/Settings/NetworkSettingsPane.swift index e2ae35f..cdb0aea 100644 --- a/Sources/Firelink/Settings/NetworkSettingsPane.swift +++ b/Sources/Firelink/Settings/NetworkSettingsPane.swift @@ -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." } } }