diff --git a/BitDream/Transmission/TransmissionModels.swift b/BitDream/Transmission/TransmissionModels.swift index 72f154d..b07fcbc 100644 --- a/BitDream/Transmission/TransmissionModels.swift +++ b/BitDream/Transmission/TransmissionModels.swift @@ -1,7 +1,5 @@ import Foundation -public typealias TransmissionConfig = URLComponents - internal enum TransmissionCredentialSource: Hashable, Sendable { case resolvedPassword(String) case keychainCredential(String) @@ -13,34 +11,6 @@ internal struct TransmissionConnectionDescriptor: Hashable, Sendable { let port: Int let username: String let credentialSource: TransmissionCredentialSource - - init( - scheme: String, - host: String, - port: Int, - username: String, - credentialSource: TransmissionCredentialSource - ) { - self.scheme = scheme - self.host = host - self.port = port - self.username = username - self.credentialSource = credentialSource - } - - init(config: TransmissionConfig, auth: TransmissionAuth) { - self.init( - scheme: config.scheme ?? "", - host: config.host ?? "", - port: config.port ?? 0, - username: auth.username, - credentialSource: .resolvedPassword(auth.password) - ) - } - - init(info: (config: TransmissionConfig, auth: TransmissionAuth)) { - self.init(config: info.config, auth: info.auth) - } } internal struct TransmissionEndpoint: Hashable, Sendable { @@ -48,7 +18,6 @@ internal struct TransmissionEndpoint: Hashable, Sendable { let host: String let port: Int let rpcURL: URL - let endpointKey: String init(scheme: String, host: String, port: Int) throws { let normalizedScheme = scheme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -84,19 +53,6 @@ internal struct TransmissionEndpoint: Hashable, Sendable { self.host = normalizedHost self.port = port self.rpcURL = rpcURL - self.endpointKey = rpcURL.absoluteString - } - - init(config: TransmissionConfig) throws { - guard let port = config.port else { - throw TransmissionError.invalidEndpointConfiguration - } - - try self.init( - scheme: config.scheme ?? "", - host: config.host ?? "", - port: port - ) } } diff --git a/BitDream/Transmission/TransmissionTorrentModels.swift b/BitDream/Transmission/TransmissionTorrentModels.swift index d6a420f..9e06b10 100644 --- a/BitDream/Transmission/TransmissionTorrentModels.swift +++ b/BitDream/Transmission/TransmissionTorrentModels.swift @@ -358,15 +358,6 @@ public struct TorrentAddResponseArgs: Codable, Sendable { public var name: String } -/// Torrent add response wraps the added torrent info -public struct TorrentAddResponseData: Codable, Sendable { - public var torrentAdded: TorrentAddResponseArgs - - enum CodingKeys: String, CodingKey { - case torrentAdded = "torrent-added" - } -} - /// Response for torrent files public struct TorrentFilesResponseData: Codable, Sendable { public let files: [TorrentFile] diff --git a/BitDream/Views/Shared/AddTorrent.swift b/BitDream/Views/Shared/AddTorrent.swift index de8be3b..569ab29 100644 --- a/BitDream/Views/Shared/AddTorrent.swift +++ b/BitDream/Views/Shared/AddTorrent.swift @@ -28,6 +28,36 @@ func handleAddTorrentError(_ message: String, errorMessage: Binding, sh let addTorrentNoServerConfiguredMessage = "No server configured. Please add or select a server in Settings." +struct AddTorrentBatchFailure: Equatable { + let fileName: String + let message: String +} + +func addTorrentBatchFailure(fileName: String, error: Error) -> AddTorrentBatchFailure? { + guard let message = TransmissionUserFacingError.message(for: error) else { + return nil + } + + return AddTorrentBatchFailure(fileName: fileName, message: message) +} + +func addTorrentBatchFailureSummary(_ failures: [AddTorrentBatchFailure]) -> String { + precondition(!failures.isEmpty, "Batch failure summary requires at least one failure.") + + if failures.count == 1, let failure = failures.first { + return "Failed to add '\(failure.fileName)': \(failure.message)" + } + + let summary = failures + .prefix(5) + .map { "\($0.fileName): \($0.message)" } + .joined(separator: "\n") + let remainingCount = failures.count - min(failures.count, 5) + let suffix = remainingCount > 0 ? "\n…and \(remainingCount) more" : "" + + return "Failed to add \(failures.count) torrent files.\n\n\(summary)\(suffix)" +} + @MainActor func presentAddTorrentSheetError( detail: String, diff --git a/BitDream/Views/macOS/macOSAddTorrent.swift b/BitDream/Views/macOS/macOSAddTorrent.swift index 6c7096d..477ed4a 100644 --- a/BitDream/Views/macOS/macOSAddTorrent.swift +++ b/BitDream/Views/macOS/macOSAddTorrent.swift @@ -276,7 +276,7 @@ private extension macOSAddTorrent { let saveLocation = downloadDir Task { @MainActor in - var failures: [(String, String)] = [] + var failures: [AddTorrentBatchFailure] = [] for torrentFile in torrentFiles { do { @@ -285,31 +285,20 @@ private extension macOSAddTorrent { saveLocation: saveLocation ) } catch { - let message = TransmissionUserFacingError.message(for: error) ?? error.localizedDescription - failures.append((torrentFile.name, message)) + guard let failure = addTorrentBatchFailure(fileName: torrentFile.name, error: error) else { + continue + } + + failures.append(failure) } } guard failures.isEmpty else { - if failures.count == 1, let failure = failures.first { - handleAddTorrentError( - "Failed to add '\(failure.0)': \(failure.1)", - errorMessage: $errorMessage, - showingError: $showingError - ) - } else { - let summary = failures - .prefix(5) - .map { "\($0.0): \($0.1)" } - .joined(separator: "\n") - let remainingCount = failures.count - min(failures.count, 5) - let suffix = remainingCount > 0 ? "\n…and \(remainingCount) more" : "" - handleAddTorrentError( - "Failed to add \(failures.count) torrent files.\n\n\(summary)\(suffix)", - errorMessage: $errorMessage, - showingError: $showingError - ) - } + handleAddTorrentError( + addTorrentBatchFailureSummary(failures), + errorMessage: $errorMessage, + showingError: $showingError + ) return } diff --git a/BitDream/Widgets/BackgroundRefresh/WidgetRefreshOperation.swift b/BitDream/Widgets/BackgroundRefresh/WidgetRefreshOperation.swift index 8839ee4..6ec38a6 100644 --- a/BitDream/Widgets/BackgroundRefresh/WidgetRefreshOperation.swift +++ b/BitDream/Widgets/BackgroundRefresh/WidgetRefreshOperation.swift @@ -157,7 +157,7 @@ enum WidgetRefreshRunner { } } -private enum WidgetRefreshScheduler { +enum WidgetRefreshScheduler { private static let state = SchedulerState() @discardableResult @@ -202,20 +202,3 @@ private enum WidgetRefreshScheduler { } } } - -/// Convenience function to perform a widget refresh operation. -/// Returns a handle that can be used to request cancellation. -@discardableResult -func performWidgetRefresh(completion: (@Sendable () -> Void)? = nil) -> WidgetRefreshHandle { - enqueueWidgetRefresh { _ in - completion?() - } -} - -@discardableResult -func enqueueWidgetRefresh( - dependencies: WidgetRefreshDependencies = .live, - completion: (@Sendable (Bool) -> Void)? = nil -) -> WidgetRefreshHandle { - WidgetRefreshScheduler.enqueue(dependencies: dependencies, completion: completion) -} diff --git a/BitDream/Widgets/BackgroundRefresh/iOSBackgroundRefresh.swift b/BitDream/Widgets/BackgroundRefresh/iOSBackgroundRefresh.swift index f102925..c6d7669 100644 --- a/BitDream/Widgets/BackgroundRefresh/iOSBackgroundRefresh.swift +++ b/BitDream/Widgets/BackgroundRefresh/iOSBackgroundRefresh.swift @@ -56,7 +56,7 @@ enum BackgroundRefreshManager { schedule() // schedule the next one ASAP to keep cadence let taskBox = AppRefreshTaskBox(task: task) - let refreshHandle = enqueueWidgetRefresh { success in + let refreshHandle = WidgetRefreshScheduler.enqueue { success in taskBox.complete(success: success) } diff --git a/BitDream/Widgets/BackgroundRefresh/macOSBackgroundRefresh.swift b/BitDream/Widgets/BackgroundRefresh/macOSBackgroundRefresh.swift index 83d7cc3..b5dd776 100644 --- a/BitDream/Widgets/BackgroundRefresh/macOSBackgroundRefresh.swift +++ b/BitDream/Widgets/BackgroundRefresh/macOSBackgroundRefresh.swift @@ -25,8 +25,7 @@ enum BackgroundActivityScheduler { newScheduler.qualityOfService = .utility newScheduler.schedule { completion in - // Perform widget refresh - performWidgetRefresh { + _ = WidgetRefreshScheduler.enqueue { _ in completion(.finished) } } @@ -54,7 +53,7 @@ enum BackgroundActivityScheduler { newScheduler.qualityOfService = .utility newScheduler.schedule { completion in - performWidgetRefresh { + _ = WidgetRefreshScheduler.enqueue { _ in completion(.finished) } } diff --git a/BitDreamTests/Transmission/Connection/TransmissionConnectionFactoryTests.swift b/BitDreamTests/Transmission/Connection/TransmissionConnectionFactoryTests.swift index 938a7d0..1008225 100644 --- a/BitDreamTests/Transmission/Connection/TransmissionConnectionFactoryTests.swift +++ b/BitDreamTests/Transmission/Connection/TransmissionConnectionFactoryTests.swift @@ -103,8 +103,14 @@ final class TransmissionConnectionFactoryTests: XCTestCase { ) } - func testLegacyTupleDescriptorBridgeUsesResolvedPasswordSource() { - let descriptor = TransmissionConnectionDescriptor(config: makeConfig(), auth: makeAuth()) + func testDescriptorStoresExplicitResolvedPasswordSource() { + let descriptor = TransmissionConnectionDescriptor( + scheme: "http", + host: "example.com", + port: 9091, + username: "demo", + credentialSource: .resolvedPassword("secret") + ) XCTAssertEqual(descriptor.scheme, "http") XCTAssertEqual(descriptor.host, "example.com") diff --git a/BitDreamTests/Transmission/Models/TransmissionEndpointTests.swift b/BitDreamTests/Transmission/Models/TransmissionEndpointTests.swift index 1565e13..cb46864 100644 --- a/BitDreamTests/Transmission/Models/TransmissionEndpointTests.swift +++ b/BitDreamTests/Transmission/Models/TransmissionEndpointTests.swift @@ -6,7 +6,6 @@ final class TransmissionEndpointTests: XCTestCase { let endpoint = try TransmissionEndpoint(scheme: "http", host: "example.com", port: 9091) XCTAssertEqual(endpoint.rpcURL.absoluteString, "http://example.com:9091/transmission/rpc") - XCTAssertEqual(endpoint.endpointKey, "http://example.com:9091/transmission/rpc") XCTAssertEqual(endpoint.scheme, "http") XCTAssertEqual(endpoint.host, "example.com") XCTAssertEqual(endpoint.port, 9091) @@ -30,21 +29,14 @@ final class TransmissionEndpointTests: XCTestCase { } } - func testEndpointRejectsMissingPortFromConfig() async { - var config = TransmissionConfig() - config.scheme = "http" - config.host = "example.com" - + func testEndpointRejectsOutOfRangePort() async { await assertThrowsTransmissionError(.invalidEndpointConfiguration) { - _ = try TransmissionEndpoint(config: config) + _ = try TransmissionEndpoint(scheme: "http", host: "example.com", port: 65_536) } } func testEndpointAlwaysUsesDefaultRPCPath() throws { - var config = makeConfig() - config.path = "/custom/path" - - let endpoint = try TransmissionEndpoint(config: config) + let endpoint = try TransmissionEndpoint(scheme: "http", host: "example.com", port: 9091) XCTAssertEqual(endpoint.rpcURL.path, "/transmission/rpc") } diff --git a/BitDreamTests/Transmission/TransmissionTestSupport.swift b/BitDreamTests/Transmission/TransmissionTestSupport.swift index 357de7c..9658f94 100644 --- a/BitDreamTests/Transmission/TransmissionTestSupport.swift +++ b/BitDreamTests/Transmission/TransmissionTestSupport.swift @@ -22,16 +22,8 @@ let successEmptyBody = """ } """ -func makeConfig() -> TransmissionConfig { - var config = TransmissionConfig() - config.scheme = "http" - config.host = "example.com" - config.port = 9091 - return config -} - func makeEndpoint() throws -> TransmissionEndpoint { - try TransmissionEndpoint(config: makeConfig()) + try TransmissionEndpoint(scheme: "http", host: "example.com", port: 9091) } func makeAuth() -> TransmissionAuth { @@ -267,6 +259,7 @@ actor ConcurrentRefreshSender: TransmissionRPCRequestSending { actor ConcurrentUnauthorizedRefreshSender: TransmissionRPCRequestSending { private var requests: [CapturedRequest] = [] private var staleTokenResponses = 0 + private var staleRequestContinuations: [CheckedContinuation] = [] func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { requests.append(CapturedRequest(request)) @@ -274,10 +267,21 @@ actor ConcurrentUnauthorizedRefreshSender: TransmissionRPCRequestSending { switch request.value(forHTTPHeaderField: transmissionSessionTokenHeader) { case "stale-token": staleTokenResponses += 1 - if staleTokenResponses == 1 { + let responseNumber = staleTokenResponses + + if responseNumber == 1 { + await withCheckedContinuation { continuation in + staleRequestContinuations.append(continuation) + } return (Data(), makeHTTPResponse(for: request.url!, statusCode: 401)) } + let continuations = staleRequestContinuations + staleRequestContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + return ( Data(), makeHTTPResponse( diff --git a/BitDreamTests/Views/AddTorrentErrorHandlingTests.swift b/BitDreamTests/Views/AddTorrentErrorHandlingTests.swift new file mode 100644 index 0000000..2eacecf --- /dev/null +++ b/BitDreamTests/Views/AddTorrentErrorHandlingTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import BitDream + +final class AddTorrentErrorHandlingTests: XCTestCase { + func testBatchFailureReturnsNilForCancellation() { + XCTAssertNil(addTorrentBatchFailure(fileName: "Example.torrent", error: TransmissionError.cancelled)) + } + + func testBatchFailureUsesSharedTransmissionMessage() { + let failure = addTorrentBatchFailure(fileName: "Example.torrent", error: TransmissionError.unauthorized) + + XCTAssertEqual( + failure, + AddTorrentBatchFailure( + fileName: "Example.torrent", + message: "Authentication failed. Please check your server credentials." + ) + ) + } + + func testBatchFailureSummaryFormatsSingleFailure() { + let summary = addTorrentBatchFailureSummary([ + AddTorrentBatchFailure(fileName: "Example.torrent", message: "Authentication failed. Please check your server credentials.") + ]) + + XCTAssertEqual( + summary, + "Failed to add 'Example.torrent': Authentication failed. Please check your server credentials." + ) + } +} diff --git a/BitDreamTests/Widgets/WidgetRefreshOperationTests.swift b/BitDreamTests/Widgets/WidgetRefreshOperationTests.swift index 737b294..8c092b9 100644 --- a/BitDreamTests/Widgets/WidgetRefreshOperationTests.swift +++ b/BitDreamTests/Widgets/WidgetRefreshOperationTests.swift @@ -159,7 +159,10 @@ final class WidgetRefreshOperationTests: XCTestCase { let scenario = try makeSerializedRefreshScenario() let completionRecorder = WidgetRefreshCompletionRecorder() - enqueueWidgetRefresh(dependencies: scenario.dependencies, completion: completionHandler(for: completionRecorder)) + WidgetRefreshScheduler.enqueue( + dependencies: scenario.dependencies, + completion: completionHandler(for: completionRecorder) + ) let didStartFirstRun = await waitUntil { let requests = await scenario.sender.capturedRequests() @@ -167,7 +170,10 @@ final class WidgetRefreshOperationTests: XCTestCase { } XCTAssertTrue(didStartFirstRun) - enqueueWidgetRefresh(dependencies: scenario.dependencies, completion: completionHandler(for: completionRecorder)) + WidgetRefreshScheduler.enqueue( + dependencies: scenario.dependencies, + completion: completionHandler(for: completionRecorder) + ) await Task.yield() await Task.yield()