From 8e3276221726b5d2b988b29914e988ad096bf395 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:33:24 +0330 Subject: [PATCH] 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 --- Sources/Firelink/AddDownloadsView.swift | 42 +++-- Sources/Firelink/Aria2DownloadEngine.swift | 92 +++++++--- Sources/Firelink/ChunkMapView.swift | 159 ++++++++++++++++++ Sources/Firelink/ContentView.swift | 30 +++- Sources/Firelink/DownloadController.swift | 2 + Sources/Firelink/DownloadPropertiesView.swift | 6 + Sources/Firelink/FirelinkApp.swift | 4 + Sources/Firelink/LocalExtensionServer.swift | 6 + Sources/Firelink/Models.swift | 2 + Sources/Firelink/SettingsView.swift | 149 ++++++++-------- 10 files changed, 376 insertions(+), 116 deletions(-) create mode 100644 Sources/Firelink/ChunkMapView.swift diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 7d2094e..18a387a 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -38,8 +38,8 @@ struct AddDownloadsView: View { } .padding(12) } - - Divider() + } + .toolbar { actionBar } .frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540) @@ -218,16 +218,35 @@ struct AddDownloadsView: View { } } - private var actionBar: some View { - HStack { - Text(actionMessage) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - Spacer() + @ToolbarContentBuilder + private var actionBar: some ToolbarContent { + ToolbarItem(placement: .status) { + HStack { + Text(actionMessage) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if metadataTask != nil { + Button { + metadataTask?.cancel() + metadataTask = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } + } + + ToolbarItemGroup(placement: .primaryAction) { Button { addDownloads(start: false) } label: { @@ -243,8 +262,6 @@ struct AddDownloadsView: View { .buttonStyle(.borderedProminent) .disabled(!canAddDownloads) } - .padding(10) - .background(.bar) } private var advancedTransferSection: some View { @@ -414,6 +431,9 @@ struct AddDownloadsView: View { } } } + await MainActor.run { + metadataTask = nil + } } } diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index 8d0c88f..4adc549 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -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,7 +195,7 @@ 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() } @@ -175,7 +205,9 @@ final class Aria2DownloadEngine { private func arguments( for item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration, - speedLimitKiBPerSecond: Int? + speedLimitKiBPerSecond: Int?, + rpcPort: Int, + rpcSecret: String ) throws -> [String] { var arguments = [ "--continue=true", @@ -191,7 +223,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 +378,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 +391,9 @@ final class LockedDataBuffer: @unchecked Sendable { func append(_ data: Data) { lock.withLock { storage.append(data) + if storage.count > maxBytes { + storage.removeFirst(storage.count - maxBytes) + } } } } diff --git a/Sources/Firelink/ChunkMapView.swift b/Sources/Firelink/ChunkMapView.swift new file mode 100644 index 0000000..fcdfb6f --- /dev/null +++ b/Sources/Firelink/ChunkMapView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +struct ChunkMapView: View { + let item: DownloadItem + + @State private var bitfield: String = "" + @State private var numPieces: Int = 0 + @State private var pollTask: Task? + @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 = 6 + let spacing: CGFloat = 1 + 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.addRect(CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing)) + } + cx += boxSize + if cx >= 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.addRect(CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing)) + } + cx += boxSize + if cx >= width { + cx = 0 + cy += boxSize + } + } + } + + context.fill(pendingPath, with: .color(Color.gray.opacity(0.3))) + context.fill(completedPath, with: .color(Color.green)) + } + .frame(minHeight: 80) + } +} diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index cc8d726..372e791 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -21,6 +21,30 @@ 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 + 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 + openWindow(id: "add-downloads") + } + } + } + } + } + return true + } } @ViewBuilder @@ -116,12 +140,6 @@ struct ContentView: View { Label("Start", systemImage: "play.fill") } } - - Button(role: .destructive) { - showDeleteConfirmation = true - } label: { - Label("Delete", systemImage: "trash") - } } } } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 00b674a..f04904c 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -496,6 +496,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() diff --git a/Sources/Firelink/DownloadPropertiesView.swift b/Sources/Firelink/DownloadPropertiesView.swift index 5d642f1..33a928d 100644 --- a/Sources/Firelink/DownloadPropertiesView.swift +++ b/Sources/Firelink/DownloadPropertiesView.swift @@ -158,6 +158,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) diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index 94154e1..e2ab8df 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -29,6 +29,10 @@ struct FirelinkApp: App { .environmentObject(schedulerController) .modifier(AppThemeModifier(theme: settings.appTheme)) .modifier(AppFontSizeModifier(fontSize: settings.appFontSize)) + .onOpenURL { url in + controller.pendingPasteboardText = url.absoluteString + NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) + } .frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760) } .windowStyle(.titleBar) diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index 6a5df70..8100d46 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -118,6 +118,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 } diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index 943ba58..af2579d 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -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 diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index 64ec6c1..dccd0cc 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -299,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 { @@ -338,6 +336,9 @@ private struct EngineSettingsPane: View { } } .formStyle(.grouped) + .task { + version = await Aria2DownloadEngine.versionString() ?? "Unavailable" + } } } @@ -448,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) } } @@ -470,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) } } @@ -513,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) } }