Compare commits

...

16 Commits

Author SHA1 Message Date
NimBold facab68237 chore(release): bump version to 0.7.0 and update changelog 2026-06-11 00:04:51 +03:30
NimBold 3a7c594d7c ci: restore macOS SDK 26 check per project requirements
- Ensure SDK version is at least 26 to avoid UI issues with older SDKs
2026-06-10 23:45:46 +03:30
NimBold 0ace3e4111 fix: resolve additional bugs identified in code review
- core: remove DispatchGroup from MediaExtractionEngine to fix hanging on yt-dlp launch failure
- core: clear readabilityHandlers in Aria2DownloadEngine if process fails to launch
- core: fix TOCTOU type warning for findFreePort
- build: fix inverted logic in Mach-O validation script
- ci: fix hardcoded macOS SDK version check in release workflow
- ci: prevent workflow_dispatch from failing on verify-tag
2026-06-10 23:43:10 +03:30
NimBold 7e2e0eeb77 fix: resolve critical download bugs and configuration issues
- proxy: Prevent SOCKS5 proxy type from being silently converted to HTTP.
- core: Remove DispatchGroup from MediaDownloadEngine to fix a hang caused by unclosed output pipes when child processes inherit file descriptors.
- settings: Fix keychain primer logic to prevent wiping extension credentials on debug builds.
- ui: Resolve a batch rename collision in AddDownloadsView by properly checking new filenames against the current batch sequence.
2026-06-10 23:36:16 +03:30
NimBold 2a5452b7c6 fix: resolve multiple critical bugs identified in code review
- fix(engine): resolve aria2 port TOCTOU race and secure conf file

- fix(server): eliminate main queue deadlock risk in local extension server

- fix(controller): ensure synchronous state save on app termination

- fix(ui): fix table skipping new items by diffing identity instead of count

- fix(scheduler): use 2-letter abbreviations to resolve day name collisions

- fix(settings): purge keychain orphans when primer access is denied

- fix(metadata): prevent synchronous DNS resolution from blocking async pool

