Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 0 additions & 44 deletions BitDream/Transmission/TransmissionModels.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import Foundation

public typealias TransmissionConfig = URLComponents

internal enum TransmissionCredentialSource: Hashable, Sendable {
case resolvedPassword(String)
case keychainCredential(String)
Expand All @@ -13,42 +11,13 @@ 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 {
let scheme: String
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()
Expand Down Expand Up @@ -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
)
}
}

Expand Down
9 changes: 0 additions & 9 deletions BitDream/Transmission/TransmissionTorrentModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
30 changes: 30 additions & 0 deletions BitDream/Views/Shared/AddTorrent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,36 @@ func handleAddTorrentError(_ message: String, errorMessage: Binding<String?>, 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,
Expand Down
33 changes: 11 additions & 22 deletions BitDream/Views/macOS/macOSAddTorrent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
19 changes: 1 addition & 18 deletions BitDream/Widgets/BackgroundRefresh/WidgetRefreshOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ enum WidgetRefreshRunner {
}
}

private enum WidgetRefreshScheduler {
enum WidgetRefreshScheduler {
private static let state = SchedulerState()

@discardableResult
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ enum BackgroundActivityScheduler {
newScheduler.qualityOfService = .utility

newScheduler.schedule { completion in
// Perform widget refresh
performWidgetRefresh {
_ = WidgetRefreshScheduler.enqueue { _ in
completion(.finished)
}
}
Expand Down Expand Up @@ -54,7 +53,7 @@ enum BackgroundActivityScheduler {
newScheduler.qualityOfService = .utility

newScheduler.schedule { completion in
performWidgetRefresh {
_ = WidgetRefreshScheduler.enqueue { _ in
completion(.finished)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 3 additions & 11 deletions BitDreamTests/Transmission/Models/TransmissionEndpointTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
Expand Down
24 changes: 14 additions & 10 deletions BitDreamTests/Transmission/TransmissionTestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -267,17 +259,29 @@ actor ConcurrentRefreshSender: TransmissionRPCRequestSending {
actor ConcurrentUnauthorizedRefreshSender: TransmissionRPCRequestSending {
private var requests: [CapturedRequest] = []
private var staleTokenResponses = 0
private var staleRequestContinuations: [CheckedContinuation<Void, Never>] = []

func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
requests.append(CapturedRequest(request))

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(
Expand Down
31 changes: 31 additions & 0 deletions BitDreamTests/Views/AddTorrentErrorHandlingTests.swift
Original file line number Diff line number Diff line change
@@ -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."
)
}
}
10 changes: 8 additions & 2 deletions BitDreamTests/Widgets/WidgetRefreshOperationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,21 @@ 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()
return requests.count == 2
}
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()
Expand Down