Compare commits

...

9 Commits

Author SHA1 Message Date
nimbold 5f99bb791e docs: add 0.5.4 release notes 2026-06-04 15:46:21 +03:30
nimbold 0cbb982f5b fix: harden download workflows 2026-06-04 15:41:38 +03:30
nimbold 0040c23050 style: refine UI layouts, toolbars, and chunk map aesthetics
- Make global and contextual Start/Stop toolbar buttons mutually exclusive in main window
- Relocate Add Downloads primary action buttons from top toolbar to standard bottom Action Bar
- Expand Download Properties window width for better readability and condense font sizes
- Wrap Advanced Transfer settings in a native DisclosureGroup to reduce scrolling
- Redesign Chunk Map with rounded corners, larger boxes, and system accent colors
2026-06-04 14:29:13 +03:30
nimbold 062c4aec61 docs: release version 0.5.3
- Update CHANGELOG.md with 0.5.3 patch features and fixes
- Document Chunk Map and Drag-and-Drop in README.md
2026-06-04 13:45:10 +03:30
nimbold 8e32762217 feat: implement chunk map inspector, drag-and-drop, and UI refinements
- Add ChunkMapView to visualize active segmented downloads using aria2 RPC
- Add drag-and-drop support for URLs and text files in main window and dock
- Refactor settings panes to use native macOS HIG Form and .toolbar layouts
- Fix DNS rebinding vulnerability by validating Host header in local server
- Fix memory leak in console buffer by capping to 512KB
- Fix UI hang during aria2c version check by running it in detached task
2026-06-04 13:34:28 +03:30
Nima fec18deb1a Update README to mention Firefox extension only 2026-06-04 06:30:09 +03:30
nimbold bf0d1f6ce1 docs: update app screenshots 2026-06-04 06:27:45 +03:30
nimbold d9c7da23e9 fix: resolve settings tab layout and hit-testing issues 2026-06-04 06:03:30 +03:30
nimbold 2f5e593d0b feat: add icons to settings capsule tabs 2026-06-04 05:47:55 +03:30
28 changed files with 756 additions and 237 deletions
+45
View File
@@ -5,6 +5,51 @@ 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.5.4] - 2026-06-04
### New features
- Added direct double-click access to Download Properties for unfinished downloads.
- Added a `make verify` command for local build and Firefox extension manifest checks.
### Changes
- Updated Firefox integration to probe the same local fallback ports used by the app.
- Updated global speed limiting so changes apply to active downloads through `aria2` RPC.
- Declared SwiftPM resources and added development fallbacks for app icons and Firefox extension copying.
- Narrowed saved site-login matching so plain host patterns match exact hosts unless a wildcard is used.
### Fixes
- Fixed browser handoff failures when the default local extension port is unavailable.
- Fixed dropped `Referer` headers from browser extension requests.
- Fixed scheduler configurations that could be enabled without any runnable queue target.
- Fixed unsafe file names from URLs, metadata responses, and manual property edits.
- Fixed a possible duplicate-open glitch when double-clicking unfinished downloads.
## [0.5.3] - 2026-06-04
### New features
- Added `ChunkMapView` to visualize active segmented downloads using `aria2` RPC with minimal performance overhead.
- Added seamless drag-and-drop support for URLs and text files in the main window and dock icon.
### Changes
- Refactored all Settings panes to use standard macOS HIG `Form` and `.toolbar` layouts.
- Updated the "Add Downloads" dialog to use native macOS `.toolbar` with integrated cancel actions.
### Fixes
- Fixed a DNS rebinding vulnerability by rigorously validating the `Host` header within the local extension server.
- Fixed a potentially unbounded memory leak in the download console buffer by introducing a strict 512KB cap.
- Fixed an intermittent UI hang during the `aria2c` version check by fully decoupling the process execution into a detached background task.
## [0.5.2] - 2026-06-04
### Fixes
- Fixed the hit-testing area on Settings tabs so the entire tab frame is clickable, not just the text/icon.
- Re-architected the Settings tab bar layout to perfectly distribute available horizontal space, ensuring symmetric right/left padding.
## [0.5.1] - 2026-06-04
### Changes
- Added sleek SF Symbol icons to the Settings capsule tabs to improve visual scannability and modernize the interface.
## [0.5.0] - 2026-06-04
### New features
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build app dmg run clean
.PHONY: build app dmg run verify clean
build:
swift build -c release
@@ -12,6 +12,9 @@ dmg: app
run:
swift run Firelink
verify:
Scripts/verify.sh
clean:
swift package clean
rm -rf build dist
+4 -1
View File
@@ -13,7 +13,10 @@ let package = Package(
targets: [
.executableTarget(
name: "Firelink",
path: "Sources/Firelink"
path: "Sources/Firelink",
resources: [
.process("Assets.xcassets")
]
)
]
)
+5 -3
View File
@@ -17,7 +17,7 @@
### 📸 Screenshots
Dark mode is shown by default. Light mode is tucked away below so the README stays easy to scan.
Dark mode is shown by default with privacy-safe example downloads. Light mode is tucked away below so the README stays easy to scan.
<div align="center">
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window in dark theme with sample downloads" width="32%" />
@@ -45,10 +45,12 @@ Dark mode is shown by default. Light mode is tucked away below so the README sta
-**High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, Speed Limiter, and system sleep prevention during active downloads.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
---
@@ -70,7 +72,7 @@ make app && open build/Firelink.app
## 🧩 Browser Extension
Find the companion browser extension (Safari, Chrome, Firefox) at:
Find the companion browser extension (Firefox) at:
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 552 KiB