- refactor: clean up dead code and unused variables
2026-06-10 19:25:06 +03:30
NimBold fdbacb8a7f feat(ui): overhaul context menu and toolbar for modern hig
- Flattened playback controls in context menu
- Added 'Copy File Path' context action for completed downloads
- Reorganized context menu to prioritize Open/Finder actions and pin Properties to bottom
- Removed ControlGroup and explicit placements in toolbar for clean borderless style
- Renamed Start/Stop toolbar items to Resume/Pause with proper SF Symbols
- Added informative tooltips to toolbar actions
2026-06-10 18:49:55 +03:30
NimBold ffba883961 feat(ui): modernize download list and sidebar styling
- Reintroduce Speed and ETA columns with dedicated rendering
- Restructure Status column to align progress bar natively
- Enhance typography with rounded fonts and monospaced digits
- Move Settings shortcut back into sidebar for faster access
- Optimize download list sorting performance by removing dynamic sortedItems
2026-06-10 17:38:16 +03:30
NimBold 3b695169d6 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.
2026-06-10 12:40:31 +03:30
NimBold a5828ec586 chore: remove temporary test file 2026-06-10 12:28:07 +03:30
NimBold efb4446739 fix(security): track executable modification date for primer detection
Includes the executable's modification date in the version hash to ensure ad-hoc and local recompilations correctly trigger the keychain primer UI, preventing unexpected system prompts on boot.
2026-06-10 12:27:48 +03:30
NimBold fd90055ccd fix(security): prevent eager keychain password fetch on startup
Defers fetching site login passwords until they are strictly needed (when a download starts or when viewing properties) to prevent the OS from showing the keychain prompt before the in-app primer UI.
2026-06-10 12:03:11 +03:30
NimBold 14924cf254 feat(settings): improve UI for Site Logins, Engine, and About panes
- Refactor Site Logins pane to use a data Table with search and a dedicated editor sheet
- Add 'Reveal in Finder' buttons for bundled engines in Engine pane
- Fix alignment of 'Bundled' labels and 'Browser Cookies' picker in Engine pane
- Remove build number from the version display in About pane
2026-06-10 06:21:35 +03:30
NimBold 85f55db66f feat(settings): add ask where to save option and overhaul Locations and Downloads UI 2026-06-10 05:43:13 +03:30
NimBold cc89f48221 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.
2026-06-10 05:26:45 +03:30
NimBold 22cc11f751 perf(media): optimize yt-dlp extraction speed and fix proxy propagation
- Add '--extractor-args youtube:player_client=ios,web' to bypass heavy JS challenge solving
- Add '--compat-options no-youtube-unavailable-videos' to skip blocked videos
- Fix missing proxy configuration in MediaExtractionEngine that caused timeouts
- Add '--live-from-start' for yt-dlp to handle live streams correctly
2026-06-10 03:12:42 +03:30
NimBold 88e99a8528 style(ui): update integration step counter visibility and add up-to-date status icon 2026-06-10 03:12:42 +03:30
29 changed files with 1351 additions and 784 deletions
+8 -4
View File
@@ -49,14 +49,14 @@ jobs:
xcode-select -p
xcrun --sdk macosx --show-sdk-version
- name: Verify macOS 26 SDK
- name: Verify macOS 26+ SDK
shell: bash
run: |
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
SDK_MAJOR="${SDK_VERSION%%.*}"
if [[ "$SDK_MAJOR" != "26" ]]; then
echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2
if [[ "$SDK_MAJOR" -lt 26 ]]; then
echo "Expected at least macOS 26 SDK, got macOS $SDK_VERSION" >&2
exit 1
fi
@@ -123,5 +123,9 @@ jobs:
gh release upload "$TAG_NAME" dist/*.dmg --clobber
gh release edit "$TAG_NAME" --notes-file release_notes.md
else
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md
else
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
fi
fi
+16
View File
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7.0] - 2026-06-11
### New Features & Improvements
- Complete UI modernization for the context menu, toolbar, download list, and sidebar to adhere strictly to Apple's Human Interface Guidelines (HIG).
- Overhaul of the Settings panes including Site Logins, Engine, About, Locations, and Downloads for a unified, cleaner look.
- Introduce an "Ask where to save" global configuration option for manual location picking per download.
- Add "Stop Time" option to the Scheduler and unit picker for the global Speed Limiter.
- Enhance the Integration pane with a visible step counter and an up-to-date status icon.
- Optimize `yt-dlp` execution for noticeably faster media extraction speeds.
- Defer Keychain access prompts and track executable modification dates for a more secure "priming" mechanism.
### Fixes
- Fix issues regarding proxy environment propagation into media download processes.
- Resolve multiple critical bugs related to configuration storage and download stability.
- Address multiple underlying issues identified during comprehensive code reviews to improve overall resilience.
## [0.6.6] - 2026-06-10
### New Features
+2 -2
View File
@@ -43,9 +43,9 @@
## ✨ Features
-**Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits.
-**Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits and a built-in download scheduler.
- 🪄 **Media Downloader:** Instantly extract high-quality media (4K, 1080p, MP3) with smart cascading format pickers, powered by bundled `yt-dlp` and `ffmpeg`.
- 🎨 **Premium Native UI:** A responsive, frosted-glass SwiftUI interface tailor-made for Apple Silicon, featuring a visual chunk map and dynamic progress tracking.
- 🎨 **Premium Native UI:** A responsive, frosted-glass SwiftUI interface strictly adhering to Apple Human Interface Guidelines, featuring a visual chunk map and dynamic progress tracking.
- 🌐 **Seamless Browser Integration:** Send downloads directly from your browser via the secure Firelink Companion extension.
- 🛡️ **Privacy & Security:** Zero-configuration setup with deferred Keychain integration ensures your credentials are kept safe and only accessed when strictly necessary.
- 🗂️ **Smart Organization:** Auto-categorizes incoming files and remembers your preferred download locations.
+1 -1
View File
@@ -20,7 +20,7 @@ cd "$ROOT_DIR"
is_valid_mach_o() {
local path="$1"
if ! file "$path" | grep -q 'Mach-O'; then
return 0
return 1
fi
lipo -archs "$path" >/dev/null 2>&1
+4 -3
View File
@@ -322,7 +322,7 @@ struct AddDownloadsView: View {
ProgressView().controlSize(.small)
Text("Fetching media options...")
.foregroundStyle(.secondary)
} else if case .failed(let error) = firstMedia.state {
} else if case .failed(_) = firstMedia.state {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
Text("Failed to load options.")
.foregroundStyle(.secondary)
@@ -603,7 +603,8 @@ struct AddDownloadsView: View {
for: item.url,
cookieSource: settings.mediaCookieSource,
credentials: metadataCredentials(for: item.url),
transferOptions: transferOptions
transferOptions: transferOptions,
proxyConfiguration: settings.downloadProxyConfiguration
)
fetchedItem.mediaMetadata = metadata
fetchedItem.mediaOptions = options
@@ -700,7 +701,7 @@ struct AddDownloadsView: View {
var count = 1
let base = URL(fileURLWithPath: newName).deletingPathExtension().lastPathComponent
let ext = URL(fileURLWithPath: newName).pathExtension
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) {
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) || finalDownloads.prefix(upTo: index).contains(where: { (overrideDirectory ?? $0.defaultDirectory) == destURL && $0.fileName == newName }) {
newName = "\(base) (\(count))" + (ext.isEmpty ? "" : ".\(ext)")
count += 1
}
+69 -14
View File
@@ -93,9 +93,6 @@ struct ProxySettings: Codable, Equatable, Sendable {
var copy = self
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
copy.port = min(max(copy.port, 1), 65_535)
if copy.type != .http {
copy.type = .http
}
return copy
}
@@ -160,6 +157,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 +221,10 @@ final class AppSettings: ObservableObject {
@Published var showKeychainPrimer = false
@Published var askWhereToSaveEachFile: Bool {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
@@ -216,12 +244,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 ?? false
} else {
appTheme = .system
appFontSize = .standard
@@ -230,17 +264,29 @@ 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 = false
}
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
let fullVersion = "\(currentVersion).\(currentBuild)"
var execHash = "unknown"
if let execPath = Bundle.main.executablePath,
let attr = try? FileManager.default.attributesOfItem(atPath: execPath),
let modDate = attr[.modificationDate] as? Date {
execHash = String(modDate.timeIntervalSince1970)
}
let fullVersion = "\(currentVersion).\(currentBuild).\(execHash)"
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
@@ -252,16 +298,13 @@ final class AppSettings: ObservableObject {
}
if granted {
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
}
if needsPrimer {
showKeychainPrimer = true
extensionPairingToken = ""
} else {
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
// The didSet of extensionPairingToken will handle setting it in the keychain since isKeychainAccessGranted is true.
}
}
} else {
extensionPairingToken = ""
@@ -396,7 +439,7 @@ final class AppSettings: ObservableObject {
extensionPairingToken = Self.generateSecureToken()
}
} else {
isKeychainAccessGranted = false
revokeKeychainAccess()
}
}
@@ -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?
}
+89 -48
View File
@@ -2,7 +2,7 @@ import Foundation
import CFNetwork
import Network
final class Aria2DownloadEngine {
final class Aria2DownloadEngine: Sendable {
struct Handle {
let processIdentifier: Int32
let rpcPort: Int
@@ -10,7 +10,7 @@ final class Aria2DownloadEngine {
let cancel: @Sendable () -> Void
}
static func findFreePort() -> Int {
static func findFreePort() -> UInt16 {
var port: UInt16 = 6800
let parameters = NWParameters.tcp
for p in 6800...6900 {
@@ -21,7 +21,7 @@ final class Aria2DownloadEngine {
break
}
}
return Int(port)
return port
}
enum EngineError: LocalizedError {
@@ -107,7 +107,7 @@ final class Aria2DownloadEngine {
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
) async throws -> Handle {
guard let executableURL else {
throw EngineError.executableNotFound
}
@@ -117,30 +117,48 @@ final class Aria2DownloadEngine {
withIntermediateDirectories: true
)
let rpcPort = Self.findFreePort()
let rpcSecret = UUID().uuidString
let confURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString).conf")
do {
let confContent = "rpc-secret=\(rpcSecret)\n"
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
} catch {
throw EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
}
var lastError: Error?
let process = Process()
process.executableURL = executableURL
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
confURL: confURL
)
for _ in 1...5 {
let rpcPort = Int(Self.findFreePort())
let rpcSecret = UUID().uuidString
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString)")
do {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
} catch {
lastError = EngineError.launchFailed("Could not create secure temporary directory: \(error.localizedDescription)")
continue
}
let confURL = tempDir.appendingPathComponent("aria2.conf")
do {
let confContent = "rpc-secret=\(rpcSecret)\n"
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
} catch {
lastError = EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
continue
}
let inputPipe = Pipe()
let outputPipe = Pipe()
let errorPipe = Pipe()
let process = Process()
process.executableURL = executableURL
do {
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
confURL: confURL
)
} catch {
lastError = error
break
}
let inputPipe = Pipe()
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardInput = inputPipe
process.standardOutput = outputPipe
process.standardError = errorPipe
@@ -169,7 +187,7 @@ final class Aria2DownloadEngine {
}
process.terminationHandler = { finishedProcess in
try? FileManager.default.removeItem(at: confURL)
try? FileManager.default.removeItem(at: tempDir)
completionMonitor.cancel()
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
@@ -190,33 +208,56 @@ final class Aria2DownloadEngine {
completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
}
do {
try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
var didThrow = false
do {
try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
didThrow = true
lastError = EngineError.launchFailed(error.localizedDescription)
}
if didThrow {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
try? FileManager.default.removeItem(at: tempDir)
continue
}
try? await Task.sleep(nanoseconds: 100_000_000)
if !process.isRunning {
let stderr = String(data: errorBuffer.data, encoding: .utf8) ?? ""
if stderr.contains("Address already in use") || stderr.contains("Failed to bind") || stderr.contains("bind: Address") {
try? FileManager.default.removeItem(at: tempDir)
continue
}
// If it exited for another reason, we might still want to fail or let the terminationHandler process it.
// But the terminationHandler will hit completionGate, so we just return the handle.
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
try? FileManager.default.removeItem(at: confURL)
throw EngineError.launchFailed(error.localizedDescription)
}
completionMonitor.set(
Self.monitorCompletion(
rpcPort: rpcPort,
rpcSecret: rpcSecret,
process: process,
completionGate: completionGate
completionMonitor.set(
Self.monitorCompletion(
rpcPort: rpcPort,
rpcSecret: rpcSecret,
process: process,
completionGate: completionGate
)
)
)
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionMonitor.cancel()
if process.isRunning {
process.terminate()
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionMonitor.cancel()
if process.isRunning {
process.terminate()
}
try? FileManager.default.removeItem(at: tempDir)
}
try? FileManager.default.removeItem(at: confURL)
}
throw lastError ?? EngineError.launchFailed("Failed to start aria2c after 5 attempts.")
}
private static func monitorCompletion(
+37 -19
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) {
@@ -108,29 +117,14 @@ struct ContentView: View {
StatusBar()
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
ToolbarItemGroup {
Button {
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
Label("Add Download", systemImage: "plus")
}
}
ToolbarItemGroup {
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading })
Button {
if selectedItems.isEmpty {
controller.pauseActiveDownloads(queueID: queueID)
} else {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
}
.disabled(!canStop)
.help("Add a new download")
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
Button {
@@ -142,9 +136,25 @@ struct ContentView: View {
}
}
} label: {
Label(selectedItems.isEmpty ? "Start Queue" : "Start", systemImage: "play.fill")
Label(selectedItems.isEmpty ? "Resume All" : "Resume", systemImage: "play.fill")
}
.help(selectedItems.isEmpty ? "Resume all downloads" : "Resume selected downloads")
.disabled(!canStart)
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading || $0.status == .queued })
Button {
if selectedItems.isEmpty {
controller.pauseActiveDownloads(queueID: queueID)
} else {
for item in selectedItems where item.status == .downloading || item.status == .queued {
controller.pause(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Pause All" : "Pause", systemImage: "pause.fill")
}
.help(selectedItems.isEmpty ? "Pause all active downloads" : "Pause selected downloads")
.disabled(!canStop)
}
}
.background {
@@ -200,6 +210,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) }
}
+107 -59
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")
@@ -75,7 +74,7 @@ final class DownloadController: ObservableObject {
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in
self?.saveDownloads()
self?.saveDownloadsSync()
}
.store(in: &cancellables)
@@ -372,12 +371,7 @@ final class DownloadController: ObservableObject {
pumpQueue()
}
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
for index in offsets {
let item = downloads[index]
delete(item, deleteFiles: deleteFiles)
}
}
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
activeHandles[item.id]?.cancel()
@@ -502,6 +496,22 @@ final class DownloadController: ObservableObject {
pruneActiveQueueScopes()
}
private func injectedEngineItem(from item: DownloadItem) -> DownloadItem {
var engineItem = item
if engineItem.credentials != nil {
if let storedPassword = KeychainCredentialStore.password(for: engineItem.id) {
engineItem.credentials?.password = storedPassword
}
}
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),
@@ -583,49 +593,61 @@ final class DownloadController: ObservableObject {
}
}
} else {
do {
let handle = try engine.start(
item: item,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
Task { @MainActor in
let now = Date()
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
return
Task {
do {
let liveItem = injectedEngineItem(from: item)
let handle = try await engine.start(
item: liveItem,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
progress: { [weak self] progress in
Task { @MainActor in
let now = Date()
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
return
}
self?.lastProgressUpdateTimes[item.id] = now
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
}
}
self?.lastProgressUpdateTimes[item.id] = now
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
},
completion: { [weak self] result in
Task { @MainActor in
self?.handleCompletion(item: item, result: result)
}
}
},
completion: { [weak self] result in
Task { @MainActor in
self?.handleCompletion(item: item, result: result)
)
await MainActor.run {
guard activeDownloadItem(id: item.id) != nil else {
handle.cancel()
return
}
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Starting..."
}
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
}
} catch {
await MainActor.run {
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
)
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Starting..."
}
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch {
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
}
}
@@ -810,7 +832,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 +855,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()
@@ -981,6 +1003,22 @@ final class DownloadController: ObservableObject {
}
}
private func saveDownloadsSync() {
let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence)
let storageURL = self.storageURL
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
let data = try JSONEncoder().encode(state)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to synchronously save downloads: \(error)")
}
}
private func saveDownloads() {
let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence)
@@ -1034,10 +1072,6 @@ final class DownloadController: ObservableObject {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
adjusted.credentials?.password = storedPassword
}
if adjusted.status == .completed && adjusted.progress != 1 {
adjusted.progress = 1
shouldRewriteStoredDownloads = true
@@ -1102,6 +1136,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 +1146,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 +1158,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)
}
@@ -54,9 +54,14 @@ enum DownloadMetadataFetcher {
return pending
}
if isAutoFetch, let host = url.host, isPrivateHost(host) {
pending.state = .loaded
return pending
if isAutoFetch, let host = url.host {
let isPrivate = await Task.detached {
isPrivateHost(host)
}.value
if isPrivate {
pending.state = .loaded
return pending
}
}
var request = URLRequest(url: url)
@@ -60,7 +60,8 @@ struct DownloadPropertiesView: View {
if let credentials = item.credentials {
_loginMode = State(initialValue: .custom)
_username = State(initialValue: credentials.username)
_password = State(initialValue: credentials.password)
let storedPassword = KeychainCredentialStore.password(for: item.id) ?? credentials.password
_password = State(initialValue: storedPassword)
} else {
_loginMode = State(initialValue: .matching)
_username = State(initialValue: "")
+214 -147
View File
@@ -13,118 +13,131 @@ struct DownloadTable: View {
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
var sortedItems: [DownloadItem] {
items.sorted(using: sortOrder)
}
@State private var sortedItems: [DownloadItem] = []
var body: some View {
VStack(spacing: 0) {
HStack {
Text(title)
.font(.headline)
.font(.title2)
.fontWeight(.semibold)
Text("\(items.count)")
.font(.caption)
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 5))
.clipShape(Capsule())
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
TableColumn("File Name", value: \.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
TableColumn("File Name", value: \.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
.draggable(item.id.uuidString)
}
.width(min: 200, ideal: 340)
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
if let size = item.sizeBytes, size > 0 {
Text(ByteFormatter.string(size))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
Text(item.bytesText)
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else {
Text("Unknown")
.monospacedDigit()
TableColumn("Size", value: \.sortableSize) { item in
if let size = item.sizeBytes, size > 0 {
Text(ByteFormatter.string(size))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
Text(item.bytesText)
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else {
Text("Unknown")
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 80, ideal: 100)
TableColumn("Status", value: \.status.rawValue) { item in
combinedStatusCell(for: item)
}
.width(min: 160, ideal: 200)
TableColumn("Speed", value: \.displaySpeedText) { item in
if item.status == .downloading {
formattedSpeedCell(for: item.displaySpeedText)
} else {
Text("-")
.foregroundStyle(.tertiary)
}
}
.width(min: 80, ideal: 100)
TableColumn("ETA", value: \.displayETAText) { item in
if item.status == .downloading {
formattedETACell(for: item.displayETAText)
} else {
Text("-")
.foregroundStyle(.tertiary)
}
}
.width(min: 80, ideal: 100)
TableColumn("Date Added", value: \.createdAt) { item in
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 100, ideal: 155)
}
.width(min: 80, ideal: 100)
TableColumn("Progress", value: \.progress) { item in
progressBarCell(for: item)
}
.width(min: 100, ideal: 115)
TableColumn("Status", value: \.status.rawValue) { item in
statusCell(for: item)
}
.width(min: 115, ideal: 170)
TableColumn("Speed", value: \.displaySpeedText) { item in
Text(item.displaySpeedText)
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.displayETAText) { item in
Text(item.displayETAText)
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 70, ideal: 90)
TableColumn("Date Added", value: \.createdAt) { item in
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 100, ideal: 155)
}
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
.animation(.default, value: sortedItems)
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
for target in targetItems {
if target.status == .completed {
openFile(target)
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
.animation(.default, value: sortedItems)
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
for target in targetItems {
if target.status == .completed {
openFile(target)
}
}
}
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
}
}
.onAppear { sortedItems = items.sorted(using: sortOrder) }
.onChange(of: items) { _, newItems in
let existingIDs = Set(sortedItems.map(\.id))
let newIDs = Set(newItems.map(\.id))
if existingIDs != newIDs {
sortedItems = newItems.sorted(using: sortOrder)
} else {
let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
sortedItems = sortedItems.compactMap { itemsDict[$0.id] }
}
}
.onChange(of: sortOrder) { _, newOrder in
sortedItems = items.sorted(using: newOrder)
}
.confirmationDialog(
"Delete Download",
isPresented: Binding(
@@ -165,19 +178,81 @@ struct DownloadTable: View {
}
@ViewBuilder
private func statusCell(for item: DownloadItem) -> some View {
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
if item.status == .downloading, !message.isEmpty {
Text(message)
.lineLimit(1)
.truncationMode(.tail)
private func combinedStatusCell(for item: DownloadItem) -> some View {
if item.status == .completed {
Text("Completed")
.foregroundStyle(.green)
.fontWeight(.medium)
} else {
Text(item.status.rawValue)
HStack(spacing: 8) {
ProgressView(value: item.progress)
.progressViewStyle(.linear)
.tint(statusColor(for: item.status))
if item.status == .downloading {
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(width: 35, alignment: .trailing)
} else {
Text(item.status.rawValue.capitalized)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
}
}
}
}
private func parseSpeed(_ text: String) -> [String] {
var display = text
if let index = display.firstIndex(where: { $0.isLetter }) {
if display.distance(from: display.startIndex, to: index) > 0 {
let prevIndex = display.index(before: index)
if display[prevIndex] != " " {
display.insert(" ", at: index)
}
}
}
return display.split(separator: " ", maxSplits: 1).map(String.init)
}
@ViewBuilder
private func formattedSpeedCell(for text: String) -> some View {
let components = parseSpeed(text)
if components.count == 2 {
HStack(alignment: .firstTextBaseline, spacing: 1) {
Text(components[0])
.font(.system(size: 13, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.primary)
Text(components[1])
.font(.system(size: 10, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
}
.lineLimit(1)
.truncationMode(.tail)
} else {
Text(components.joined(separator: " "))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
}
@ViewBuilder
private func formattedETACell(for text: String) -> some View {
Text(text)
.font(.system(size: 13, weight: .medium, design: .rounded))
.monospacedDigit()
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
@ViewBuilder
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
@@ -198,42 +273,38 @@ struct DownloadTable: View {
showInFinder(target)
}
} label: {
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "magnifyingglass")
}
Divider()
Divider()
Menu("Controls") {
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} label: {
Label("Start", systemImage: "play.fill")
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} label: {
Label("Resume", systemImage: "play.fill")
}
}
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
}
} label: {
Label("Pause", systemImage: "pause.fill")
}
}
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
}
}
@@ -263,12 +334,16 @@ struct DownloadTable: View {
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
}
Button {
for target in targetItems {
openWindow(id: "download-properties", value: target.id)
if targetItems.allSatisfy({ $0.status == .completed }) {
Button {
NSPasteboard.general.clearContents()
let paths = targetItems.map { $0.destinationPath }.joined(separator: "\n")
if !paths.isEmpty {
NSPasteboard.general.setString(paths, forType: .string)
}
} label: {
Label(targetItems.count > 1 ? "Copy File Paths" : "Copy File Path", systemImage: "doc.on.doc")
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Divider()
@@ -276,7 +351,17 @@ struct DownloadTable: View {
Button(role: .destructive) {
pendingDeleteItems = itemIDs
} label: {
Label("Remove", systemImage: "trash")
Label("Remove from List", systemImage: "trash")
}
Divider()
Button {
for target in targetItems {
openWindow(id: "download-properties", value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
}
}
@@ -303,25 +388,7 @@ struct DownloadTable: View {
}
}
@ViewBuilder
private func progressBarCell(for item: DownloadItem) -> some View {
if item.status == .completed {
Text("Completed")
.foregroundStyle(.green)
.lineLimit(1)
.truncationMode(.tail)
} else {
HStack(spacing: 4) {
ProgressView(value: item.progress)
.progressViewStyle(.linear)
.tint(statusColor(for: item.status))
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.frame(width: 35, alignment: .trailing)
}
}
}
private func formatted(_ date: Date?) -> String {
guard let date else { return "-" }
+28 -5
View File
@@ -1,6 +1,7 @@
import Foundation
import Network
import AppKit
import Combine
final class LocalExtensionServer: @unchecked Sendable {
private enum Constants {
@@ -16,7 +17,12 @@ final class LocalExtensionServer: @unchecked Sendable {
private let settings: AppSettings
private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16
private let tokenLock = NSLock()
private var _pairingToken: String = ""
private var cancellables = Set<AnyCancellable>()
@MainActor
init?(downloadController: DownloadController, settings: AppSettings) {
self.downloadController = downloadController
self.settings = settings
@@ -42,6 +48,16 @@ final class LocalExtensionServer: @unchecked Sendable {
self.listener = createdListener
self.port = selectedPort ?? 6412
settings.$extensionPairingToken
.sink { [weak self] token in
self?.tokenLock.withLock { self?._pairingToken = token }
}
.store(in: &cancellables)
}
private var currentPairingToken: String {
tokenLock.withLock { _pairingToken }
}
func start() {
@@ -139,7 +155,7 @@ final class LocalExtensionServer: @unchecked Sendable {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken }
let expectedToken = currentPairingToken
guard let token = request.header(named: Constants.extensionRequestHeader),
token == expectedToken else {
return .forbidden
@@ -185,10 +201,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
+9 -14
View File
@@ -45,6 +45,9 @@ final class MediaDownloadEngine: @unchecked Sendable {
"--newline",
"--ffmpeg-location", ffmpegURL.path,
"--force-ipv4",
"--live-from-start",
"--extractor-args", "youtube:player_client=ios,web",
"--compat-options", "no-youtube-unavailable-videos",
"-o", item.destinationPath
]
@@ -97,16 +100,10 @@ final class MediaDownloadEngine: @unchecked Sendable {
messageUpdate: messageUpdate
)
let group = DispatchGroup()
group.enter() // output
group.enter() // error
group.enter() // process
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else if let text = String(data: data, encoding: .utf8) {
outputHandler.handle(text)
}
@@ -116,7 +113,6 @@ final class MediaDownloadEngine: @unchecked Sendable {
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
errorBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
@@ -125,16 +121,15 @@ final class MediaDownloadEngine: @unchecked Sendable {
}
}
process.terminationHandler = { _ in
group.leave()
}
group.notify(queue: .global()) {
if process.terminationStatus == 0 {
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
} else {
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: process.terminationStatus))))
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
}
}
+22 -16
View File
@@ -69,7 +69,8 @@ enum MediaExtractionEngine {
for url: URL,
cookieSource: BrowserCookieSource,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions
transferOptions: DownloadTransferOptions,
proxyConfiguration: DownloadProxyConfiguration
) async throws -> (MediaMetadata, [CleanFormatOption]) {
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
@@ -77,7 +78,20 @@ enum MediaExtractionEngine {
}
let ytDlpPath = ytDlpURL.path
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--force-ipv4"]
var args = [
"-J",
"--no-warnings",
"--ignore-no-formats-error",
"--no-playlist",
"--force-ipv4",
"--extractor-args", "youtube:player_client=ios,web",
"--compat-options", "no-youtube-unavailable-videos"
]
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
args.append(contentsOf: ["--proxy", proxyURI])
}
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
args.append(url.absoluteString)
@@ -402,16 +416,10 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
process.standardError = errorPipe
process.standardInput = nil
let group = DispatchGroup()
group.enter() // output
group.enter() // error
group.enter() // process
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
outputBuffer.append(data)
}
@@ -421,7 +429,6 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
errorBuffer.append(data)
}
@@ -431,12 +438,11 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
self.process = process
}
process.terminationHandler = { _ in
group.leave()
}
group.notify(queue: .global()) {
if process.terminationStatus == 0 {
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
continuation.resume(returning: outputBuffer.data)
} else {
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
@@ -449,7 +455,7 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
.joined(separator: "\n")
continuation.resume(
throwing: MediaExtractionEngine.ExtractionError.processFailed(
message.isEmpty ? "Exit code \(process.terminationStatus)" : message
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
)
)
}
+35 -16
View File
@@ -18,13 +18,13 @@ enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
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"
case .sunday: "Su"
case .monday: "Mo"
case .tuesday: "Tu"
case .wednesday: "We"
case .thursday: "Th"
case .friday: "Fr"
case .saturday: "Sa"
}
}
}
@@ -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)
}
}
+120 -65
View File
@@ -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
@@ -15,9 +15,6 @@ struct AboutSettingsPane: View {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
}
var body: some View {
Form {
@@ -31,7 +28,7 @@ struct AboutSettingsPane: View {
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.bold))
Text("Version \(appVersion) (\(buildNumber))")
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
@@ -148,6 +145,9 @@ struct AboutSettingsPane: View {
.buttonStyle(.borderedProminent)
case .upToDate(let latestVersion, _):
Image(systemName: "checkmark.seal.fill")
.font(.title2)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Firelink is up to date")
.font(.headline)
@@ -6,27 +6,27 @@ struct DownloadSettingsPane: View {
var body: some View {
Form {
Section {
Stepper(
"Default connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
in: 1...16
)
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
.font(.caption)
.foregroundStyle(.secondary)
Stepper(
"Parallel downloads: \(settings.maxConcurrentDownloads)",
value: $settings.maxConcurrentDownloads,
in: 1...12
)
Text("Controls how many files Firelink downloads at the same time.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
LabeledContent("Global speed limit") {
LabeledContent {
Stepper("\(settings.perServerConnections)", value: $settings.perServerConnections, in: 1...16)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Default connections:")
Text("For new downloads")
.font(.caption)
.foregroundStyle(.secondary)
}
}
LabeledContent {
Stepper("\(settings.maxConcurrentDownloads)", value: $settings.maxConcurrentDownloads, in: 1...12)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Parallel downloads:")
Text("Max simultaneous active files")
.font(.caption)
.foregroundStyle(.secondary)
}
}
LabeledContent {
HStack {
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder)
@@ -35,10 +35,36 @@ struct DownloadSettingsPane: View {
Text("KiB/s")
.foregroundStyle(.secondary)
}
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Global speed limit:")
Text("0 = unlimited speed")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text("Set to 0 for unlimited speed. This limit is divided across currently active downloads.")
.font(.caption)
.foregroundStyle(.secondary)
LabeledContent {
Stepper("\(settings.maxAutomaticRetries)", value: $settings.maxAutomaticRetries, in: 0...10)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Automatic retries:")
Text("If a connection fails")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Toggle(isOn: $settings.showNotifications) {
VStack(alignment: .leading, spacing: 2) {
Text("Show notification when download completes")
Text("Alerts you in Notification Center")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Toggle(isOn: $settings.playCompletionSound) {
Text("Play sound when download completes")
}
.disabled(!settings.showNotifications)
}
}
.formStyle(.grouped)
@@ -20,15 +20,16 @@ struct EngineSettingsPane: View {
.textSelection(.enabled)
}
LabeledContent("Binary Path") {
Text(executableURL?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
LabeledContent("Binary") {
if let url = executableURL {
Button("Reveal in Finder") {
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: "")
}
} else {
Text("Not found")
.foregroundStyle(.red)
}
}
.help(executableURL?.path ?? "")
} header: {
Text("Core Downloader (Aria2)")
} footer: {
@@ -46,13 +47,16 @@ struct EngineSettingsPane: View {
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
LabeledContent("Browser Cookies") {
Picker("", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source)
HStack {
Spacer()
Picker("", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source)
}
}
.labelsHidden()
.fixedSize()
}
.labelsHidden()
.frame(maxWidth: 200)
}
} header: {
Text("Media Extractors")
@@ -75,7 +79,7 @@ struct EngineSettingsPane: View {
@ViewBuilder
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
LabeledContent(title) {
VStack(alignment: .trailing) {
HStack(spacing: 8) {
switch state {
case .notInstalled:
Text("Missing")
@@ -89,13 +93,12 @@ struct EngineSettingsPane: View {
.foregroundStyle(.red)
.help(error)
}
Text(path?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
if let path {
Button("Reveal") {
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: "")
}
}
}
}
}
@@ -7,117 +7,139 @@ struct IntegrationSettingsPane: View {
@State private var showToast = false
var body: some View {
VStack(spacing: 20) {
// Header
HStack(alignment: .center, spacing: 16) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
Form {
Section {
HStack(alignment: .center, spacing: 12) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Connect Browser Extension")
.font(.title2.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.subheadline)
VStack(alignment: .leading, spacing: 2) {
Text("Connect Browser Extension")
.font(.title3.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.subheadline)
}
Spacer()
}
Spacer()
.padding(.bottom, 4)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
KeychainAccessCard()
Section {
KeychainAccessCard()
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
if settings.isKeychainAccessGranted {
HStack(spacing: 12) {
// Step 1
CompactStepCardView(
stepNumber: 1,
title: "Copy Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token",
action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
withAnimation {
showToast = true
Section {
HStack(spacing: 8) {
// Step 1
CompactStepCardView(
stepNumber: 1,
title: "Copy Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token",
action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
withAnimation {
showToast = true
}
},
secondaryActionText: "Regenerate",
secondaryAction: {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
}
},
secondaryActionText: "Regenerate",
secondaryAction: {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
}
)
)
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
// Step 2
CompactStepCardView(
stepNumber: 2,
title: "Get Extension",
description: "Install the Firelink Companion extension on your browser.",
icon: "globe",
iconColor: .orange,
actionText: "Firefox Add-ons",
action: {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
// Step 2
CompactStepCardView(
stepNumber: 2,
title: "Get Extension",
description: "Install the Firelink Companion extension on your browser.",
icon: "globe",
iconColor: .orange,
actionText: "Firefox Add-ons",
action: {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
)
)
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
// Step 3
CompactStepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
}
.fixedSize(horizontal: false, vertical: true)
Divider()
// Diagnostics
HStack {
Text("Diagnostics:")
.foregroundStyle(.secondary)
Spacer()
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
// Step 3
CompactStepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
}
.fixedSize(horizontal: false, vertical: true)
} header: {
Text("Setup Guide")
}
.font(.footnote)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
Section {
HStack {
Text("Diagnostics:")
.foregroundStyle(.secondary)
Spacer()
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
.font(.footnote)
.padding(8)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
} header: {
Text("Status")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
Spacer()
}
.padding(24)
.formStyle(.grouped)
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
.background(Color(NSColor.windowBackgroundColor))
}
}
@@ -133,57 +155,56 @@ struct CompactStepCardView: View {
var secondaryAction: (() -> Void)? = nil
var body: some View {
VStack(spacing: 12) {
VStack(spacing: 8) {
HStack(alignment: .top) {
// Step Number Badge
ZStack {
Circle()
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 24, height: 24)
.shadow(color: .black.opacity(0.1), radius: 1, y: 1)
.fill(Color.secondary.opacity(0.1))
.frame(width: 20, height: 20)
Text("\(stepNumber)")
.font(.system(.caption, design: .rounded).weight(.bold))
.font(.system(.caption2, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
Spacer()
// Icon
ZStack {
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: 6)
.fill(iconColor.opacity(0.15))
.frame(width: 32, height: 32)
.frame(width: 28, height: 28)
Image(systemName: icon)
.font(.system(size: 16))
.font(.system(size: 14))
.foregroundStyle(iconColor)
}
}
// Text Content
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Text(description)
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Spacer(minLength: 4)
// Action Button
VStack(spacing: 8) {
VStack(spacing: 6) {
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.caption.weight(.medium))
.font(.caption2.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.padding(.vertical, 4)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(6)
.cornerRadius(4)
}
.buttonStyle(.plain)
}
@@ -191,14 +212,14 @@ struct CompactStepCardView: View {
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
Button(action: secondaryAction) {
Text(secondaryActionText)
.font(.caption.weight(.medium))
.font(.caption2.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.padding(.vertical, 4)
.background(Color(nsColor: .controlBackgroundColor))
.foregroundColor(.primary)
.cornerRadius(6)
.cornerRadius(4)
.overlay(
RoundedRectangle(cornerRadius: 6)
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
@@ -206,15 +227,15 @@ struct CompactStepCardView: View {
}
}
}
.padding(12)
.padding(10)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: 10)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
@@ -4,38 +4,39 @@ struct KeychainAccessCard: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
HStack(spacing: 16) {
HStack(spacing: settings.isKeychainAccessGranted ? 12 : 16) {
ZStack {
RoundedRectangle(cornerRadius: 10)
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 6 : 10)
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
.frame(width: 36, height: 36)
.frame(width: settings.isKeychainAccessGranted ? 28 : 36, height: settings.isKeychainAccessGranted ? 28 : 36)
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
.font(.system(size: 18))
.font(.system(size: settings.isKeychainAccessGranted ? 14 : 18))
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
}
VStack(alignment: .leading, spacing: 2) {
Text("Keychain Access")
.font(.headline)
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(settings.isKeychainAccessGranted ? "Keychain Access Granted" : "Keychain Access")
.font(settings.isKeychainAccessGranted ? .subheadline.weight(.medium) : .headline)
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .primary)
if !settings.isKeychainAccessGranted {
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: 16)
Spacer(minLength: settings.isKeychainAccessGranted ? 8 : 16)
if settings.isKeychainAccessGranted {
Label("Granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline.weight(.medium))
Button(role: .destructive) {
settings.revokeKeychainAccess()
} label: {
Text("Revoke")
}
.controlSize(.small)
} else {
Button {
settings.grantKeychainAccess()
@@ -51,14 +52,14 @@ struct KeychainAccessCard: View {
.buttonStyle(.plain)
}
}
.padding(12)
.padding(settings.isKeychainAccessGranted ? 8 : 12)
.background(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
@@ -6,18 +6,15 @@ struct LocationsSettingsPane: View {
var body: some View {
Form {
Section {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
BulkDirectoryPickerRow()
GridRow {
Divider()
.gridCellColumns(2)
}
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
Section(footer: Text("When enabled, you can choose the download location each time you add a download. Otherwise, files are saved automatically.")) {
Toggle("Ask where to save each file before downloading", isOn: $settings.askWhereToSaveEachFile)
}
Section(footer: Text("Folders will be created automatically when saving.")) {
BulkDirectoryPickerRow()
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
@@ -26,8 +23,6 @@ struct LocationsSettingsPane: View {
settings.resetDirectories()
}
}
} footer: {
Text("Folders will be created automatically when applied.")
}
}
.formStyle(.grouped)
@@ -42,31 +37,20 @@ struct DirectoryPickerRow: View {
@State private var message = ""
var body: some View {
GridRow(alignment: .firstTextBaseline) {
Label(category.rawValue, systemImage: category.symbolName)
.gridColumnAlignment(.leading)
LabeledContent {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TextField("", text: $path, prompt: Text("Folder path"))
TextField("Folder path", text: $path, prompt: Text("Folder path"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced))
.onSubmit {
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")
}
}
@@ -76,6 +60,8 @@ struct DirectoryPickerRow: View {
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
}
}
} label: {
Label(category.rawValue, systemImage: category.symbolName)
}
.onAppear {
syncPathFromSettings()
@@ -175,31 +161,20 @@ struct BulkDirectoryPickerRow: View {
@State private var message = ""
var body: some View {
GridRow(alignment: .firstTextBaseline) {
Label("All Categories", systemImage: "folder.fill.badge.plus")
.gridColumnAlignment(.leading)
LabeledContent {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TextField("", text: $path, prompt: Text("Base folder path"))
TextField("Base folder path", text: $path, prompt: Text("Base folder path"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced))
.onSubmit {
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")
}
}
@@ -207,12 +182,10 @@ struct BulkDirectoryPickerRow: View {
Text(message)
.font(.caption)
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
} else {
Text("Automatically creates all category folders in the selected path.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} label: {
Label("All Categories", systemImage: "folder.fill.badge.plus")
}
}
@@ -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."
}
}
}
@@ -1,6 +1,32 @@
import AppKit
import SwiftUI
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
case downloads = "Downloads"
case lookAndFeel = "Look and feel"
case network = "Network"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case integration = "Integrations"
case about = "About"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .integration: "puzzlepiece.extension"
case .about: "info.circle"
}
}
}
struct SettingsPaneContainer: View {
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
@@ -1,11 +1,23 @@
import SwiftUI
import AppKit
struct SiteLoginsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
@State private var editingLoginID: UUID?
@State private var searchText = ""
@State private var selection = Set<SiteLogin.ID>()
@State private var editingLogin: SiteLogin?
@State private var showEditor = false
var filteredLogins: [SiteLogin] {
if searchText.isEmpty {
return settings.siteLogins
} else {
return settings.siteLogins.filter {
$0.urlPattern.localizedCaseInsensitiveContains(searchText) ||
$0.username.localizedCaseInsensitiveContains(searchText)
}
}
}
var body: some View {
Form {
@@ -16,85 +28,204 @@ struct SiteLoginsSettingsPane: View {
.listRowInsets(EdgeInsets())
if settings.isKeychainAccessGranted {
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
if editingLoginID != nil {
Button("Cancel Edit") {
resetForm()
}
}
Button {
settings.saveSiteLogin(
id: editingLoginID,
urlPattern: urlPattern,
username: username,
password: password
)
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
resetForm()
}
} label: {
Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
}
.buttonStyle(.borderedProminent)
}
}
Section("Saved Logins") {
if settings.siteLogins.isEmpty {
Text("No saved logins.")
.foregroundStyle(.secondary)
} else {
List {
ForEach(settings.siteLogins) { login in
HStack {
Image(systemName: "key.horizontal")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
Section {
VStack(spacing: 0) {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search Logins", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button {
edit(login)
searchText = ""
} label: {
Label("Edit", systemImage: "pencil")
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
.buttonStyle(.plain)
}
}
.onDelete(perform: settings.deleteSiteLogins)
.padding(8)
.background(Color(NSColor.textBackgroundColor))
Divider()
if settings.siteLogins.isEmpty {
ContentUnavailableView("No saved logins", systemImage: "key")
.frame(minHeight: 250)
} else if filteredLogins.isEmpty {
ContentUnavailableView.search(text: searchText)
.frame(minHeight: 250)
} else {
Table(filteredLogins, selection: $selection) {
TableColumn("URL Pattern") { login in
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
.contextMenu {
contextMenuActions(for: login)
}
}
TableColumn("Username") { login in
Text(login.username)
.contextMenu {
contextMenuActions(for: login)
}
}
}
.frame(minHeight: 200, idealHeight: 250, maxHeight: 300)
}
Divider()
HStack {
Button {
editingLogin = nil
showEditor = true
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 4)
Divider()
.frame(height: 16)
Button {
deleteSelected()
} label: {
Image(systemName: "minus")
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 4)
.disabled(selection.isEmpty)
Spacer()
}
.padding(.vertical, 4)
.padding(.horizontal, 4)
.background(Color(NSColor.windowBackgroundColor))
}
.frame(minHeight: 180)
}
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
} header: {
Text("Saved Logins")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
}
.formStyle(.grouped)
.sheet(isPresented: $showEditor) {
LoginEditorSheet(login: editingLogin)
}
}
private func edit(_ login: SiteLogin) {
editingLoginID = login.id
urlPattern = login.urlPattern
username = login.username
password = ""
@ViewBuilder
private func contextMenuActions(for login: SiteLogin) -> some View {
Button("Edit") {
editingLogin = login
showEditor = true
}
Divider()
Button("Copy Username") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(login.username, forType: .string)
}
Button("Copy Password") {
if let password = KeychainCredentialStore.password(for: login.id) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(password, forType: .string)
}
}
Divider()
Button("Delete", role: .destructive) {
if let index = settings.siteLogins.firstIndex(where: { $0.id == login.id }) {
settings.deleteSiteLogins(at: IndexSet(integer: index))
}
selection.remove(login.id)
}
}
private func resetForm() {
editingLoginID = nil
urlPattern = ""
username = ""
password = ""
private func deleteSelected() {
let indices = settings.siteLogins.enumerated().compactMap { index, login in
selection.contains(login.id) ? index : nil
}
settings.deleteSiteLogins(at: IndexSet(indices))
selection.removeAll()
}
}
struct LoginEditorSheet: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var settings: AppSettings
let login: SiteLogin?
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
var body: some View {
VStack(spacing: 0) {
Text(login == nil ? "Add Login" : "Edit Login")
.font(.headline)
.padding()
Form {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField(login == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
}
.padding(.horizontal)
.padding(.bottom)
if !settings.message.isEmpty && (settings.message.hasPrefix("Add a") || settings.message.hasPrefix("A login") || settings.message.hasPrefix("Could not")) {
Text(settings.message)
.foregroundColor(.red)
.font(.caption)
.padding(.bottom)
}
HStack {
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Button(login == nil ? "Add" : "Save") {
settings.message = ""
settings.saveSiteLogin(
id: login?.id,
urlPattern: urlPattern,
username: username,
password: password
)
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
dismiss()
}
}
.keyboardShortcut(.defaultAction)
}
.padding()
}
.frame(width: 400)
.onAppear {
if let login = login {
urlPattern = login.urlPattern
username = login.username
}
settings.message = ""
}
}
}
+5 -28
View File
@@ -21,31 +21,6 @@ enum DownloadSidebarFilter: Hashable {
}
}
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
case downloads = "Downloads"
case lookAndFeel = "Look and feel"
case network = "Network"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case integration = "Integrations"
case about = "About"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .integration: "puzzlepiece.extension"
case .about: "info.circle"
}
}
}
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
@@ -57,6 +32,7 @@ enum SidebarSelection: Hashable {
struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Environment(\.controlActiveState) private var controlActiveState
@Binding var selection: SidebarSelection
@State private var queueBeingRenamed: DownloadQueue?
@State private var queueBeingRemoved: DownloadQueue?
@@ -95,6 +71,7 @@ struct SidebarView: View {
selection = .queue(queue.id)
} label: {
Label("Add new queue", systemImage: "plus")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
@@ -121,8 +98,8 @@ struct SidebarView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(selection == .settings ? Color.accentColor : Color.clear)
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
.background(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.accentColor : Color.secondary.opacity(0.5)) : Color.clear)
.foregroundStyle(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.white : Color.primary) : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 8)
.padding(.vertical, 8)
@@ -163,7 +140,7 @@ struct SidebarView: View {
Button("Delete Queue", role: .destructive) {
controller.removeQueue(id: queue.id)
if selection == .queue(queue.id) {
selection = .downloads(.unfinished)
selection = .downloads(.all)
}
queueBeingRemoved = nil
}
+69 -28
View File
@@ -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