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
This commit is contained in:
NimBold
2026-06-10 19:25:06 +03:30
parent fdbacb8a7f
commit 2a5452b7c6
8 changed files with 189 additions and 104 deletions
+1 -1
View File
@@ -322,7 +322,7 @@ struct AddDownloadsView: View {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Text("Fetching media options...") Text("Fetching media options...")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if case .failed(let error) = firstMedia.state { } else if case .failed(_) = firstMedia.state {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
Text("Failed to load options.") Text("Failed to load options.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
+1 -1
View File
@@ -445,7 +445,7 @@ final class AppSettings: ObservableObject {
extensionPairingToken = Self.generateSecureToken() extensionPairingToken = Self.generateSecureToken()
} }
} else { } else {
isKeychainAccessGranted = false revokeKeychainAccess()
} }
} }
+85 -46
View File
@@ -2,7 +2,7 @@ import Foundation
import CFNetwork import CFNetwork
import Network import Network
final class Aria2DownloadEngine { final class Aria2DownloadEngine: Sendable {
struct Handle { struct Handle {
let processIdentifier: Int32 let processIdentifier: Int32
let rpcPort: Int let rpcPort: Int
@@ -107,7 +107,7 @@ final class Aria2DownloadEngine {
speedLimitKiBPerSecond: Int?, speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void, progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle { ) async throws -> Handle {
guard let executableURL else { guard let executableURL else {
throw EngineError.executableNotFound throw EngineError.executableNotFound
} }
@@ -117,30 +117,48 @@ final class Aria2DownloadEngine {
withIntermediateDirectories: true withIntermediateDirectories: true
) )
let rpcPort = Self.findFreePort() var lastError: Error?
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)")
}
let process = Process() for _ in 1...5 {
process.executableURL = executableURL let rpcPort = Self.findFreePort()
process.arguments = try arguments( let rpcSecret = UUID().uuidString
for: item, let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString)")
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond, do {
rpcPort: rpcPort, try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
confURL: confURL } 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 process = Process()
let outputPipe = Pipe() process.executableURL = executableURL
let errorPipe = Pipe()
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.standardInput = inputPipe
process.standardOutput = outputPipe process.standardOutput = outputPipe
process.standardError = errorPipe process.standardError = errorPipe
@@ -169,7 +187,7 @@ final class Aria2DownloadEngine {
} }
process.terminationHandler = { finishedProcess in process.terminationHandler = { finishedProcess in
try? FileManager.default.removeItem(at: confURL) try? FileManager.default.removeItem(at: tempDir)
completionMonitor.cancel() completionMonitor.cancel()
outputPipe.fileHandleForReading.readabilityHandler = nil outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil errorPipe.fileHandleForReading.readabilityHandler = nil
@@ -190,33 +208,54 @@ final class Aria2DownloadEngine {
completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message))) completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
} }
do { var didThrow = false
try process.run() do {
if let input = inputFileContent(for: item).data(using: .utf8) { try process.run()
inputPipe.fileHandleForWriting.write(input) 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 {
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( completionMonitor.set(
Self.monitorCompletion( Self.monitorCompletion(
rpcPort: rpcPort, rpcPort: rpcPort,
rpcSecret: rpcSecret, rpcSecret: rpcSecret,
process: process, process: process,
completionGate: completionGate completionGate: completionGate
)
) )
)
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) { return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionMonitor.cancel() completionMonitor.cancel()
if process.isRunning { if process.isRunning {
process.terminate() 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( private static func monitorCompletion(
+67 -44
View File
@@ -74,7 +74,7 @@ final class DownloadController: ObservableObject {
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification) NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.saveDownloads() self?.saveDownloadsSync()
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -371,12 +371,7 @@ final class DownloadController: ObservableObject {
pumpQueue() 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) { func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
activeHandles[item.id]?.cancel() activeHandles[item.id]?.cancel()
@@ -598,49 +593,61 @@ final class DownloadController: ObservableObject {
} }
} }
} else { } else {
do { Task {
let handle = try engine.start( do {
item: injectedEngineItem(from: item), let liveItem = injectedEngineItem(from: item)
proxyConfiguration: settings.downloadProxyConfiguration, let handle = try await engine.start(
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), item: liveItem,
progress: { [weak self] progress in proxyConfiguration: settings.downloadProxyConfiguration,
Task { @MainActor in speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
let now = Date() progress: { [weak self] progress in
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 { Task { @MainActor in
return 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) { completion: { [weak self] result in
guard $0.status == .downloading else { return } Task { @MainActor in
$0.progress = progress.fraction self?.handleCompletion(item: item, result: result)
$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 await MainActor.run {
self?.handleCompletion(item: item, result: result) 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()
} }
} }
} }
@@ -996,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() { private func saveDownloads() {
let queuesCopy = queues let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence) let downloadsCopy = downloads.map(\.redactedForPersistence)
@@ -54,9 +54,14 @@ enum DownloadMetadataFetcher {
return pending return pending
} }
if isAutoFetch, let host = url.host, isPrivateHost(host) { if isAutoFetch, let host = url.host {
pending.state = .loaded let isPrivate = await Task.detached {
return pending isPrivateHost(host)
}.value
if isPrivate {
pending.state = .loaded
return pending
}
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
+3 -1
View File
@@ -126,7 +126,9 @@ struct DownloadTable: View {
} }
.onAppear { sortedItems = items.sorted(using: sortOrder) } .onAppear { sortedItems = items.sorted(using: sortOrder) }
.onChange(of: items) { _, newItems in .onChange(of: items) { _, newItems in
if newItems.count != sortedItems.count { let existingIDs = Set(sortedItems.map(\.id))
let newIDs = Set(newItems.map(\.id))
if existingIDs != newIDs {
sortedItems = newItems.sorted(using: sortOrder) sortedItems = newItems.sorted(using: sortOrder)
} else { } else {
let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
+17 -1
View File
@@ -1,6 +1,7 @@
import Foundation import Foundation
import Network import Network
import AppKit import AppKit
import Combine
final class LocalExtensionServer: @unchecked Sendable { final class LocalExtensionServer: @unchecked Sendable {
private enum Constants { private enum Constants {
@@ -16,7 +17,12 @@ final class LocalExtensionServer: @unchecked Sendable {
private let settings: AppSettings private let settings: AppSettings
private let queue = DispatchQueue(label: "local.firelink.server") private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16 let port: UInt16
private let tokenLock = NSLock()
private var _pairingToken: String = ""
private var cancellables = Set<AnyCancellable>()
@MainActor
init?(downloadController: DownloadController, settings: AppSettings) { init?(downloadController: DownloadController, settings: AppSettings) {
self.downloadController = downloadController self.downloadController = downloadController
self.settings = settings self.settings = settings
@@ -42,6 +48,16 @@ final class LocalExtensionServer: @unchecked Sendable {
self.listener = createdListener self.listener = createdListener
self.port = selectedPort ?? 6412 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() { func start() {
@@ -139,7 +155,7 @@ final class LocalExtensionServer: @unchecked Sendable {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden 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), guard let token = request.header(named: Constants.extensionRequestHeader),
token == expectedToken else { token == expectedToken else {
return .forbidden return .forbidden
+7 -7
View File
@@ -18,13 +18,13 @@ enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
var shortName: String { var shortName: String {
switch self { switch self {
case .sunday: "S" case .sunday: "Su"
case .monday: "M" case .monday: "Mo"
case .tuesday: "T" case .tuesday: "Tu"
case .wednesday: "W" case .wednesday: "We"
case .thursday: "T" case .thursday: "Th"
case .friday: "F" case .friday: "Fr"
case .saturday: "S" case .saturday: "Sa"
} }
} }
} }