Merge pull request #2 from nimbold/codex/fix-download-workflows

fix: harden download workflows
This commit is contained in:
Nima
2026-06-04 15:45:23 +03:30
committed by GitHub
18 changed files with 283 additions and 62 deletions
+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")
]
)
]
)
+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
+20 -1
View File
@@ -52,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
}
@@ -388,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()
@@ -458,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] {
@@ -202,6 +202,30 @@ final class Aria2DownloadEngine {
}
}
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,
+12 -1
View File
@@ -28,6 +28,7 @@ struct ContentView: View {
if let url = url {
DispatchQueue.main.async {
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
}
@@ -37,6 +38,7 @@ struct ContentView: View {
if let text = text {
DispatchQueue.main.async {
controller.pendingPasteboardText = text
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
}
@@ -117,7 +119,7 @@ struct ContentView: View {
}
.disabled(!canStop)
let canStart = selectedItems.isEmpty ? true : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
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)
@@ -183,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")
}
@@ -199,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:
+45 -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),
@@ -526,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
@@ -543,6 +564,7 @@ final class DownloadController: ObservableObject {
} else if credentials == nil {
KeychainCredentialStore.deletePassword(for: id)
}
applySpeedLimitToActiveDownload(id: id)
saveDownloads()
}
@@ -568,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)
}
@@ -221,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))
}
}
+17 -1
View File
@@ -31,6 +31,7 @@ struct FirelinkApp: App {
.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)
@@ -87,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
@@ -99,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
}
}
+14 -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"
@@ -132,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
}
@@ -161,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)
}
+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()
}
}
+30 -2
View File
@@ -666,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
}
@@ -677,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()
@@ -686,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")