+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
swift build
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
+43 -10
View File
@@ -38,9 +38,10 @@ struct AddDownloadsView: View {
}
.padding(12)
}
Divider()
actionBar
.padding(16)
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540)
.onChange(of: linkText) { _, newValue in
@@ -51,6 +52,7 @@ struct AddDownloadsView: View {
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
controller.pendingAddQueueID = nil
if let text = controller.pendingPasteboardText {
applyPendingReferer()
linkText = text
controller.pendingPasteboardText = nil
}
@@ -224,27 +226,37 @@ struct AddDownloadsView: View {
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if metadataTask != nil {
Button {
metadataTask?.cancel()
metadataTask = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
Spacer()
Button("Cancel") {
dismiss()
}
Button {
.keyboardShortcut(.cancelAction)
Button("Add to Queue") {
addDownloads(start: false)
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
.disabled(!canAddDownloads)
Button {
Button("Start Downloads") {
addDownloads(start: true)
} label: {
Label("Start Downloads", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
.keyboardShortcut(.defaultAction)
}
.padding(10)
.background(.bar)
}
private var advancedTransferSection: some View {
@@ -377,6 +389,24 @@ struct AddDownloadsView: View {
}
}
private func applyPendingReferer() {
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
!referer.isEmpty,
URL(string: referer) != nil else {
controller.pendingReferer = nil
return
}
let refererHeader = "Referer: \(referer)"
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? refererHeader
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
}
controller.pendingReferer = nil
}
private func refreshMetadata(for text: String) {
let urls = DownloadURLParser.parse(text)
metadataTask?.cancel()
@@ -414,6 +444,9 @@ struct AddDownloadsView: View {
}
}
}
await MainActor.run {
metadataTask = nil
}
}
}
@@ -444,7 +477,7 @@ struct AddDownloadsView: View {
var savedHosts = Set<String>()
for item in pendingDownloads {
if let host = item.url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: "*.\(host)", username: cleanUsername, password: authPassword)
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
+1 -1
View File
@@ -285,7 +285,7 @@ final class AppSettings: ObservableObject {
return absolute.contains(normalizedPattern)
}
return host == normalizedPattern || host.hasSuffix(".\(normalizedPattern)")
return host == normalizedPattern
}
private static func defaultDirectories() -> [DownloadCategory: String] {
+92 -24
View File
@@ -1,12 +1,29 @@
import Foundation
import CFNetwork
import Network
final class Aria2DownloadEngine {
struct Handle {
let processIdentifier: Int32
let rpcPort: Int
let rpcSecret: String
let cancel: @Sendable () -> Void
}
static func findFreePort() -> Int {
var port: UInt16 = 6800
let parameters = NWParameters.tcp
for p in 6800...6900 {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(p))!)
if let listener = try? NWListener(using: parameters) {
listener.cancel()
port = UInt16(p)
break
}
}
return Int(port)
}
enum EngineError: LocalizedError {
case executableNotFound
case launchFailed(String)
@@ -57,30 +74,38 @@ final class Aria2DownloadEngine {
return nil
}
static func versionString() -> String? {
static func versionString() async -> String? {
guard let executableURL = findExecutable() else { return nil }
let process = Process()
let outputPipe = Pipe()
process.executableURL = executableURL
process.arguments = ["--version"]
process.standardOutput = outputPipe
process.standardError = Pipe()
return await Task.detached {
let process = Process()
let outputPipe = Pipe()
process.executableURL = executableURL
process.arguments = ["--version"]
process.standardOutput = outputPipe
process.standardError = nil
process.standardInput = nil // ensure no stdin is inherited that could cause blocking
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
do {
try process.run()
// Close the write file handle in the parent process immediately
// This guarantees readToEnd() won't hang waiting for the parent itself
outputPipe.fileHandleForWriting.closeFile()
let data = try? outputPipe.fileHandleForReading.readToEnd()
process.waitUntilExit()
guard process.terminationStatus == 0, let data = data else { return nil }
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return output
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.first
.map(String.init)
} catch {
return nil
}
let output = String(data: data, encoding: .utf8) ?? ""
return output
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.first
.map(String.init)
} catch {
return nil
}
}.value
}
func start(
@@ -99,12 +124,17 @@ final class Aria2DownloadEngine {
withIntermediateDirectories: true
)
let rpcPort = Self.findFreePort()
let rpcSecret = UUID().uuidString
let process = Process()
process.executableURL = executableURL
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
rpcSecret: rpcSecret
)
let inputPipe = Pipe()
@@ -165,17 +195,43 @@ final class Aria2DownloadEngine {
throw EngineError.launchFailed(error.localizedDescription)
}
return Handle(processIdentifier: process.processIdentifier) {
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
if process.isRunning {
process.terminate()
}
}
}
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
let limitValue = speedLimitKiBPerSecond.map { "\($0)K" } ?? "0"
let payload: [String: Any] = [
"jsonrpc": "2.0",
"method": "aria2.changeGlobalOption",
"id": UUID().uuidString,
"params": [
"token:\(handle.rpcSecret)",
["max-download-limit": limitValue]
]
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
request.timeoutInterval = 3
_ = try? await URLSession.shared.data(for: request)
}
private func arguments(
for item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?
speedLimitKiBPerSecond: Int?,
rpcPort: Int,
rpcSecret: String
) throws -> [String] {
var arguments = [
"--continue=true",
@@ -191,7 +247,11 @@ final class Aria2DownloadEngine {
"--connect-timeout=30",
"--timeout=60",
"--uri-selector=adaptive",
"--input-file=-"
"--input-file=-",
"--enable-rpc=true",
"--rpc-listen-port=\(rpcPort)",
"--rpc-secret=\(rpcSecret)",
"--rpc-listen-all=false"
]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
@@ -342,6 +402,11 @@ final class Aria2DownloadEngine {
final class LockedDataBuffer: @unchecked Sendable {
private let lock = NSLock()
private var storage = Data()
private let maxBytes: Int
init(maxBytes: Int = 512 * 1024) {
self.maxBytes = maxBytes
}
var data: Data {
lock.withLock { storage }
@@ -350,6 +415,9 @@ final class LockedDataBuffer: @unchecked Sendable {
func append(_ data: Data) {
lock.withLock {
storage.append(data)
if storage.count > maxBytes {
storage.removeFirst(storage.count - maxBytes)
}
}
}
}
+160
View File
@@ -0,0 +1,160 @@
import SwiftUI
struct ChunkMapView: View {
let item: DownloadItem
@State private var bitfield: String = ""
@State private var numPieces: Int = 0
@State private var pollTask: Task<Void, Never>?
@State private var isVisible = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if numPieces > 0 {
ChunkGrid(bitfield: bitfield, numPieces: numPieces)
} else {
Text("Loading chunk data...")
.foregroundStyle(.secondary)
.font(.caption)
}
}
.onAppear {
isVisible = true
startPolling()
}
.onDisappear {
isVisible = false
pollTask?.cancel()
}
.onChange(of: item.status) { _, status in
if status != .downloading {
pollTask?.cancel()
} else if isVisible && pollTask == nil {
startPolling()
}
}
}
private func startPolling() {
pollTask?.cancel()
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
pollTask = Task {
while !Task.isCancelled {
await fetchStatus(port: port, secret: secret)
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
break
}
}
}
}
private func fetchStatus(port: Int, secret: String) async {
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [
"jsonrpc": "2.0",
"method": "aria2.tellActive",
"id": "1",
"params": ["token:\(secret)", ["bitfield", "numPieces"]]
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
request.httpBody = data
do {
let (responseData, _) = try await URLSession.shared.data(for: request)
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
let result = json["result"] as? [[String: Any]],
let active = result.first else {
return
}
let fetchedBitfield = active["bitfield"] as? String ?? ""
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
await MainActor.run {
self.bitfield = fetchedBitfield
self.numPieces = fetchedNumPieces
}
} catch {
// Ignore errors
}
}
}
struct ChunkGrid: View {
let bitfield: String
let numPieces: Int
private var pieces: [Bool] {
var result = [Bool]()
result.reserveCapacity(numPieces)
for char in bitfield {
if let val = char.hexDigitValue {
for i in (0..<4).reversed() {
if result.count < numPieces {
result.append((val & (1 << i)) != 0)
}
}
}
}
while result.count < numPieces {
result.append(false)
}
return result
}
var body: some View {
let itemPieces = pieces
Canvas { context, size in
let boxSize: CGFloat = 10
let spacing: CGFloat = 2
let cornerSize = CGSize(width: 2, height: 2)
let width = size.width
let x: CGFloat = 0
let y: CGFloat = 0
let completedPath = Path { p in
var cx = x
var cy = y
for piece in itemPieces {
if piece {
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
}
cx += boxSize
if cx + boxSize > width {
cx = 0
cy += boxSize
}
}
}
let pendingPath = Path { p in
var cx: CGFloat = 0
var cy: CGFloat = 0
for piece in itemPieces {
if !piece {
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
}
cx += boxSize
if cx + boxSize > width {
cx = 0
cy += boxSize
}
}
}
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
context.fill(completedPath, with: .color(Color.accentColor))
}
.frame(minHeight: 140)
}
}
+59 -41
View File
@@ -21,6 +21,32 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
openWindow(id: "add-downloads")
}
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
for provider in providers {
if provider.canLoadObject(ofClass: URL.self) {
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url = url {
DispatchQueue.main.async {
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
}
}
} else if provider.canLoadObject(ofClass: String.self) {
_ = provider.loadObject(ofClass: String.self) { text, _ in
if let text = text {
DispatchQueue.main.async {
controller.pendingPasteboardText = text
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
}
}
}
}
return true
}
}
@ViewBuilder
@@ -78,51 +104,34 @@ struct ContentView: View {
}
}
ToolbarItem {
Button {
controller.startQueue(queueID: queueID)
} label: {
Label("Start Queue", systemImage: "play.fill")
}
}
ToolbarItem {
Button {
controller.pauseActiveDownloads(queueID: queueID)
} label: {
Label("Stop Downloads", systemImage: "stop.fill")
}
.disabled(!hasActiveDownloads(in: queueID))
}
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
} label: {
Label("Stop", systemImage: "stop.fill")
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)
}
}
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
} label: {
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
}
.disabled(!canStop)
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
Button {
if selectedItems.isEmpty {
controller.startQueue(queueID: queueID)
} else {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Start Queue" : "Start", systemImage: "play.fill")
}
.disabled(!canStart)
}
}
.background {
@@ -176,6 +185,7 @@ struct ContentView: View {
private func handlePaste(queueID: UUID?) {
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
controller.pendingPasteboardText = text
controller.pendingReferer = nil
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
}
@@ -192,6 +202,14 @@ struct ContentView: View {
return controller.activeCount > 0
}
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
if let queueID {
return controller.queueItems(for: queueID).contains { $0.status == .queued }
}
return controller.queuedCount > 0
}
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
switch filter {
case .all:
+47 -5
View File
@@ -9,6 +9,7 @@ final class DownloadController: ObservableObject {
@Published var queues: [DownloadQueue] = [.main]
@Published var engineMessage = ""
@Published var pendingPasteboardText: String?
@Published var pendingReferer: String?
var pendingAddQueueID: UUID?
private let settings: AppSettings
@@ -38,6 +39,25 @@ final class DownloadController: ObservableObject {
}
}
.store(in: &cancellables)
settings.$globalSpeedLimitKiBPerSecond
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor in
self?.applySpeedLimitsToActiveDownloads()
}
}
.store(in: &cancellables)
settings.$maxConcurrentDownloads
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor in
self?.applySpeedLimitsToActiveDownloads()
self?.pumpQueue()
}
}
.store(in: &cancellables)
$downloads
.dropFirst()
@@ -131,10 +151,11 @@ final class DownloadController: ObservableObject {
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
let items = pendingDownloads.map { pending in
DownloadItem(
let fileName = FileClassifier.sanitizedFileName(pending.fileName)
return DownloadItem(
url: pending.url,
fileName: pending.fileName,
category: pending.category,
fileName: fileName,
category: FileClassifier.category(forFileName: fileName),
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: credentials ?? settings.credentials(for: pending.url),
@@ -496,6 +517,8 @@ final class DownloadController: ObservableObject {
)
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
@@ -524,8 +547,8 @@ final class DownloadController: ObservableObject {
) {
update(id) {
$0.url = url
$0.fileName = fileName
$0.category = FileClassifier.category(forFileName: fileName)
$0.fileName = FileClassifier.sanitizedFileName(fileName)
$0.category = FileClassifier.category(forFileName: $0.fileName)
$0.destinationDirectory = destinationDirectory
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
$0.credentials = credentials
@@ -541,6 +564,7 @@ final class DownloadController: ObservableObject {
} else if credentials == nil {
KeychainCredentialStore.deletePassword(for: id)
}
applySpeedLimitToActiveDownload(id: id)
saveDownloads()
}
@@ -566,6 +590,24 @@ final class DownloadController: ObservableObject {
}
}
private func applySpeedLimitsToActiveDownloads() {
for item in downloads where item.status == .downloading {
applySpeedLimitToActiveDownload(id: item.id)
}
}
private func applySpeedLimitToActiveDownload(id: UUID) {
guard let handle = activeHandles[id],
let item = downloads.first(where: { $0.id == id }) else {
return
}
let limit = effectiveSpeedLimitKiBPerSecond(for: item)
Task {
await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit)
}
}
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
if let queueID {
let normalizedID = normalizedQueueID(queueID)
@@ -1,8 +1,9 @@
import Foundation
enum DownloadURLParser {
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
static func parse(_ text: String) -> [URL] {
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
@@ -71,8 +72,8 @@ enum DownloadMetadataFetcher {
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
let fileName = fileName(fromContentDisposition: disposition) {
pending.fileName = fileName
pending.category = FileClassifier.category(forFileName: fileName)
pending.fileName = FileClassifier.sanitizedFileName(fileName)
pending.category = FileClassifier.category(forFileName: pending.fileName)
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
}
+18 -11
View File
@@ -46,6 +46,7 @@ struct DownloadPropertiesView: View {
@State private var cookieText: String
@State private var mirrorText: String
@State private var errorMessage = ""
@State private var showsAdvancedTransfer = false
init(item: DownloadItem) {
self.item = item
@@ -83,11 +84,11 @@ struct DownloadPropertiesView: View {
Form {
Section("Download") {
TextField("URL", text: $urlText)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
TextField("File name", text: $fileName)
HStack {
TextField("Save location", text: $destinationPath)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
Button {
selectDestination()
} label: {
@@ -124,7 +125,7 @@ struct DownloadPropertiesView: View {
}
}
Section("Advanced Transfer") {
DisclosureGroup("Advanced Transfer", isExpanded: $showsAdvancedTransfer) {
Toggle("Checksum", isOn: $checksumEnabled)
if checksumEnabled {
Picker("Algorithm", selection: $checksumAlgorithm) {
@@ -133,24 +134,24 @@ struct DownloadPropertiesView: View {
}
}
TextField("Expected digest", text: $checksumValue)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
}
VStack(alignment: .leading, spacing: 6) {
Text("Headers")
TextEditor(text: $headerText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
.font(.system(.callout, design: .monospaced))
.frame(minHeight: 60)
}
TextField("Cookies", text: $cookieText)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
VStack(alignment: .leading, spacing: 6) {
Text("Mirrors")
TextEditor(text: $mirrorText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
.font(.system(.callout, design: .monospaced))
.frame(minHeight: 60)
}
}
@@ -158,6 +159,12 @@ struct DownloadPropertiesView: View {
ProgressView(value: item.progress)
InfoGrid(item: item)
}
if item.status == .downloading && item.rpcPort != nil {
Section("Chunk Map") {
ChunkMapView(item: item)
}
}
}
.formStyle(.grouped)
@@ -181,7 +188,7 @@ struct DownloadPropertiesView: View {
.padding(14)
.background(.bar)
}
.frame(width: 620, height: 760)
.frame(width: 720, height: 720)
}
private var matchingLoginText: String {
@@ -214,7 +221,7 @@ struct DownloadPropertiesView: View {
return
}
let cleanFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanFileName = FileClassifier.sanitizedFileName(fileName)
guard !cleanFileName.isEmpty else {
errorMessage = "File name cannot be empty."
return
+58 -34
View File
@@ -37,70 +37,75 @@ struct DownloadTable: View {
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)
doubleClickableCell(for: item) {
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)
}
.draggable(item.id.uuidString)
}
}
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
doubleClickableCell(for: item) {
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 80, ideal: 100)
TableColumn("Progress", value: \.progress) { item in
progressBarCell(for: item)
doubleClickableCell(for: item) {
progressBarCell(for: item)
}
}
.width(min: 100, ideal: 115)
TableColumn("Status", value: \.status.rawValue) { item in
Text(item.status.rawValue)
doubleClickableCell(for: item) {
Text(item.status.rawValue)
}
}
.width(min: 80, ideal: 105)
TableColumn("Speed", value: \.speedText) { item in
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
doubleClickableCell(for: item) {
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.etaText) { item in
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
doubleClickableCell(for: item) {
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("Date Added", value: \.createdAt) { item in
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
doubleClickableCell(for: item) {
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 100, ideal: 155)
}
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
for itemID in itemIDs {
if let item = controller.downloads.first(where: { $0.id == itemID }) {
if item.status == .completed {
openFile(item)
} else {
openWindow(value: item.id)
}
}
}
}
.overlay {
if items.isEmpty {
@@ -151,6 +156,25 @@ struct DownloadTable: View {
}
}
private func doubleClickableCell<Content: View>(
for item: DownloadItem,
@ViewBuilder content: () -> Content
) -> some View {
content()
.contentShape(Rectangle())
.onTapGesture(count: 2) {
performPrimaryAction(for: item)
}
}
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
openFile(item)
} else {
openWindow(value: item.id)
}
}
@ViewBuilder
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
+22 -2
View File
@@ -57,10 +57,30 @@ enum FileClassifier {
static func fileName(from url: URL) -> String {
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
if !pathName.isEmpty, pathName != "/" {
return pathName
return sanitizedFileName(pathName)
}
let host = url.host(percentEncoded: false) ?? "download"
return "\(host)-download"
return sanitizedFileName("\(host)-download")
}
static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String {
let separators = CharacterSet(charactersIn: "/\\")
let invalidCharacters = separators
.union(.newlines)
.union(.controlCharacters)
let components = rawValue
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: invalidCharacters)
.filter { !$0.isEmpty }
let clean = components.joined(separator: "_")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty, clean != ".", clean != ".." else {
return fallback
}
return String(clean.prefix(255))
}
}
+21 -1
View File
@@ -29,6 +29,11 @@ struct FirelinkApp: App {
.environmentObject(schedulerController)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.onOpenURL { url in
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
@@ -83,7 +88,7 @@ struct FirelinkApp: App {
.environmentObject(controller)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"),
guard let url = menuBarIconURL(),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 21, height: 21)
img.isTemplate = true
@@ -95,4 +100,19 @@ struct FirelinkApp: App {
}
}
}
private func menuBarIconURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeIcon = projectRoot
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
}
}
+20 -3
View File
@@ -4,25 +4,30 @@ import AppKit
final class LocalExtensionServer: @unchecked Sendable {
private enum Constants {
static let port = NWEndpoint.Port(rawValue: 6412)!
static let portRange = 6412...6422
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let extensionRequestHeader = "x-firelink-extension"
static let extensionRequestToken = "firelink-extension-v1"
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
private let listener: NWListener
private let downloadController: DownloadController
private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16
init?(downloadController: DownloadController) {
self.downloadController = downloadController
let parameters = NWParameters.tcp
var createdListener: NWListener?
for portValue in 6412...6422 {
var selectedPort: UInt16?
for portValue in Constants.portRange {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
do {
createdListener = try NWListener(using: parameters)
selectedPort = UInt16(portValue)
break
} catch {
continue
@@ -35,6 +40,7 @@ final class LocalExtensionServer: @unchecked Sendable {
}
self.listener = createdListener
self.port = selectedPort ?? 6412
}
func start() {
@@ -95,7 +101,7 @@ final class LocalExtensionServer: @unchecked Sendable {
headers.append("Access-Control-Allow-Origin: \(origin)")
headers.append("Vary: Origin")
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
headers.append("Access-Control-Allow-Headers: Content-Type")
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
}
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
@@ -118,6 +124,12 @@ final class LocalExtensionServer: @unchecked Sendable {
return .notFound
}
let host = request.header(named: "host") ?? ""
let isLocalhost = host.hasPrefix("127.0.0.1:") || host.hasPrefix("localhost:") || host == "127.0.0.1" || host == "localhost"
guard isLocalhost else {
return .forbidden
}
if request.method == "OPTIONS" {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
@@ -126,6 +138,10 @@ final class LocalExtensionServer: @unchecked Sendable {
return .methodNotAllowed
}
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
return .forbidden
}
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
return .unsupportedMediaType
}
@@ -155,6 +171,7 @@ 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)
}
+2
View File
@@ -186,6 +186,8 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var lastTryAt: Date?
var autoResumeOnLaunch: Bool?
var queueID: UUID?
var rpcPort: Int?
var rpcSecret: String?
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
+12 -3
View File
@@ -35,7 +35,7 @@ struct SchedulerSettings: Codable, Equatable {
var isEveryday: Bool = true
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
var postQueueAction: PostQueueAction = .doNothing
var targetQueueIDs: Set<UUID> = []
var targetQueueIDs: Set<UUID> = [DownloadQueue.mainQueueID]
}
@MainActor
@@ -62,6 +62,9 @@ final class SchedulerController: ObservableObject {
} else {
self.settings = SchedulerSettings()
}
if self.settings.targetQueueIDs.isEmpty {
self.settings.targetQueueIDs = [DownloadQueue.mainQueueID]
}
checkAutomationPermission()
startTimer()
@@ -131,7 +134,8 @@ final class SchedulerController: ObservableObject {
}
private func triggerQueues() {
let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in
let targetQueueIDs = effectiveTargetQueueIDs()
let runnableQueueIDs = targetQueueIDs.filter { queueID in
downloadController.queues.contains(where: { $0.id == queueID }) &&
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
}
@@ -150,7 +154,8 @@ final class SchedulerController: ObservableObject {
private func checkIfRunningFinished() {
guard isRunning else { return }
let hasActiveItems = settings.targetQueueIDs.contains { queueID in
let targetQueueIDs = effectiveTargetQueueIDs()
let hasActiveItems = targetQueueIDs.contains { queueID in
downloadController.queueItems(for: queueID).contains {
$0.status == .queued || $0.status == .downloading
}
@@ -162,6 +167,10 @@ final class SchedulerController: ObservableObject {
}
}
private func effectiveTargetQueueIDs() -> Set<UUID> {
settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs
}
private func performPostAction() {
guard settings.postQueueAction != .doNothing else { return }
+6 -2
View File
@@ -199,7 +199,9 @@ struct SchedulerView: View {
isEveryday = schedulerController.settings.isEveryday
selectedDays = schedulerController.settings.selectedDays
postQueueAction = schedulerController.settings.postQueueAction
targetQueueIDs = schedulerController.settings.targetQueueIDs
targetQueueIDs = schedulerController.settings.targetQueueIDs.isEmpty
? [DownloadQueue.mainQueueID]
: schedulerController.settings.targetQueueIDs
}
private func saveState() {
@@ -208,7 +210,9 @@ struct SchedulerView: View {
schedulerController.settings.isEveryday = isEveryday
schedulerController.settings.selectedDays = selectedDays
schedulerController.settings.postQueueAction = postQueueAction
schedulerController.settings.targetQueueIDs = targetQueueIDs
schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
? [DownloadQueue.mainQueueID]
: targetQueueIDs
schedulerController.saveSettings()
}
}
+124 -91
View File
@@ -6,25 +6,31 @@ struct SettingsPaneContainer: View {
var body: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
HStack(spacing: 4) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
VStack(spacing: 4) {
Image(systemName: filter.symbolName)
.font(.system(size: 16))
Text(filter.rawValue)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(Capsule())
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
Divider()
@@ -293,14 +299,12 @@ private struct AboutSettingsPane: View {
}
private struct EngineSettingsPane: View {
@State private var version = "Checking..."
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
private var version: String {
Aria2DownloadEngine.versionString() ?? "Unavailable"
}
var body: some View {
Form {
Section {
@@ -332,6 +336,9 @@ private struct EngineSettingsPane: View {
}
}
.formStyle(.grouped)
.task {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
}
@@ -442,18 +449,21 @@ private struct LocationsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
VStack(alignment: .leading, spacing: 14) {
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
Form {
Section {
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
}
}
}
}
.formStyle(.grouped)
}
}
@@ -464,25 +474,26 @@ private struct DirectoryPickerRow: View {
@State private var path = ""
var body: some View {
HStack(spacing: 10) {
Label(category.rawValue, systemImage: category.symbolName)
.frame(width: 125, alignment: .leading)
LabeledContent {
HStack(spacing: 8) {
TextField("Folder path", text: Binding(
get: { settings.downloadDirectories[category] ?? path },
set: { newValue in
path = newValue
settings.setDirectory(newValue, for: category)
}
))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
TextField("Folder path", text: Binding(
get: { settings.downloadDirectories[category] ?? path },
set: { newValue in
path = newValue
settings.setDirectory(newValue, for: category)
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
} label: {
Label(category.rawValue, systemImage: category.symbolName)
}
}
@@ -507,62 +518,56 @@ private struct SiteLoginsSettingsPane: View {
@State private var password = ""
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("URL Pattern")
TextField("*.github.com", text: $urlPattern)
.textFieldStyle(.roundedBorder)
}
Form {
Section("Add Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField("Password", text: $password)
GridRow {
Text("Username")
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
}
GridRow {
Text("Password")
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
}
}
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
if settings.message.hasPrefix("Added") {
urlPattern = ""
username = ""
password = ""
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
if settings.message.hasPrefix("Added") {
urlPattern = ""
username = ""
password = ""
}
} label: {
Label("Add Login", systemImage: "plus")
}
} label: {
Label("Add Login", systemImage: "plus")
.buttonStyle(.borderedProminent)
}
.buttonStyle(.borderedProminent)
}
List {
ForEach(settings.siteLogins) { login in
HStack {
Image(systemName: "key.horizontal")
.foregroundStyle(.secondary)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
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)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: settings.deleteSiteLogins)
}
.frame(minHeight: 180)
}
.onDelete(perform: settings.deleteSiteLogins)
}
.frame(minHeight: 180)
}
.formStyle(.grouped)
}
}
@@ -661,7 +666,7 @@ private struct IntegrationSettingsPane: View {
}
private func copyExtensionToDownloads() {
guard let sourceURL = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) else {
guard let sourceURL = bundledFirefoxExtensionURL() else {
installMessage = "The bundled Firefox extension folder was not found."
return
}
@@ -672,7 +677,7 @@ private struct IntegrationSettingsPane: View {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
copiedExtensionURL = destinationURL
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
showCopiedExtensionInFinder()
@@ -681,6 +686,34 @@ private struct IntegrationSettingsPane: View {
}
}
private func bundledFirefoxExtensionURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true)
return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path)
? sourceTreeExtension
: nil
}
private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] {
try fileManager.copyItem(
at: sourceURL.appendingPathComponent(component),
to: destinationURL.appendingPathComponent(component)
)
}
}
private func showCopiedExtensionInFinder() {
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
let manifestURL = folderURL.appendingPathComponent("manifest.json")