From 024a2f13ffea940f21214c8543a2c9d03eb931d9 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sat, 7 Mar 2026 22:44:26 -0800 Subject: [PATCH 1/8] move torrent actions and detail loading onto TransmissionStore --- .swiftlint.yml | 1 + BitDream.xcodeproj/project.pbxproj | 22 +- BitDream/BitDreamApp.swift | 95 ++- BitDream/Delegates/AppFileOpenDelegate.swift | 13 +- .../TransmissionErrorPresentation.swift | 56 +- .../Transmission/TransmissionFunctions.swift | 573 ------------------ .../TransmissionLegacyConnectionInfo.swift | 23 - .../TransmissionReadSnapshots.swift | 30 + .../TransmissionTorrentModels.swift | 27 +- .../Transmission/TransmissionTorrents.swift | 184 ++++++ BitDream/TransmissionStore.swift | 216 ++++++- BitDream/Views/Shared/AddTorrent.swift | 103 +--- .../Views/Shared/TorrentActionExecutor.swift | 145 ----- BitDream/Views/Shared/TorrentDetail.swift | 48 +- BitDream/Views/Shared/TorrentFileDetail.swift | 48 -- BitDream/Views/Shared/TorrentListRow.swift | 46 -- .../Views/Shared/TransmissionActions.swift | 215 +++++++ .../Views/Shared/TransmissionLegacyUI.swift | 37 -- BitDream/Views/iOS/iOSAddTorrent.swift | 42 +- BitDream/Views/iOS/iOSContentView.swift | 249 ++++---- BitDream/Views/iOS/iOSTorrentDetail.swift | 104 ++-- BitDream/Views/iOS/iOSTorrentFileDetail.swift | 97 +-- BitDream/Views/iOS/iOSTorrentListRow.swift | 141 +++-- BitDream/Views/iOS/iOSTorrentPeerDetail.swift | 20 +- BitDream/Views/macOS/macOSAddTorrent.swift | 93 ++- BitDream/Views/macOS/macOSContentDetail.swift | 20 +- BitDream/Views/macOS/macOSContentView.swift | 28 +- BitDream/Views/macOS/macOSMenuCommands.swift | 100 +-- .../Views/macOS/macOSTorrentActionsMenu.swift | 210 ++++--- BitDream/Views/macOS/macOSTorrentDetail.swift | 103 ++-- BitDream/Views/macOS/macOSTorrentEdit.swift | 73 ++- .../Views/macOS/macOSTorrentFileDetail.swift | 190 +++--- BitDream/Views/macOS/macOSTorrentList.swift | 4 +- .../Views/macOS/macOSTorrentListCompact.swift | 4 +- .../Views/macOS/macOSTorrentPeerDetail.swift | 14 +- .../TransmissionConnectionQueryTests.swift | 103 ++++ .../TransmissionConnectionTests.swift | 110 ++++ ...issionCompatibilityAdapterQueryTests.swift | 139 ----- ...ssionCompatibilityAdapterStatusTests.swift | 198 ------ ...ssionCompatibilityAdapterTestSupport.swift | 10 - .../TransmissionErrorPresentationTests.swift | 93 --- .../TransmissionTestSupport.swift | 7 + ...ansmissionStoreTorrentOperationTests.swift | 471 ++++++++++++++ .../Views/TransmissionActionsTests.swift | 72 +++ 44 files changed, 2373 insertions(+), 2204 deletions(-) delete mode 100644 BitDream/Transmission/TransmissionFunctions.swift delete mode 100644 BitDream/Transmission/TransmissionLegacyConnectionInfo.swift delete mode 100644 BitDream/Views/Shared/TorrentActionExecutor.swift create mode 100644 BitDream/Views/Shared/TransmissionActions.swift delete mode 100644 BitDream/Views/Shared/TransmissionLegacyUI.swift delete mode 100644 BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterQueryTests.swift delete mode 100644 BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterStatusTests.swift delete mode 100644 BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterTestSupport.swift delete mode 100644 BitDreamTests/Transmission/Legacy/TransmissionErrorPresentationTests.swift create mode 100644 BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift create mode 100644 BitDreamTests/Views/TransmissionActionsTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 0b2f06b..b483e3b 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,6 +4,7 @@ disabled_rules: excluded: - build + - .build # TODO: Refactor these files and remove the temporary lint exclusion. - BitDream/Transmission/TransmissionFunctions.swift - BitDream/TransmissionStore.swift diff --git a/BitDream.xcodeproj/project.pbxproj b/BitDream.xcodeproj/project.pbxproj index 68aa7b2..3f69fde 100644 --- a/BitDream.xcodeproj/project.pbxproj +++ b/BitDream.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 4B1DADE8295E6C450037E9FB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B1DADE7295E6C450037E9FB /* Assets.xcassets */; }; 4B1DADEC295E6C450037E9FB /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B1DADEB295E6C450037E9FB /* Preview Assets.xcassets */; }; 4B1DADFE295E6F390037E9FB /* TransmissionTorrentModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1DADFD295E6F390037E9FB /* TransmissionTorrentModels.swift */; }; - 4B1DAE00295E6F600037E9FB /* TransmissionFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1DADFF295E6F600037E9FB /* TransmissionFunctions.swift */; }; 4B1DAE02295E6FA30037E9FB /* AddTorrent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1DAE01295E6FA30037E9FB /* AddTorrent.swift */; }; 4B1DAE06295E6FC80037E9FB /* TorrentDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1DAE05295E6FC80037E9FB /* TorrentDetail.swift */; }; 4B1DAE0A295E6FE10037E9FB /* ServerDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1DAE09295E6FE10037E9FB /* ServerDetail.swift */; }; @@ -100,7 +99,6 @@ 4F8000044000000000A1B2C3 /* macOSContentSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8000014000000000A1B2C3 /* macOSContentSidebar.swift */; }; 4F8000054000000000A1B2C3 /* macOSContentDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8000024000000000A1B2C3 /* macOSContentDetail.swift */; }; 4F8000064000000000A1B2C3 /* macOSContentToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8000034000000000A1B2C3 /* macOSContentToolbar.swift */; }; - 4FA9A0024100000000A1B2C3 /* TorrentActionExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA9A0014100000000A1B2C3 /* TorrentActionExecutor.swift */; }; 5A1000014100000000A1B2C3 /* TransmissionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1000064100000000A1B2C3 /* TransmissionModels.swift */; }; 5A1000214100000000A1B2C3 /* TransmissionTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1000224100000000A1B2C3 /* TransmissionTransport.swift */; }; 5A1000234100000000A1B2C3 /* TransmissionConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1000244100000000A1B2C3 /* TransmissionConnection.swift */; }; @@ -111,8 +109,7 @@ 7B1000014300000000A1B2C3 /* Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1000024300000000A1B2C3 /* Formatting.swift */; }; 7B1000034300000000A1B2C3 /* Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1000044300000000A1B2C3 /* Sorting.swift */; }; 7B1000054300000000A1B2C3 /* TransmissionReadSnapshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1000064300000000A1B2C3 /* TransmissionReadSnapshots.swift */; }; - 7B1000074300000000A1B2C3 /* TransmissionLegacyConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1000084300000000A1B2C3 /* TransmissionLegacyConnectionInfo.swift */; }; - 7B1000094300000000A1B2C3 /* TransmissionLegacyUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B10000A4300000000A1B2C3 /* TransmissionLegacyUI.swift */; }; + 7B1000094300000000A1B2C3 /* TransmissionActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B10000A4300000000A1B2C3 /* TransmissionActions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -153,7 +150,6 @@ 4B1DADE9295E6C450037E9FB /* BitDream.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BitDream.entitlements; sourceTree = ""; }; 4B1DADEB295E6C450037E9FB /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 4B1DADFD295E6F390037E9FB /* TransmissionTorrentModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionTorrentModels.swift; sourceTree = ""; }; - 4B1DADFF295E6F600037E9FB /* TransmissionFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionFunctions.swift; sourceTree = ""; }; 4B1DAE01295E6FA30037E9FB /* AddTorrent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTorrent.swift; sourceTree = ""; }; 4B1DAE05295E6FC80037E9FB /* TorrentDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentDetail.swift; sourceTree = ""; }; 4B1DAE09295E6FE10037E9FB /* ServerDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetail.swift; sourceTree = ""; }; @@ -239,7 +235,6 @@ 4F8000014000000000A1B2C3 /* macOSContentSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = macOSContentSidebar.swift; sourceTree = ""; }; 4F8000024000000000A1B2C3 /* macOSContentDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = macOSContentDetail.swift; sourceTree = ""; }; 4F8000034000000000A1B2C3 /* macOSContentToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = macOSContentToolbar.swift; sourceTree = ""; }; - 4FA9A0014100000000A1B2C3 /* TorrentActionExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentActionExecutor.swift; sourceTree = ""; }; 5A1000064100000000A1B2C3 /* TransmissionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionModels.swift; sourceTree = ""; }; 5A1000074100000000A1B2C3 /* BitDreamTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BitDreamTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5A1000224100000000A1B2C3 /* TransmissionTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionTransport.swift; sourceTree = ""; }; @@ -251,8 +246,7 @@ 7B1000024300000000A1B2C3 /* Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatting.swift; sourceTree = ""; }; 7B1000044300000000A1B2C3 /* Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sorting.swift; sourceTree = ""; }; 7B1000064300000000A1B2C3 /* TransmissionReadSnapshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionReadSnapshots.swift; sourceTree = ""; }; - 7B1000084300000000A1B2C3 /* TransmissionLegacyConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionLegacyConnectionInfo.swift; sourceTree = ""; }; - 7B10000A4300000000A1B2C3 /* TransmissionLegacyUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionLegacyUI.swift; sourceTree = ""; }; + 7B10000A4300000000A1B2C3 /* TransmissionActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionActions.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -345,13 +339,13 @@ children = ( 4B908AED2E6EBAC700C2DA5C /* Info.plist */, 4B7EF5702E6E9F0C00C1C281 /* AppConfig.swift */, - 4B1DAE0F295E702B0037E9FB /* TransmissionStore.swift */, 4D1000033B00000000B1C2D3 /* AppUpdater.swift */, 4B1DADDE295E6C440037E9FB /* BitDreamApp.swift */, 7B1000024300000000A1B2C3 /* Formatting.swift */, 4D3A00043C00000000E1F1A1 /* KeychainService.swift */, 7B1000044300000000A1B2C3 /* Sorting.swift */, 4BD938E92D82AF6F006CE97C /* ThemeManager.swift */, + 4B1DAE0F295E702B0037E9FB /* TransmissionStore.swift */, 4B0349D52F56EB81008ED58D /* AppIcon */, 4B73985B2E70097C00D06E5D /* Delegates */, 4D3A00073C00000000E1F1A1 /* Models */, @@ -388,8 +382,6 @@ 4B1DADFC295E6F0F0037E9FB /* Transmission */ = { isa = PBXGroup; children = ( - 4B1DADFF295E6F600037E9FB /* TransmissionFunctions.swift */, - 7B1000084300000000A1B2C3 /* TransmissionLegacyConnectionInfo.swift */, 5A1000064100000000A1B2C3 /* TransmissionModels.swift */, 5A1000224100000000A1B2C3 /* TransmissionTransport.swift */, 5A1000244100000000A1B2C3 /* TransmissionConnection.swift */, @@ -474,8 +466,7 @@ 4B1DAE09295E6FE10037E9FB /* ServerDetail.swift */, 4B1DAE0B295E6FEF0037E9FB /* ServerList.swift */, 4B1DAE05295E6FC80037E9FB /* TorrentDetail.swift */, - 4FA9A0014100000000A1B2C3 /* TorrentActionExecutor.swift */, - 7B10000A4300000000A1B2C3 /* TransmissionLegacyUI.swift */, + 7B10000A4300000000A1B2C3 /* TransmissionActions.swift */, 4B6F1EBF2D86D52A003D8F6E /* TorrentFileDetail.swift */, 4B6F1EB12D82DB21003D8F6E /* TorrentListRow.swift */, 4BF593B82E89BF6300A8C1FC /* TorrentPeerDetail.swift */, @@ -751,13 +742,11 @@ 4B6F1EB02D82D340003D8F6E /* iOSSettingsView.swift in Sources */, 4BC6F24B2E89F8510037DFDF /* PiecesGridView.swift in Sources */, 4B6F1EBC2D86C908003D8F6E /* macOSTorrentFileDetail.swift in Sources */, - 4B1DAE00295E6F600037E9FB /* TransmissionFunctions.swift in Sources */, 5A1000014100000000A1B2C3 /* TransmissionModels.swift in Sources */, 5A1000214100000000A1B2C3 /* TransmissionTransport.swift in Sources */, 5A1000234100000000A1B2C3 /* TransmissionConnection.swift in Sources */, 7B1000054300000000A1B2C3 /* TransmissionReadSnapshots.swift in Sources */, 6A1000014200000000A1B2C3 /* TransmissionConnectionFactory.swift in Sources */, - 7B1000074300000000A1B2C3 /* TransmissionLegacyConnectionInfo.swift in Sources */, 6A1000034200000000A1B2C3 /* TransmissionTorrents.swift in Sources */, 6A1000054200000000A1B2C3 /* TransmissionSession.swift in Sources */, 6A1000074200000000A1B2C3 /* TransmissionErrorPresentation.swift in Sources */, @@ -775,7 +764,6 @@ 4B1DAE0C295E6FEF0037E9FB /* ServerList.swift in Sources */, 4E2000024000000000C1D2E3 /* NetworkSettings.swift in Sources */, 4E2000014000000000C1D2E3 /* SpeedLimitsSettings.swift in Sources */, - 4FA9A0024100000000A1B2C3 /* TorrentActionExecutor.swift in Sources */, 4B6F1EB22D82DB21003D8F6E /* TorrentListRow.swift in Sources */, 4B1DAE06295E6FC80037E9FB /* TorrentDetail.swift in Sources */, 4E2000034000000000C1D2E3 /* TorrentSettings.swift in Sources */, @@ -786,7 +774,7 @@ 4BCF27502E774EDC00F6BF76 /* DataModels.swift in Sources */, 4B9CAC562D7E83460094CD03 /* iOSAddTorrent.swift in Sources */, 4BE364BA2E6E75B300CF1A33 /* SharedComponents.swift in Sources */, - 7B1000094300000000A1B2C3 /* TransmissionLegacyUI.swift in Sources */, + 7B1000094300000000A1B2C3 /* TransmissionActions.swift in Sources */, 4BF593B72E89BF5500A8C1FC /* macOSTorrentPeerDetail.swift in Sources */, 4BF593B92E89BF6300A8C1FC /* TorrentPeerDetail.swift in Sources */, 4BC5C2AA2D7D20B500D80AB4 /* iOSContentView.swift in Sources */, diff --git a/BitDream/BitDreamApp.swift b/BitDream/BitDreamApp.swift index 0ddcb01..2052f64 100644 --- a/BitDream/BitDreamApp.swift +++ b/BitDream/BitDreamApp.swift @@ -57,17 +57,34 @@ struct BitDreamApp: App { #endif } + var body: some Scene { + #if os(macOS) + macOSScenes + #else + iOSScene + #endif + } +} + +private extension BitDreamApp { #if os(macOS) - private func syncMenuBarStatusItem(isEnabled: Bool? = nil) { + func syncMenuBarStatusItem(isEnabled: Bool? = nil) { menuBarStatusItemController.configure( isEnabled: isEnabled ?? menuBarTransferWidgetEnabled, store: store ) } - #endif - var body: some Scene { - #if os(macOS) + @SceneBuilder + var macOSScenes: some Scene { + mainWindowScene + connectionInfoScene + statisticsScene + aboutScene + settingsScene + } + + var mainWindowScene: some Scene { Window("BitDream", id: "main") { ContentView() .environmentObject(store) // Pass the shared store to the ContentView @@ -127,11 +144,29 @@ struct BitDreamApp: App { ) { result in switch result { case .success(let urls): + guard store.host != nil else { + presentAddTorrentStoreError( + detail: addTorrentNoServerConfiguredMessage, + store: store + ) + return + } var failures: [(String, String)] = [] for url in urls { do { let data = try Data(contentsOf: url) - addTorrentFromFileData(data, store: store) + performTransmissionAction( + operation: { + try await store.addTorrent( + fileData: data, + saveLocation: store.defaultDownloadDir + ) + }, + onSuccess: { (_: TransmissionTorrentAddOutcome) in }, + onError: { message in + presentAddTorrentStoreError(detail: message, store: store) + } + ) } catch { failures.append((url.lastPathComponent, error.localizedDescription)) } @@ -155,7 +190,6 @@ struct BitDreamApp: App { store.showGlobalAlert = true } } - #if os(macOS) .sheet(isPresented: $store.showGlobalRenameDialog) { // Resolve target torrent using the stored ID if let targetId = store.globalRenameTargetId, @@ -176,24 +210,25 @@ struct BitDreamApp: App { store.showGlobalAlert = true return } - renameTorrentRoot(torrent: targetTorrent, to: newName, store: store) { error in - if let error = error { - store.globalAlertTitle = "Rename Error" - store.globalAlertMessage = error - store.showGlobalAlert = true - } else { + performTransmissionAction( + operation: { try await store.renameTorrentRoot(targetTorrent, to: newName) }, + onSuccess: { (_: TorrentRenameResponseArgs) in store.showGlobalRenameDialog = false store.globalRenameInput = "" store.globalRenameTargetId = nil + }, + onError: { message in + store.globalAlertTitle = "Rename Error" + store.globalAlertMessage = message + store.showGlobalAlert = true } - } + ) } ) .frame(width: 420) .padding() } } - #endif } .windowResizability(.contentSize) .commands { @@ -213,6 +248,9 @@ struct BitDreamApp: App { ) } .modelContainer(persistenceController.container) + } + + var connectionInfoScene: some Scene { WindowGroup("Connection Info", id: "connection-info") { macOSConnectionInfoView() .environmentObject(store) @@ -223,7 +261,9 @@ struct BitDreamApp: App { } .windowResizability(.contentSize) .modelContainer(persistenceController.container) + } + var statisticsScene: some Scene { WindowGroup("Statistics", id: "statistics") { macOSStatisticsView() .environmentObject(store) @@ -234,7 +274,9 @@ struct BitDreamApp: App { } .windowResizability(.contentSize) .modelContainer(persistenceController.container) + } + var aboutScene: some Scene { // About window - Using WindowGroup to prevent automatic Window menu entry // This follows Apple's recommended pattern for auxiliary windows that shouldn't // appear in the Window menu, as About windows are not user-managed utility windows @@ -248,8 +290,19 @@ struct BitDreamApp: App { .windowResizability(.contentSize) .defaultPosition(.center) .modelContainer(persistenceController.container) + } - #else + var settingsScene: some Scene { + Settings { + SettingsView(store: store) // Use the same store instance + .frame(minWidth: 500, idealWidth: 550, maxWidth: 650) + .environmentObject(appUpdater) + .environmentObject(themeManager) // Pass the ThemeManager to the Settings view + .immediateTheme(manager: themeManager) + } + } + #else + var iOSScene: some Scene { WindowGroup { ContentView() .environmentObject(store) // Pass the shared store to the ContentView @@ -268,18 +321,8 @@ struct BitDreamApp: App { } } .modelContainer(persistenceController.container) - #endif - - #if os(macOS) - Settings { - SettingsView(store: store) // Use the same store instance - .frame(minWidth: 500, idealWidth: 550, maxWidth: 650) - .environmentObject(appUpdater) - .environmentObject(themeManager) // Pass the ThemeManager to the Settings view - .immediateTheme(manager: themeManager) - } - #endif } + #endif } // TODO(swiftdata-cutover): Remove this function entirely after the migration diff --git a/BitDream/Delegates/AppFileOpenDelegate.swift b/BitDream/Delegates/AppFileOpenDelegate.swift index be683d3..d725fcd 100644 --- a/BitDream/Delegates/AppFileOpenDelegate.swift +++ b/BitDream/Delegates/AppFileOpenDelegate.swift @@ -104,7 +104,18 @@ final class AppFileOpenDelegate: NSObject, NSApplicationDelegate, ObservableObje case .magnet(let magnetString): store.enqueueMagnet(magnetString) case .torrentData(let data): - addTorrentFromFileData(data, store: store) + performTransmissionAction( + operation: { + try await store.addTorrent( + fileData: data, + saveLocation: store.defaultDownloadDir + ) + }, + onSuccess: { (_: TransmissionTorrentAddOutcome) in }, + onError: { message in + presentAddTorrentStoreError(detail: message, store: store) + } + ) } } diff --git a/BitDream/Transmission/TransmissionErrorPresentation.swift b/BitDream/Transmission/TransmissionErrorPresentation.swift index 0acf8c3..b39b320 100644 --- a/BitDream/Transmission/TransmissionErrorPresentation.swift +++ b/BitDream/Transmission/TransmissionErrorPresentation.swift @@ -80,57 +80,17 @@ enum TransmissionErrorPresenter { } } -struct TransmissionLegacyCompatibilityError: LocalizedError, Sendable { - let transmissionError: TransmissionError - - var errorDescription: String? { - TransmissionErrorPresenter.presentation(for: transmissionError).message - } -} - -enum TransmissionLegacyCompatibility { - static func response(from error: Error) -> TransmissionResponse { - response(from: TransmissionErrorResolver.transmissionError(from: error)) - } - - static func response(from error: TransmissionError) -> TransmissionResponse { - switch error { - case .unauthorized: - return .unauthorized - case .invalidEndpointConfiguration, .transport, .timeout: - return .configError - case .rpcFailure, .httpStatus, .invalidResponse, .decoding, .cancelled: - return .failed - } - } - - static func localizedError(from error: Error) -> Error { - TransmissionLegacyCompatibilityError( - transmissionError: TransmissionErrorResolver.transmissionError(from: error) - ) - } - - static func presentation(for response: TransmissionResponse) -> TransmissionErrorPresentation? { - switch response { - case .success: +enum TransmissionUserFacingError { + static func presentation(for error: Error) -> TransmissionErrorPresentation? { + let transmissionError = TransmissionErrorResolver.transmissionError(from: error) + if case .cancelled = transmissionError { return nil - case .unauthorized: - return TransmissionErrorPresenter.presentation(for: .unauthorized) - case .configError: - return TransmissionErrorPresenter.presentation(for: .invalidEndpointConfiguration) - case .failed: - return TransmissionErrorPresentation( - title: "Operation Failed", - message: "Operation failed. Please try again." - ) } - } - static func transmissionError(from error: Error) -> TransmissionError { - if let compatibilityError = error as? TransmissionLegacyCompatibilityError { - return compatibilityError.transmissionError - } + return TransmissionErrorPresenter.presentation(for: transmissionError) + } - return TransmissionErrorResolver.transmissionError(from: error) + static func message(for error: Error) -> String? { + presentation(for: error)?.message } } diff --git a/BitDream/Transmission/TransmissionFunctions.swift b/BitDream/Transmission/TransmissionFunctions.swift deleted file mode 100644 index e3226fa..0000000 --- a/BitDream/Transmission/TransmissionFunctions.swift +++ /dev/null @@ -1,573 +0,0 @@ -import Foundation - -// TODO: Remove the remaining legacy adapter and callback wrapper surface in -// phases 4/5 once write/detail callers have migrated to `TransmissionConnection` -// and typed error presentation. -internal struct TransmissionLegacyAdapter: Sendable { - private let factory: TransmissionConnectionFactory - - init(factory: TransmissionConnectionFactory = TransmissionConnectionFactory()) { - self.factory = factory - } - - init( - transport: TransmissionTransport = TransmissionTransport(), - credentialResolver: TransmissionCredentialResolver = .live - ) { - self.factory = TransmissionConnectionFactory( - transport: transport, - credentialResolver: credentialResolver - ) - } - - func performDataRequest( - method: String, - args: Args, - config: TransmissionConfig, - auth: TransmissionAuth, - responseType: ResponseData.Type = ResponseData.self - ) async -> Result { - do { - let connection = try await connection(for: config, auth: auth) - let responseData = try await connection.sendRequiredArguments( - method: method, - arguments: args, - responseType: responseType - ) - return .success(responseData) - } catch { - return .failure(TransmissionLegacyCompatibility.localizedError(from: error)) - } - } - - func performStatusRequest( - method: String, - args: Args, - config: TransmissionConfig, - auth: TransmissionAuth - ) async -> TransmissionResponse { - do { - let connection = try await connection(for: config, auth: auth) - try await connection.sendStatusRequest( - method: method, - arguments: args - ) - return .success - } catch { - return TransmissionLegacyCompatibility.response(from: error) - } - } - - func performTorrentAddRequest( - args: StringArguments, - config: TransmissionConfig, - auth: TransmissionAuth - ) async -> (response: TransmissionResponse, transferId: Int) { - do { - let connection = try await connection(for: config, auth: auth) - let outcome = try await connection.sendTorrentAdd(arguments: args) - - switch outcome { - case .added(let torrent), .duplicate(let torrent): - return (.success, torrent.id) - } - } catch { - return (TransmissionLegacyCompatibility.response(from: error), 0) - } - } - - func fetchTorrentFiles( - transferID: Int, - config: TransmissionConfig, - auth: TransmissionAuth - ) async -> Result { - await performQuery(config: config, auth: auth) { connection in - try await connection.fetchTorrentFiles(id: transferID) - } - } - - func fetchTorrentPeers( - transferID: Int, - config: TransmissionConfig, - auth: TransmissionAuth - ) async -> Result { - await performQuery(config: config, auth: auth) { connection in - try await connection.fetchTorrentPeers(id: transferID) - } - } - - func fetchTorrentPieces( - transferID: Int, - config: TransmissionConfig, - auth: TransmissionAuth - ) async -> Result { - await performQuery(config: config, auth: auth) { connection in - try await connection.fetchTorrentPieces(id: transferID) - } - } - - private func connection( - for config: TransmissionConfig, - auth: TransmissionAuth - ) async throws -> TransmissionConnection { - try await factory.connection(for: TransmissionConnectionDescriptor(config: config, auth: auth)) - } - - private func performQuery( - config: TransmissionConfig, - auth: TransmissionAuth, - operation: (TransmissionConnection) async throws -> Success - ) async -> Result { - do { - let connection = try await connection(for: config, auth: auth) - return .success(try await operation(connection)) - } catch { - return .failure(TransmissionLegacyCompatibility.localizedError(from: error)) - } - } -} - -private let legacyTransmissionAdapter = TransmissionLegacyAdapter() - -// MARK: - Generic API Method Factory - -/// Generic method to perform any Transmission RPC action that returns data -public func performTransmissionDataRequest( - method: String, - args: Args, - config: TransmissionConfig, - auth: TransmissionAuth, - completion: @MainActor @escaping (Result) -> Void -) { - Task { - let result = await legacyTransmissionAdapter.performDataRequest( - method: method, - args: args, - config: config, - auth: auth, - responseType: ResponseData.self - ) - await completion(result) - } -} - -/// Generic method to perform any Transmission RPC action that only needs status -public func performTransmissionStatusRequest( - method: String, - args: Args, - config: TransmissionConfig, - auth: TransmissionAuth, - completion: @MainActor @escaping (TransmissionResponse) -> Void -) { - Task { - let response = await legacyTransmissionAdapter.performStatusRequest( - method: method, - args: args, - config: config, - auth: auth - ) - await completion(response) - } -} - -// MARK: - Torrent Action Helper - -/// Executes a torrent action on a specific torrent -/// - Parameters: -/// - actionMethod: The action method name (torrent-start, torrent-stop, etc.) -/// - torrentId: The ID of the torrent to perform the action on -/// - config: Server configuration -/// - auth: Authentication credentials -/// - onResponse: Callback with the server's response -private func executeTorrentAction(actionMethod: String, torrentId: Int, config: TransmissionConfig, auth: TransmissionAuth, onResponse: @MainActor @escaping (TransmissionResponse) -> Void) { - performTransmissionStatusRequest( - method: actionMethod, - args: ["ids": [torrentId]] as [String: [Int]], - config: config, - auth: auth, - completion: onResponse - ) -} - -// MARK: - API Functions - -// TODO: Revisit this signature and reduce parameter count. -// swiftlint:disable function_parameter_count -/// Makes a request to the server containing either a base64 representation of a .torrent file or a magnet link -/// - Parameter fileUrl: Either a magnet link or base64 encoded file -/// - Parameter auth: A `TransmissionAuth` containing username and password for the server -/// - Parameter file: A boolean value; true if `fileUrl` is a base64 encoded file and false if `fileUrl` is a magnet link -/// - Parameter config: A `TransmissionConfig` containing the server's address and port -/// - Parameter onAdd: An escaping function that receives the servers response code represented as a `TransmissionResponse` -public func addTorrent( - fileUrl: String, - saveLocation: String, - auth: TransmissionAuth, - file: Bool, - config: TransmissionConfig, - onAdd: @MainActor @escaping ((response: TransmissionResponse, transferId: Int)) -> Void -) { - // Create the torrent body based on the value of `fileUrl` and `file` - let args: [String: String] = file ? - ["metainfo": fileUrl, "download-dir": saveLocation] : - ["filename": fileUrl, "download-dir": saveLocation] - - Task { - let result = await legacyTransmissionAdapter.performTorrentAddRequest( - args: args, - config: config, - auth: auth - ) - await onAdd(result) - } -} -// swiftlint:enable function_parameter_count - -/// Gets the list of files in a torrent -/// - Parameter transferId: The ID of the torrent to get files for -/// - Parameter info: A tuple containing the server config and auth info -/// - Parameter onReceived: A callback that receives the list of files and their stats -public func getTorrentFiles(transferId: Int, info: (config: TransmissionConfig, auth: TransmissionAuth), onReceived: @MainActor @escaping ([TorrentFile], [TorrentFileStats]) -> Void) { - Task { - let result = await legacyTransmissionAdapter.fetchTorrentFiles( - transferID: transferId, - config: info.config, - auth: info.auth - ) - - switch result { - case .success(let response): - await onReceived(response.files, response.fileStats) - case .failure: - await onReceived([], []) - } - } -} - -/// Deletes a torrent from the queue -/// - Parameter torrent: The `Torrent` to be deleted -/// - Parameter erase: Whether or not to delete the downloaded data from the server along with the transfer in Transmssion -/// - Parameter config: A `TransmissionConfig` containing the server's address and port -/// - Parameter auth: A `TransmissionAuth` containing username and password for the server -/// - Parameter onDel: An escaping function that receives the server's response code as a `TransmissionResponse` -public func deleteTorrent(torrent: Torrent, erase: Bool, config: TransmissionConfig, auth: TransmissionAuth, onDel: @MainActor @escaping (TransmissionResponse) -> Void) { - let args = TransmissionRemoveRequestArgs( - ids: [torrent.id], - deleteLocalData: erase - ) - - performTransmissionStatusRequest( - method: "torrent-remove", - args: args, - config: config, - auth: auth, - completion: onDel - ) -} - -public func playPauseTorrent(torrent: Torrent, config: TransmissionConfig, auth: TransmissionAuth, onResponse: @MainActor @escaping (TransmissionResponse) -> Void) { - // If the torrent already has `stopped` status, start it. Otherwise, stop it. - let actionMethod = torrent.status == TorrentStatus.stopped.rawValue ? "torrent-start" : "torrent-stop" - executeTorrentAction(actionMethod: actionMethod, torrentId: torrent.id, config: config, auth: auth, onResponse: onResponse) -} - -/// Play/Pause all active transfers -/// - Parameter start: True if we are starting all transfers, false if we are stopping them -/// - Parameter info: An info struct generated from makeConfig -/// - Parameter onResponse: Called when the request is complete -public func playPauseAllTorrents(start: Bool, info: (config: TransmissionConfig, auth: TransmissionAuth), onResponse: @MainActor @escaping (TransmissionResponse) -> Void) { - // If the torrent already has `stopped` status, start it. Otherwise, stop it. - let method = start ? "torrent-start" : "torrent-stop" - - performTransmissionStatusRequest( - method: method, - args: EmptyArguments(), - config: info.config, - auth: info.auth, - completion: onResponse - ) -} - -/// Pause multiple torrents by IDs -public func pauseTorrents( - ids: [Int], - info: (config: TransmissionConfig, auth: TransmissionAuth), - onResponse: @MainActor @escaping (TransmissionResponse) -> Void -) { - performTransmissionStatusRequest( - method: "torrent-stop", - args: ["ids": ids] as [String: [Int]], - config: info.config, - auth: info.auth, - completion: onResponse - ) -} - -/// Resume multiple torrents by IDs -public func resumeTorrents( - ids: [Int], - info: (config: TransmissionConfig, auth: TransmissionAuth), - onResponse: @MainActor @escaping (TransmissionResponse) -> Void -) { - performTransmissionStatusRequest( - method: "torrent-start", - args: ["ids": ids] as [String: [Int]], - config: info.config, - auth: info.auth, - completion: onResponse - ) -} - -public func verifyTorrent(torrent: Torrent, config: TransmissionConfig, auth: TransmissionAuth, onResponse: @MainActor @escaping (TransmissionResponse) -> Void) { - executeTorrentAction(actionMethod: "torrent-verify", torrentId: torrent.id, config: config, auth: auth, onResponse: onResponse) -} - -/// Update torrent properties using the torrent-set method -/// - Parameter args: TorrentSetRequestArgs containing the properties and IDs to update -/// - Parameter info: Tuple containing server config and auth info -/// - Parameter onComplete: Called when the server's response is received -public func updateTorrent(args: TorrentSetRequestArgs, info: (config: TransmissionConfig, auth: TransmissionAuth), onComplete: @MainActor @escaping (TransmissionResponse) -> Void) { - performTransmissionStatusRequest( - method: "torrent-set", - args: args, - config: info.config, - auth: info.auth, - completion: onComplete - ) -} - -public func startTorrentNow(torrent: Torrent, config: TransmissionConfig, auth: TransmissionAuth, onResponse: @MainActor @escaping (TransmissionResponse) -> Void) { - executeTorrentAction(actionMethod: "torrent-start-now", torrentId: torrent.id, config: config, auth: auth, onResponse: onResponse) -} - -public func reAnnounceTorrent(torrent: Torrent, config: TransmissionConfig, auth: TransmissionAuth, onResponse: @MainActor @escaping (TransmissionResponse) -> Void) { - executeTorrentAction(actionMethod: "torrent-reannounce", torrentId: torrent.id, config: config, auth: auth, onResponse: onResponse) -} - -// MARK: - File Operation Functions - -/// Set wanted status for specific files in a torrent -public func setFileWantedStatus( - torrentId: Int, - fileIndices: [Int], - wanted: Bool, - info: (config: TransmissionConfig, auth: TransmissionAuth), - completion: @MainActor @escaping (TransmissionResponse) -> Void -) { - var args = TorrentSetRequestArgs(ids: [torrentId]) - if wanted { - args.filesWanted = fileIndices - } else { - args.filesUnwanted = fileIndices - } - - updateTorrent(args: args, info: info, onComplete: completion) -} - -/// Move or relocate torrent data on the server -/// - Parameters: -/// - args: TorrentSetLocationRequestArgs with ids, destination location, and move flag -/// - info: Tuple containing server config and auth info -/// - completion: Called with TransmissionResponse status -public func setTorrentLocation( - args: TorrentSetLocationRequestArgs, - info: (config: TransmissionConfig, auth: TransmissionAuth), - completion: @MainActor @escaping (TransmissionResponse) -> Void -) { - performTransmissionStatusRequest( - method: "torrent-set-location", - args: args, - config: info.config, - auth: info.auth, - completion: completion - ) -} - -// TODO: Revisit this signature and reduce parameter count. -// swiftlint:disable function_parameter_count -/// Rename a path (file or folder) within a torrent -/// - Parameters: -/// - torrentId: The torrent ID (Transmission expects exactly one id) -/// - path: The current path (relative to torrent root) to rename. For renaming the torrent root, pass the torrent name. -/// - newName: The new name for the path component -/// - config: Server configuration -/// - auth: Authentication credentials -/// - completion: Result containing the server's rename response args or an error -public func renameTorrentPath( - torrentId: Int, - path: String, - newName: String, - config: TransmissionConfig, - auth: TransmissionAuth, - completion: @MainActor @escaping (Result) -> Void -) { - let args = TorrentRenameRequestArgs(ids: [torrentId], path: path, name: newName) - performTransmissionDataRequest( - method: "torrent-rename-path", - args: args, - config: config, - auth: auth, - completion: { (result: Result) in - switch result { - case .success(let response): - completion(.success(response)) - case .failure(let error): - completion(.failure(error)) - } - } - ) -} -// swiftlint:enable function_parameter_count - -/// Set priority for specific files in a torrent -public func setFilePriority( - torrentId: Int, - fileIndices: [Int], - priority: FilePriority, - info: (config: TransmissionConfig, auth: TransmissionAuth), - completion: @MainActor @escaping (TransmissionResponse) -> Void -) { - var args = TorrentSetRequestArgs(ids: [torrentId]) - - switch priority { - case .low: args.priorityLow = fileIndices - case .normal: args.priorityNormal = fileIndices - case .high: args.priorityHigh = fileIndices - } - - updateTorrent(args: args, info: info, onComplete: completion) -} - -// MARK: - Queue Management Functions - -/// Move torrents to the top of the queue -/// - Parameters: -/// - ids: Array of torrent IDs to move -/// - info: Tuple containing server config and auth info -/// - completion: Called when the server's response is received -public func queueMoveTop( - ids: [Int], - info: (config: TransmissionConfig, auth: TransmissionAuth), - completion: @MainActor @escaping (TransmissionResponse) -> Void -) { - performTransmissionStatusRequest( - method: "queue-move-top", - args: ["ids": ids] as [String: [Int]], - config: info.config, - auth: info.auth, - completion: completion - ) -} - -/// Move torrents up one position in the queue -/// - Parameters: -/// - ids: Array of torrent IDs to move -/// - info: Tuple containing server config and auth info -/// - completion: Called when the server's response is received -public func queueMoveUp( - ids: [Int], - info: (config: TransmissionConfig, auth: TransmissionAuth), - completion: @MainActor @escaping (TransmissionResponse) -> Void -) { - performTransmissionStatusRequest( - method: "queue-move-up", - args: ["ids": ids] as [String: [Int]], - config: info.config, - auth: info.auth, - completion: completion - ) -} - -/// Move torrents down one position in the queue -/// - Parameters: -/// - ids: Array of torrent IDs to move -/// - info: Tuple containing server config and auth info -/// - completion: Called when the server's response is received -public func queueMoveDown( - ids: [Int], - info: (config: TransmissionConfig, auth: TransmissionAuth), - completion: @MainActor @escaping (TransmissionResponse) -> Void -) { - performTransmissionStatusRequest( - method: "queue-move-down", - args: ["ids": ids] as [String: [Int]], - config: info.config, - auth: info.auth, - completion: completion - ) -} - -/// Move torrents to the bottom of the queue -/// - Parameters: -/// - ids: Array of torrent IDs to move -/// - info: Tuple containing server config and auth info -/// - completion: Called when the server's response is received -public func queueMoveBottom( - ids: [Int], - info: (config: TransmissionConfig, auth: TransmissionAuth), - completion: @MainActor @escaping (TransmissionResponse) -> Void -) { - performTransmissionStatusRequest( - method: "queue-move-bottom", - args: ["ids": ids] as [String: [Int]], - config: info.config, - auth: info.auth, - completion: completion - ) -} - -// MARK: - Peer Queries - -/// Gets the list of peers (and peersFrom breakdown) for a torrent -/// - Parameters: -/// - transferId: The ID of the torrent -/// - info: Tuple containing server config and auth info -/// - onReceived: Callback providing peers and optional peersFrom breakdown -public func getTorrentPeers( - transferId: Int, - info: (config: TransmissionConfig, auth: TransmissionAuth), - onReceived: @MainActor @escaping (_ peers: [Peer], _ peersFrom: PeersFrom?) -> Void -) { - Task { - let result = await legacyTransmissionAdapter.fetchTorrentPeers( - transferID: transferId, - config: info.config, - auth: info.auth - ) - - switch result { - case .success(let response): - await onReceived(response.peers, response.peersFrom) - case .failure: - await onReceived([], nil) - } - } -} - -// MARK: - Pieces Queries - -/// Gets the pieces bitfield and metadata for a torrent -/// - Parameters: -/// - transferId: The ID of the torrent -/// - info: Tuple containing server config and auth info -/// - onReceived: Callback providing pieceCount, pieceSize, and base64-encoded pieces bitfield -public func getTorrentPieces( - transferId: Int, - info: (config: TransmissionConfig, auth: TransmissionAuth), - onReceived: @MainActor @escaping (_ pieceCount: Int, _ pieceSize: Int64, _ piecesBitfieldBase64: String) -> Void -) { - Task { - let result = await legacyTransmissionAdapter.fetchTorrentPieces( - transferID: transferId, - config: info.config, - auth: info.auth - ) - - switch result { - case .success(let response): - await onReceived(response.pieceCount, response.pieceSize, response.pieces) - case .failure: - await onReceived(0, 0, "") - } - } -} diff --git a/BitDream/Transmission/TransmissionLegacyConnectionInfo.swift b/BitDream/Transmission/TransmissionLegacyConnectionInfo.swift deleted file mode 100644 index 50d2d3e..0000000 --- a/BitDream/Transmission/TransmissionLegacyConnectionInfo.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -// TODO: Remove this file in phase 5 once write-side callers stop depending on -// `makeConfig(store:)`. -/// Temporary compatibility helper for legacy write-side call sites. -/// Phase 4 removes settings/session use of this helper, but torrent actions still depend on it. -@MainActor -func makeConfig(store: TransmissionStore) -> (config: TransmissionConfig, auth: TransmissionAuth) { - guard let host = store.host else { - return (TransmissionConfig(), TransmissionAuth(username: "", password: "")) - } - - var config = TransmissionConfig() - config.host = host.server - config.port = Int(host.port) - config.scheme = host.isSSL ? "https" : "http" - - let auth = TransmissionAuth( - username: host.username ?? "", - password: store.readPassword(for: host) - ) - return (config, auth) -} diff --git a/BitDream/Transmission/TransmissionReadSnapshots.swift b/BitDream/Transmission/TransmissionReadSnapshots.swift index 1b9a38d..4e5ba71 100644 --- a/BitDream/Transmission/TransmissionReadSnapshots.swift +++ b/BitDream/Transmission/TransmissionReadSnapshots.swift @@ -16,6 +16,16 @@ internal struct TransmissionWidgetRefreshSnapshot: Sendable { let torrentSummaryError: TransmissionError? } +internal struct TransmissionTorrentDetailSnapshot: Sendable { + let files: [TorrentFile] + let fileStats: [TorrentFileStats] + let peers: [Peer] + let peersFrom: PeersFrom? + let pieceCount: Int + let pieceSize: Int64 + let piecesBitfieldBase64: String +} + internal extension TransmissionConnection { func fetchPollingSnapshot() async throws -> TransmissionPollingSnapshot { async let sessionStats = fetchSessionStats() @@ -50,6 +60,26 @@ internal extension TransmissionConnection { .widgetSnapshot(torrentSummaryError: resolvedTorrentSummary.error) } + func fetchTorrentDetailSnapshot(id: Int) async throws -> TransmissionTorrentDetailSnapshot { + async let filesResponse = fetchTorrentFiles(id: id) + async let peersResponse = fetchTorrentPeers(id: id) + async let piecesResponse = fetchTorrentPieces(id: id) + + let files = try await filesResponse + let peers = try await peersResponse + let pieces = try await piecesResponse + + return TransmissionTorrentDetailSnapshot( + files: files.files, + fileStats: files.fileStats, + peers: peers.peers, + peersFrom: peers.peersFrom, + pieceCount: pieces.pieceCount, + pieceSize: pieces.pieceSize, + piecesBitfieldBase64: pieces.pieces + ) + } + private func fetchSessionSettingsResult() async -> Result { do { return .success(try await fetchSessionSettings()) diff --git a/BitDream/Transmission/TransmissionTorrentModels.swift b/BitDream/Transmission/TransmissionTorrentModels.swift index 003a1af..d6a420f 100644 --- a/BitDream/Transmission/TransmissionTorrentModels.swift +++ b/BitDream/Transmission/TransmissionTorrentModels.swift @@ -2,21 +2,14 @@ import Foundation // MARK: - Enums -public enum TransmissionResponse: Sendable, Equatable { - case success - case unauthorized - case configError - case failed -} - -public enum TorrentPriority: String { - case high = "priority-high" - case normal = "priority-normal" - case low = "priority-low" +public enum TorrentPriority: Int, Sendable { + case high = 1 + case normal = 0 + case low = -1 } // Priority enum for torrent files -public enum FilePriority: Int { +public enum FilePriority: Int, Sendable { case low = -1 case normal = 0 case high = 1 @@ -172,7 +165,7 @@ public struct Torrent: Codable, Hashable, Identifiable, Sendable { } } -public struct TorrentFile: Codable, Identifiable, Sendable { +public struct TorrentFile: Codable, Equatable, Identifiable, Sendable { public var id: String { name } var bytesCompleted: Int64 var length: Int64 @@ -180,7 +173,7 @@ public struct TorrentFile: Codable, Identifiable, Sendable { var percentDone: Double { Double(bytesCompleted) / Double(length) } } -public struct TorrentFileStats: Codable, Sendable { +public struct TorrentFileStats: Codable, Equatable, Sendable { var bytesCompleted: Int64 var wanted: Bool var priority: Int @@ -323,11 +316,7 @@ public struct TorrentSetRequestArgs: Codable, Sendable { public init(ids: [Int], priority: TorrentPriority) { self.ids = ids - switch priority { - case .high: priorityHigh = [] - case .normal: priorityNormal = [] - case .low: priorityLow = [] - } + bandwidthPriority = priority.rawValue } enum CodingKeys: String, CodingKey { diff --git a/BitDream/Transmission/TransmissionTorrents.swift b/BitDream/Transmission/TransmissionTorrents.swift index 0bebd57..821a36b 100644 --- a/BitDream/Transmission/TransmissionTorrents.swift +++ b/BitDream/Transmission/TransmissionTorrents.swift @@ -1,5 +1,12 @@ import Foundation +internal enum TransmissionTorrentQueueMoveDirection: Sendable { + case top + case upward + case downward + case bottom +} + internal struct TransmissionTorrentListQuerySpec: Sendable { let fields: [String] @@ -52,6 +59,18 @@ internal enum TransmissionTorrentQuerySpec { } internal extension TransmissionConnection { + func addTorrent( + fileURL: String, + saveLocation: String, + isTorrentFile: Bool + ) async throws -> TransmissionTorrentAddOutcome { + let arguments: StringArguments = isTorrentFile + ? ["metainfo": fileURL, "download-dir": saveLocation] + : ["filename": fileURL, "download-dir": saveLocation] + + return try await sendTorrentAdd(arguments: arguments) + } + func fetchTorrentSummary() async throws -> [Torrent] { let response = try await sendRequiredArguments( method: "torrent-get", @@ -113,4 +132,169 @@ internal extension TransmissionConnection { return torrent } + + func removeTorrents(ids: [Int], deleteLocalData: Bool) async throws { + guard !ids.isEmpty else { return } + + try await sendStatusRequest( + method: "torrent-remove", + arguments: TransmissionRemoveRequestArgs( + ids: ids, + deleteLocalData: deleteLocalData + ) + ) + } + + func pauseTorrents(ids: [Int]) async throws { + try await performBatchTorrentAction(method: "torrent-stop", ids: ids) + } + + func resumeTorrents(ids: [Int]) async throws { + try await performBatchTorrentAction(method: "torrent-start", ids: ids) + } + + func pauseAllTorrents() async throws { + try await sendStatusRequest( + method: "torrent-stop", + arguments: EmptyArguments() + ) + } + + func resumeAllTorrents() async throws { + try await sendStatusRequest( + method: "torrent-start", + arguments: EmptyArguments() + ) + } + + func startTorrentsNow(ids: [Int]) async throws { + try await performBatchTorrentAction(method: "torrent-start-now", ids: ids) + } + + func verifyTorrents(ids: [Int]) async throws { + try await performBatchTorrentAction(method: "torrent-verify", ids: ids) + } + + func reannounceTorrents(ids: [Int]) async throws { + try await performBatchTorrentAction(method: "torrent-reannounce", ids: ids) + } + + func updateTorrents(_ args: TorrentSetRequestArgs) async throws { + guard !args.ids.isEmpty else { return } + + try await sendStatusRequest( + method: "torrent-set", + arguments: args + ) + } + + func setTorrentPriority(ids: [Int], priority: TorrentPriority) async throws { + try await updateTorrents( + TorrentSetRequestArgs(ids: ids, priority: priority) + ) + } + + func setTorrentLabels(ids: [Int], labels: [String]) async throws { + guard !ids.isEmpty else { return } + + let normalizedLabels = labels.sorted() + try await updateTorrents( + TorrentSetRequestArgs(ids: ids, labels: normalizedLabels) + ) + } + + func setFileWantedStatus( + torrentID: Int, + fileIndices: [Int], + wanted: Bool + ) async throws { + guard !fileIndices.isEmpty else { return } + + var args = TorrentSetRequestArgs(ids: [torrentID]) + if wanted { + args.filesWanted = fileIndices + } else { + args.filesUnwanted = fileIndices + } + + try await updateTorrents(args) + } + + func setFilePriority( + torrentID: Int, + fileIndices: [Int], + priority: FilePriority + ) async throws { + guard !fileIndices.isEmpty else { return } + + var args = TorrentSetRequestArgs(ids: [torrentID]) + switch priority { + case .low: + args.priorityLow = fileIndices + case .normal: + args.priorityNormal = fileIndices + case .high: + args.priorityHigh = fileIndices + } + + try await updateTorrents(args) + } + + func setTorrentLocation( + ids: [Int], + location: String, + move: Bool + ) async throws { + guard !ids.isEmpty else { return } + + try await sendStatusRequest( + method: "torrent-set-location", + arguments: TorrentSetLocationRequestArgs( + ids: ids, + location: location, + move: move + ) + ) + } + + func renameTorrentPath( + torrentID: Int, + path: String, + newName: String + ) async throws -> TorrentRenameResponseArgs { + try await sendRequiredArguments( + method: "torrent-rename-path", + arguments: TorrentRenameRequestArgs( + ids: [torrentID], + path: path, + name: newName + ), + responseType: TorrentRenameResponseArgs.self + ) + } + + func queueMove(_ direction: TransmissionTorrentQueueMoveDirection, ids: [Int]) async throws { + let method: String + switch direction { + case .top: + method = "queue-move-top" + case .upward: + method = "queue-move-up" + case .downward: + method = "queue-move-down" + case .bottom: + method = "queue-move-bottom" + } + + try await performBatchTorrentAction(method: method, ids: ids) + } + + private func performBatchTorrentAction(method: String, ids: [Int]) async throws { + guard !ids.isEmpty else { return } + + try await sendStatusRequest( + method: method, + arguments: TorrentIDsArgument(ids: ids) + ) + } } diff --git a/BitDream/TransmissionStore.swift b/BitDream/TransmissionStore.swift index 0dcdfd4..a6b0000 100644 --- a/BitDream/TransmissionStore.swift +++ b/BitDream/TransmissionStore.swift @@ -10,6 +10,11 @@ enum AddTorrentInitialMode { } #endif +internal struct TransmissionTorrentLabelsUpdate: Sendable { + let ids: [Int] + let labels: [String] +} + @MainActor final class TransmissionStore: NSObject, ObservableObject { private struct ActiveConnection: Sendable { @@ -283,6 +288,202 @@ extension TransmissionStore { return response } + func addTorrent(magnetLink: String, saveLocation: String) async throws -> TransmissionTorrentAddOutcome { + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.addTorrent( + fileURL: magnetLink, + saveLocation: saveLocation, + isTorrentFile: false + ) + } + } + + func addTorrent(fileData: Data, saveLocation: String) async throws -> TransmissionTorrentAddOutcome { + let fileStream = fileData.base64EncodedString(options: []) + + return try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.addTorrent( + fileURL: fileStream, + saveLocation: saveLocation, + isTorrentFile: true + ) + } + } + + func loadTorrentDetail(id: Int) async throws -> TransmissionTorrentDetailSnapshot { + try await performConnectionOperation { connectionState in + try await connectionState.connection.fetchTorrentDetailSnapshot(id: id) + } + } + + func removeTorrents(ids: [Int], deleteLocalData: Bool) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.removeTorrents( + ids: ids, + deleteLocalData: deleteLocalData + ) + } + } + + func toggleTorrentPlayback(_ torrent: Torrent) async throws { + if torrent.status == TorrentStatus.stopped.rawValue { + try await resumeTorrents(ids: [torrent.id]) + } else { + try await pauseTorrents(ids: [torrent.id]) + } + } + + func pauseTorrents(ids: [Int]) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.pauseTorrents(ids: ids) + } + } + + func resumeTorrents(ids: [Int]) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.resumeTorrents(ids: ids) + } + } + + func pauseAllTorrents() async throws { + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.pauseAllTorrents() + } + } + + func resumeAllTorrents() async throws { + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.resumeAllTorrents() + } + } + + func startTorrentsNow(ids: [Int]) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.startTorrentsNow(ids: ids) + } + } + + func reannounceTorrents(ids: [Int]) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.reannounceTorrents(ids: ids) + } + } + + func verifyTorrents(ids: [Int]) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.verifyTorrents(ids: ids) + } + } + + func moveTorrentsInQueue(_ direction: TransmissionTorrentQueueMoveDirection, ids: [Int]) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.queueMove(direction, ids: ids) + } + } + + func updateTorrentPriority(ids: [Int], priority: TorrentPriority) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.setTorrentPriority(ids: ids, priority: priority) + } + } + + func updateTorrentLabels(_ updates: [TransmissionTorrentLabelsUpdate]) async throws { + let nonEmptyUpdates = updates.filter { !$0.ids.isEmpty } + guard !nonEmptyUpdates.isEmpty else { return } + + let connectionState = try requireActiveConnection() + var appliedAnyUpdate = false + + do { + for update in nonEmptyUpdates { + try await connectionState.connection.setTorrentLabels( + ids: update.ids, + labels: update.labels + ) + appliedAnyUpdate = true + } + + try ensureCurrent(connectionState) + requestRefresh() + } catch { + if appliedAnyUpdate, (try? ensureCurrent(connectionState)) != nil { + requestRefresh() + } + + throw error + } + } + + func setTorrentLocation(ids: [Int], location: String, move: Bool) async throws { + guard !ids.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.setTorrentLocation( + ids: ids, + location: location, + move: move + ) + } + } + + func renameTorrentRoot(_ torrent: Torrent, to newName: String) async throws -> TorrentRenameResponseArgs { + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.renameTorrentPath( + torrentID: torrent.id, + path: torrent.name, + newName: newName + ) + } + } + + func setFileWantedStatus( + torrentId: Int, + fileIndices: [Int], + wanted: Bool + ) async throws { + guard !fileIndices.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.setFileWantedStatus( + torrentID: torrentId, + fileIndices: fileIndices, + wanted: wanted + ) + } + } + + func setFilePriority( + torrentId: Int, + fileIndices: [Int], + priority: FilePriority + ) async throws { + guard !fileIndices.isEmpty else { return } + + try await performConnectionOperation(refreshOnSuccess: true) { connectionState in + try await connectionState.connection.setFilePriority( + torrentID: torrentId, + fileIndices: fileIndices, + priority: priority + ) + } + } + func clearReconnectPresentationState() { nextRetryAt = nil cancelRetryTask() @@ -652,12 +853,25 @@ extension TransmissionStore { private func requireActiveConnection() throws -> ActiveConnection { guard let activeConnection else { - throw CancellationError() + throw TransmissionError.invalidEndpointConfiguration } return activeConnection } + private func performConnectionOperation( + refreshOnSuccess: Bool = false, + operation: (ActiveConnection) async throws -> Result + ) async throws -> Result { + let connectionState = try requireActiveConnection() + let result = try await operation(connectionState) + try ensureCurrent(connectionState) + if refreshOnSuccess { + requestRefresh() + } + return result + } + private func ensureCurrent(_ connectionState: ActiveConnection) throws { guard isCurrentGeneration(connectionState.generation, hostID: connectionState.hostID) else { throw CancellationError() diff --git a/BitDream/Views/Shared/AddTorrent.swift b/BitDream/Views/Shared/AddTorrent.swift index 0e9767a..8187df7 100644 --- a/BitDream/Views/Shared/AddTorrent.swift +++ b/BitDream/Views/Shared/AddTorrent.swift @@ -25,41 +25,38 @@ func handleAddTorrentError(_ message: String, errorMessage: Binding, sh showingError.wrappedValue = true } -/// Function to add a torrent to the server +let addTorrentNoServerConfiguredMessage = + "No server configured. Please add or select a server in Settings." + @MainActor -func addTorrentAction( - alertInput: String, - downloadDir: String, - store: TransmissionStore, +func presentAddTorrentSheetError( + detail: String, errorMessage: Binding, - showingError: Binding, - onSuccess: (@MainActor () -> Void)? = nil + showingError: Binding ) { - // Only proceed if we have a magnet link - guard !alertInput.isEmpty else { return } - - // Send the magnet link to the server - let info = makeConfig(store: store) - addTorrent( - fileUrl: alertInput, - saveLocation: downloadDir, - auth: info.auth, - file: false, - config: info.config, - onAdd: { response in - if let presentation = TransmissionLegacyCompatibility.presentation(for: response.response) { - handleAddTorrentError( - "Failed to add torrent: \(presentation.message)", - errorMessage: errorMessage, - showingError: showingError - ) - } else { - onSuccess?() - } - } + handleAddTorrentError( + TransmissionActionFailureContext.addTorrent.inlineMessage(detail: detail), + errorMessage: errorMessage, + showingError: showingError ) } +@MainActor +func presentAddTorrentStoreError( + detail: String, + store: TransmissionStore +) { + #if os(macOS) + store.globalAlertTitle = TransmissionActionFailureContext.addTorrent.globalAlertTitle + store.globalAlertMessage = TransmissionActionFailureContext.addTorrent.globalAlertMessage(detail: detail) + store.showGlobalAlert = true + #else + store.debugBrief = TransmissionActionFailureContext.addTorrent.debugBrief + store.debugMessage = detail + store.isError = true + #endif +} + // MARK: - Extensions extension UTType { /// Convenience UTType for .torrent used by file importer; prefers extension, then MIME type, then .data @@ -69,51 +66,3 @@ extension UTType { ?? .data } } - -// MARK: - Programmatic Add from .torrent data - -/// Adds a torrent by sending a base64-encoded .torrent file to Transmission without presenting UI -@MainActor -func addTorrentFromFileData(_ fileData: Data, store: TransmissionStore) { - // Ensure server is configured; surface an error instead of silently returning - guard store.host != nil else { - #if os(macOS) - store.globalAlertTitle = "Error" - store.globalAlertMessage = "Failed to add torrent\n\nNo server configured. Please add or select a server in Settings." - store.showGlobalAlert = true - #else - store.debugBrief = "Failed to add torrent" - store.debugMessage = "No server configured. Please add or select a server in Settings." - store.isError = true - #endif - return - } - - let fileStream = fileData.base64EncodedString(options: []) - let info = makeConfig(store: store) - - addTorrent( - fileUrl: fileStream, - saveLocation: store.defaultDownloadDir, - auth: info.auth, - file: true, - config: info.config, - onAdd: { response in - handleTransmissionResponse( - response.response, - onSuccess: {}, - onError: { message in - #if os(macOS) - store.globalAlertTitle = "Error" - store.globalAlertMessage = "Failed to add torrent\n\n\(message)" - store.showGlobalAlert = true - #else - store.debugBrief = "Failed to add torrent" - store.debugMessage = message - store.isError = true - #endif - } - ) - } - ) -} diff --git a/BitDream/Views/Shared/TorrentActionExecutor.swift b/BitDream/Views/Shared/TorrentActionExecutor.swift deleted file mode 100644 index 198660c..0000000 --- a/BitDream/Views/Shared/TorrentActionExecutor.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Foundation - -@MainActor -func reAnnounceToTrackers( - torrent: Torrent, - store: TransmissionStore, - onResponse: @MainActor @escaping (TransmissionResponse) -> Void = { _ in } -) { - let info = makeConfig(store: store) - reAnnounceTorrent(torrent: torrent, config: info.config, auth: info.auth, onResponse: onResponse) -} - -@MainActor -func resumeTorrentNow( - torrent: Torrent, - store: TransmissionStore, - onResponse: @MainActor @escaping (TransmissionResponse) -> Void = { _ in } -) { - let info = makeConfig(store: store) - startTorrentNow(torrent: torrent, config: info.config, auth: info.auth, onResponse: onResponse) -} - -enum TorrentQueueMoveDirection { - case top - case upward - case downward - case bottom -} - -enum TorrentActionExecutor { - @MainActor - static func pause(ids: [Int], store: TransmissionStore, onError: @escaping (String) -> Void) { - perform(ids: ids, store: store, onError: onError) { ids, info, onResponse in - pauseTorrents(ids: ids, info: info, onResponse: onResponse) - } - } - - @MainActor - static func resume(ids: [Int], store: TransmissionStore, onError: @escaping (String) -> Void) { - perform(ids: ids, store: store, onError: onError) { ids, info, onResponse in - resumeTorrents(ids: ids, info: info, onResponse: onResponse) - } - } - - @MainActor - static func setAllPlayback(start: Bool, store: TransmissionStore, onError: @escaping (String) -> Void) { - guard !store.torrents.isEmpty else { return } - - let info = makeConfig(store: store) - playPauseAllTorrents(start: start, info: info) { response in - handleResponse(response, onError: onError) - } - } - - @MainActor - static func resumeNow(torrents: [Torrent], store: TransmissionStore, onError: @escaping (String) -> Void) { - perform(torrents: torrents, store: store, onError: onError) { torrent, store, onResponse in - resumeTorrentNow(torrent: torrent, store: store, onResponse: onResponse) - } - } - - @MainActor - static func reannounce(torrents: [Torrent], store: TransmissionStore, onError: @escaping (String) -> Void) { - perform(torrents: torrents, store: store, onError: onError) { torrent, store, onResponse in - reAnnounceToTrackers(torrent: torrent, store: store, onResponse: onResponse) - } - } - - @MainActor - static func verify(torrents: [Torrent], store: TransmissionStore, onError: @escaping (String) -> Void) { - guard !torrents.isEmpty else { return } - - let info = makeConfig(store: store) - for torrent in torrents { - verifyTorrent(torrent: torrent, config: info.config, auth: info.auth) { response in - handleResponse(response, onError: onError) - } - } - } - - @MainActor - static func moveInQueue( - _ direction: TorrentQueueMoveDirection, - ids: [Int], - store: TransmissionStore, - onError: @escaping (String) -> Void - ) { - guard !ids.isEmpty else { return } - - let info = makeConfig(store: store) - switch direction { - case .top: - queueMoveTop(ids: ids, info: info) { response in - handleResponse(response, onError: onError) - } - case .upward: - queueMoveUp(ids: ids, info: info) { response in - handleResponse(response, onError: onError) - } - case .downward: - queueMoveDown(ids: ids, info: info) { response in - handleResponse(response, onError: onError) - } - case .bottom: - queueMoveBottom(ids: ids, info: info) { response in - handleResponse(response, onError: onError) - } - } - } - - @MainActor - private static func perform( - ids: [Int], - store: TransmissionStore, - onError: @escaping (String) -> Void, - action: (_ ids: [Int], _ info: (config: TransmissionConfig, auth: TransmissionAuth), _ onResponse: @MainActor @escaping (TransmissionResponse) -> Void) -> Void - ) { - guard !ids.isEmpty else { return } - - let info = makeConfig(store: store) - action(ids, info) { response in - handleResponse(response, onError: onError) - } - } - - @MainActor - private static func perform( - torrents: [Torrent], - store: TransmissionStore, - onError: @escaping (String) -> Void, - action: (_ torrent: Torrent, _ store: TransmissionStore, _ onResponse: @MainActor @escaping (TransmissionResponse) -> Void) -> Void - ) { - guard !torrents.isEmpty else { return } - - for torrent in torrents { - action(torrent, store) { response in - handleResponse(response, onError: onError) - } - } - } - - private static func handleResponse(_ response: TransmissionResponse, onError: @escaping (String) -> Void) { - handleTransmissionResponse(response, onSuccess: {}, onError: onError) - } -} diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index 21b1606..8cdc348 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -31,44 +31,6 @@ func statusColor(for torrent: Torrent) -> Color { } } -// Shared function to fetch torrent files -@MainActor -func fetchTorrentFiles(transferId: Int, store: TransmissionStore, completion: @escaping ([TorrentFile], [TorrentFileStats]) -> Void) { - let info = makeConfig(store: store) - - getTorrentFiles(transferId: transferId, info: info, onReceived: { files, fileStats in - completion(files, fileStats) - }) -} - -// Shared function to fetch torrent peers -@MainActor -func fetchTorrentPeers(transferId: Int, store: TransmissionStore, completion: @escaping ([Peer], PeersFrom?) -> Void) { - let info = makeConfig(store: store) - - getTorrentPeers(transferId: transferId, info: info, onReceived: { peers, peersFrom in - completion(peers, peersFrom) - }) -} - -// Shared function to play/pause a torrent -@MainActor -func toggleTorrentPlayPause(torrent: Torrent, store: TransmissionStore, completion: @escaping () -> Void = {}) { - let info = makeConfig(store: store) - playPauseTorrent(torrent: torrent, config: info.config, auth: info.auth, onResponse: { response in - handleTransmissionResponse(response, - onSuccess: { - completion() - }, - onError: { _ in - // For play/pause operations, we'll silently fail and still call completion - // since the UI should update regardless to reflect current state - completion() - } - ) - }) -} - struct TorrentDetailsDisplay { let percentComplete: String let percentAvailable: String @@ -157,7 +119,15 @@ struct TorrentDetailToolbar: ToolbarContent { ToolbarItem { Menu { Button(action: { - toggleTorrentPlayPause(torrent: torrent, store: store) + performTransmissionAction( + operation: { try await store.toggleTorrentPlayback(torrent) }, + onError: makeTransmissionDebugErrorHandler( + store: store, + context: torrent.status == TorrentStatus.stopped.rawValue + ? .resumeTorrents + : .pauseTorrents + ) + ) }, label: { HStack { Text(torrent.status == TorrentStatus.stopped.rawValue ? "Resume Dream" : "Pause Dream") diff --git a/BitDream/Views/Shared/TorrentFileDetail.swift b/BitDream/Views/Shared/TorrentFileDetail.swift index b7e2029..7cee7ca 100644 --- a/BitDream/Views/Shared/TorrentFileDetail.swift +++ b/BitDream/Views/Shared/TorrentFileDetail.swift @@ -282,51 +282,3 @@ struct TorrentFileRow: Identifiable { self.wanted = wanted } } - -// MARK: - Shared File Action Executor - -/// Namespaced executor that standardizes optimistic apply + revert-on-failure -/// across platforms while keeping all network calls in TransmissionFunctions. -enum FileActionExecutor { - /// Set wanted status for specific file indices. - @MainActor - static func setWanted( - torrentId: Int, - fileIndices: [Int], - store: TransmissionStore, - wanted: Bool - ) async -> TransmissionResponse { - let info = makeConfig(store: store) - return await withCheckedContinuation { continuation in - setFileWantedStatus( - torrentId: torrentId, - fileIndices: fileIndices, - wanted: wanted, - info: info - ) { response in - continuation.resume(returning: response) - } - } - } - - /// Set priority for specific file indices. - @MainActor - static func setPriority( - torrentId: Int, - fileIndices: [Int], - store: TransmissionStore, - priority: FilePriority - ) async -> TransmissionResponse { - let info = makeConfig(store: store) - return await withCheckedContinuation { continuation in - setFilePriority( - torrentId: torrentId, - fileIndices: fileIndices, - priority: priority, - info: info - ) { response in - continuation.resume(returning: response) - } - } - } -} diff --git a/BitDream/Views/Shared/TorrentListRow.swift b/BitDream/Views/Shared/TorrentListRow.swift index 3a259b0..aa1ea5c 100644 --- a/BitDream/Views/Shared/TorrentListRow.swift +++ b/BitDream/Views/Shared/TorrentListRow.swift @@ -172,24 +172,6 @@ func copyMagnetLinkToClipboard(_ magnetLink: String) { // MARK: - Shared Label Components -// Shared function to save labels and refresh torrent data -@MainActor -func saveTorrentLabels(torrentId: Int, labels: Set, store: TransmissionStore, onComplete: @escaping () -> Void = {}) { - let info = makeConfig(store: store) - let sortedLabels = Array(labels).sorted() - - // First update the labels - updateTorrent( - args: TorrentSetRequestArgs(ids: [torrentId], labels: sortedLabels), - info: info, - onComplete: { _ in - // Trigger an immediate refresh - store.requestRefresh() - onComplete() - } - ) -} - // Shared function to handle adding new tags from input field @MainActor func addNewTag(from input: inout String, to workingLabels: inout Set) -> Bool { @@ -323,31 +305,3 @@ func validateNewName(_ name: String, current: String) -> String? { } return nil } - -/// Rename the torrent root folder/name using Transmission's torrent-rename-path -/// - Parameters: -/// - torrent: The torrent whose root should be renamed -/// - newName: The new root name -/// - store: App store for config/auth and refresh -/// - onComplete: Called with nil on success, or an error message on failure -@MainActor -func renameTorrentRoot(torrent: Torrent, to newName: String, store: TransmissionStore, onComplete: @escaping (String?) -> Void) { - let info = makeConfig(store: store) - // For root rename, Transmission expects the current root path (the torrent's name) - renameTorrentPath( - torrentId: torrent.id, - path: torrent.name, - newName: newName, - config: info.config, - auth: info.auth - ) { result in - switch result { - case .success: - // Refresh to pick up updated name and files - store.requestRefresh() - onComplete(nil) - case .failure(let error): - onComplete(error.localizedDescription) - } - } -} diff --git a/BitDream/Views/Shared/TransmissionActions.swift b/BitDream/Views/Shared/TransmissionActions.swift new file mode 100644 index 0000000..cda45dc --- /dev/null +++ b/BitDream/Views/Shared/TransmissionActions.swift @@ -0,0 +1,215 @@ +import Foundation +import SwiftUI + +enum TransmissionActionFailureContext: Sendable { + case addTorrent + case askForMorePeers + case pauseAllTorrents + case pauseTorrents + case queueMove + case removeTorrents + case resumeAllTorrents + case resumeTorrents + case resumeTorrentsNow + case verifyTorrents + + var debugBrief: String { + switch self { + case .addTorrent: + "Failed to add torrent" + case .askForMorePeers: + "Failed to ask for more peers" + case .pauseAllTorrents: + "Failed to pause all torrents" + case .pauseTorrents: + "Failed to pause torrents" + case .queueMove: + "Failed to move torrents in queue" + case .removeTorrents: + "Failed to remove torrent" + case .resumeAllTorrents: + "Failed to resume all torrents" + case .resumeTorrents: + "Failed to resume torrents" + case .resumeTorrentsNow: + "Failed to resume torrents now" + case .verifyTorrents: + "Failed to verify torrent" + } + } + + func inlineMessage(detail: String) -> String { + "\(debugBrief): \(detail)" + } + +#if os(macOS) + var globalAlertTitle: String { + switch self { + case .queueMove: + "Queue Error" + default: + "Error" + } + } + + func globalAlertMessage(detail: String) -> String { + "\(debugBrief)\n\n\(detail)" + } +#endif +} + +@MainActor +func presentTransmissionError( + _ error: Error, + onError: @escaping @MainActor @Sendable (String) -> Void +) { + guard let message = TransmissionUserFacingError.message(for: error) else { + return + } + + onError(message) +} + +@MainActor +/// Use for button taps and similar event handlers that intentionally launch detached async work. +func performTransmissionAction( + operation: @escaping @MainActor @Sendable () async throws -> Void, + onSuccess: @escaping @MainActor @Sendable () -> Void = {}, + onError: @escaping @MainActor @Sendable (String) -> Void +) { + Task { @MainActor in + do { + try await operation() + onSuccess() + } catch { + presentTransmissionError(error, onError: onError) + } + } +} + +@MainActor +/// Use for button taps and similar event handlers that intentionally launch detached async work. +func performTransmissionAction( + operation: @escaping @MainActor @Sendable () async throws -> Result, + onSuccess: @escaping @MainActor @Sendable (Result) -> Void, + onError: @escaping @MainActor @Sendable (String) -> Void +) { + Task { @MainActor in + do { + let result = try await operation() + onSuccess(result) + } catch { + presentTransmissionError(error, onError: onError) + } + } +} + +@MainActor +/// Use for `.task`, `.refreshable`, and other structured lifetimes that must preserve parent cancellation. +func performStructuredTransmissionOperation( + operation: @escaping @MainActor @Sendable () async throws -> Result, + onError: @escaping @MainActor @Sendable (String) -> Void +) async -> Result? { + do { + try Task.checkCancellation() + let result = try await operation() + try Task.checkCancellation() + return result + } catch { + presentTransmissionError(error, onError: onError) + return nil + } +} + +@MainActor +func performTransmissionDebugAction( + _ context: TransmissionActionFailureContext, + store: TransmissionStore, + operation: @escaping @MainActor @Sendable () async throws -> Void, + onSuccess: @escaping @MainActor @Sendable () -> Void = {} +) { + performTransmissionAction( + operation: operation, + onSuccess: onSuccess, + onError: makeTransmissionDebugErrorHandler( + store: store, + context: context + ) + ) +} + +#if os(macOS) +@MainActor +func performTransmissionGlobalAlertAction( + _ context: TransmissionActionFailureContext, + store: TransmissionStore, + operation: @escaping @MainActor @Sendable () async throws -> Void, + onSuccess: @escaping @MainActor @Sendable () -> Void = {} +) { + performTransmissionAction( + operation: operation, + onSuccess: onSuccess, + onError: makeTransmissionGlobalAlertHandler( + store: store, + context: context + ) + ) +} +#endif + +@MainActor +func makeTransmissionBindingErrorHandler( + isPresented: Binding, + message: Binding +) -> @MainActor @Sendable (String) -> Void { + { text in + message.wrappedValue = text + isPresented.wrappedValue = true + } +} + +@MainActor +func makeTransmissionDebugErrorHandler( + store: TransmissionStore, + context: TransmissionActionFailureContext +) -> @MainActor @Sendable (String) -> Void { + { message in + store.debugBrief = context.debugBrief + store.debugMessage = message + store.isError = true + } +} + +#if os(macOS) +@MainActor +func makeTransmissionGlobalAlertHandler( + store: TransmissionStore, + context: TransmissionActionFailureContext +) -> @MainActor @Sendable (String) -> Void { + { message in + store.globalAlertTitle = context.globalAlertTitle + store.globalAlertMessage = message + store.showGlobalAlert = true + } +} +#endif + +struct TransmissionErrorAlert: ViewModifier { + @Binding var isPresented: Bool + let message: String + + func body(content: Content) -> some View { + content + .alert("Error", isPresented: $isPresented) { + Button("OK") { } + } message: { + Text(message) + } + } +} + +extension View { + func transmissionErrorAlert(isPresented: Binding, message: String) -> some View { + modifier(TransmissionErrorAlert(isPresented: isPresented, message: message)) + } +} diff --git a/BitDream/Views/Shared/TransmissionLegacyUI.swift b/BitDream/Views/Shared/TransmissionLegacyUI.swift deleted file mode 100644 index aa1afd9..0000000 --- a/BitDream/Views/Shared/TransmissionLegacyUI.swift +++ /dev/null @@ -1,37 +0,0 @@ -import SwiftUI - -// TODO: Remove this file in phases 4/5 once callers stop using -// `TransmissionResponse` and `handleTransmissionResponse` for UI error handling. -/// Handles legacy `TransmissionResponse` values with user-facing error presentation. -func handleTransmissionResponse( - _ response: TransmissionResponse, - onSuccess: @escaping () -> Void, - onError: @escaping (String) -> Void -) { - guard let presentation = TransmissionLegacyCompatibility.presentation(for: response) else { - onSuccess() - return - } - - onError(presentation.message) -} - -struct TransmissionErrorAlert: ViewModifier { - @Binding var isPresented: Bool - let message: String - - func body(content: Content) -> some View { - content - .alert("Error", isPresented: $isPresented) { - Button("OK") { } - } message: { - Text(message) - } - } -} - -extension View { - func transmissionErrorAlert(isPresented: Binding, message: String) -> some View { - modifier(TransmissionErrorAlert(isPresented: isPresented, message: message)) - } -} diff --git a/BitDream/Views/iOS/iOSAddTorrent.swift b/BitDream/Views/iOS/iOSAddTorrent.swift index 9daef91..db6ff4d 100644 --- a/BitDream/Views/iOS/iOSAddTorrent.swift +++ b/BitDream/Views/iOS/iOSAddTorrent.swift @@ -28,14 +28,7 @@ struct iOSAddTorrent: View { } ToolbarItem(placement: .navigationBarTrailing) { Button("Add") { - addTorrentAction( - alertInput: alertInput, - downloadDir: downloadDir, - store: store, - errorMessage: $errorMessage, - showingError: $showingError, - onSuccess: { dismiss() } - ) + submitMagnetTorrent() } .keyboardShortcut(.defaultAction) .disabled(alertInput.isEmpty) @@ -60,14 +53,7 @@ struct iOSAddTorrent: View { .disableAutocorrection(true) .submitLabel(.done) .onSubmit { - addTorrentAction( - alertInput: alertInput, - downloadDir: downloadDir, - store: store, - errorMessage: $errorMessage, - showingError: $showingError, - onSuccess: { dismiss() } - ) + submitMagnetTorrent() } } @@ -85,6 +71,28 @@ struct iOSAddTorrent: View { } // MARK: - Actions - // Using shared implementations from AddTorrent.swift + + private func submitMagnetTorrent() { + guard !alertInput.isEmpty else { return } + + performTransmissionAction( + operation: { + try await store.addTorrent( + magnetLink: alertInput, + saveLocation: downloadDir + ) + }, + onSuccess: { (_: TransmissionTorrentAddOutcome) in + dismiss() + }, + onError: { message in + presentAddTorrentSheetError( + detail: message, + errorMessage: $errorMessage, + showingError: $showingError + ) + } + ) + } } #endif diff --git a/BitDream/Views/iOS/iOSContentView.swift b/BitDream/Views/iOS/iOSContentView.swift index 1d30e15..a4cae60 100644 --- a/BitDream/Views/iOS/iOSContentView.swift +++ b/BitDream/Views/iOS/iOSContentView.swift @@ -18,13 +18,6 @@ struct iOSContentView: View { // Store the selected torrent IDs @State private var selectedTorrentIds: Set = [] - // Computed property to get the selected torrents from the IDs - private var selectedTorrentsSet: Set { - Set(selectedTorrentIds.compactMap { id in - store.torrents.first { $0.id == id } - }) - } - @State var sortProperty: SortProperty = UserDefaults.standard.sortProperty @State var sortOrder: SortOrder = UserDefaults.standard.sortOrder @State var filterBySelection: [TorrentStatusCalc] = TorrentStatusCalc.allCases @@ -95,10 +88,16 @@ struct iOSContentView: View { SettingsView(store: store) }) } +} - // MARK: - iOS Views +private extension iOSContentView { + var selectedTorrentsSet: Set { + Set(selectedTorrentIds.compactMap { id in + store.torrents.first { $0.id == id } + }) + } - private var torrentRows: some View { + var torrentRows: some View { Group { if store.torrents.isEmpty { Text("No dreams available") @@ -126,33 +125,15 @@ struct iOSContentView: View { } } - // MARK: - Toolbar Items - - private var serverToolbarItem: some ToolbarContent { + var serverToolbarItem: some ToolbarContent { ToolbarItem(placement: .automatic) { Menu { - Menu { - Picker("Server", selection: .init( - get: { store.host }, - set: { host in - if let host = host { - store.setHost(host: host) - } - } - )) { - ForEach(hosts, id: \.serverID) { host in - Text(host.name ?? "Unnamed Server") - .tag(host as Host?) - } - } - } label: { - Label("Server", systemImage: "arrow.triangle.2.circlepath") - } + serverSelectionMenu Divider() - Button(action: {store.setup.toggle()}, label: { + Button(action: { store.setup.toggle() }, label: { Label("Add", systemImage: "plus") }) - Button(action: {store.editServers.toggle()}, label: { + Button(action: { store.editServers.toggle() }, label: { Label("Edit", systemImage: "square.and.pencil") }) } label: { @@ -161,100 +142,19 @@ struct iOSContentView: View { } } - private var actionToolbarItems: some ToolbarContent { + var actionToolbarItems: some ToolbarContent { ToolbarItemGroup(placement: .automatic) { Menu { - Menu { - Section(header: Text("Include")) { - Button("All") { - filterBySelection = TorrentStatusCalc.allCases - } - Button("Downloading") { - filterBySelection = [.downloading] - } - Button("Complete") { - filterBySelection = [.complete] - } - Button("Paused") { - filterBySelection = [.paused] - } - } - Section(header: Text("Exclude")) { - Button("Complete") { - filterBySelection = TorrentStatusCalc.allCases.filter {$0 != .complete} - } - } - } label: { - Text("Filter By") - Image(systemName: "slider.horizontal.3") - }.environment(\.menuOrder, .fixed) - - Menu { - // Sort properties - ForEach(SortProperty.allCases, id: \.self) { property in - Button { - sortProperty = property - } label: { - HStack { - Text(property.rawValue) - Spacer() - if sortProperty == property { - Image(systemName: "checkmark") - } - } - } - } - - Divider() - - // Sort order - Button { - sortOrder = .ascending - } label: { - HStack { - Text("Ascending") - Spacer() - if sortOrder == .ascending { - Image(systemName: "checkmark") - } - } - } - - Button { - sortOrder = .descending - } label: { - HStack { - Text("Descending") - Spacer() - if sortOrder == .descending { - Image(systemName: "checkmark") - } - } - } - } label: { - Label("Sort", systemImage: "arrow.up.arrow.down") - }.environment(\.menuOrder, .fixed) - + filterMenu + sortMenu Divider() - - Button(action: { - playPauseAllTorrents(start: false, info: makeConfig(store: store), onResponse: { _ in - store.requestRefresh() - }) - }, label: { + Button(action: pauseAllTorrents, label: { Label("Pause All", systemImage: "pause") }) - - Button(action: { - playPauseAllTorrents(start: true, info: makeConfig(store: store), onResponse: { _ in - store.requestRefresh() - }) - }, label: { + Button(action: resumeAllTorrents, label: { Label("Resume All", systemImage: "play") }) - Divider() - Button(action: { store.showSettings.toggle() }, label: { @@ -266,9 +166,7 @@ struct iOSContentView: View { } } - // MARK: - Bottom Toolbar - - private var bottomToolbarItems: some ToolbarContent { + var bottomToolbarItems: some ToolbarContent { Group { DefaultToolbarItem(kind: .search, placement: .bottomBar) ToolbarSpacer(.flexible, placement: .bottomBar) @@ -283,5 +181,116 @@ struct iOSContentView: View { } } } + + var serverSelectionMenu: some View { + Menu { + Picker("Server", selection: .init( + get: { store.host }, + set: { host in + if let host { + store.setHost(host: host) + } + } + )) { + ForEach(hosts, id: \.serverID) { host in + Text(host.name ?? "Unnamed Server") + .tag(host as Host?) + } + } + } label: { + Label("Server", systemImage: "arrow.triangle.2.circlepath") + } + } + + var filterMenu: some View { + Menu { + Section(header: Text("Include")) { + Button("All") { + filterBySelection = TorrentStatusCalc.allCases + } + Button("Downloading") { + filterBySelection = [.downloading] + } + Button("Complete") { + filterBySelection = [.complete] + } + Button("Paused") { + filterBySelection = [.paused] + } + } + Section(header: Text("Exclude")) { + Button("Complete") { + filterBySelection = TorrentStatusCalc.allCases.filter { $0 != .complete } + } + } + } label: { + Text("Filter By") + Image(systemName: "slider.horizontal.3") + } + .environment(\.menuOrder, .fixed) + } + + var sortMenu: some View { + Menu { + ForEach(SortProperty.allCases, id: \.self) { property in + Button { + sortProperty = property + } label: { + HStack { + Text(property.rawValue) + Spacer() + if sortProperty == property { + Image(systemName: "checkmark") + } + } + } + } + + Divider() + + Button { + sortOrder = .ascending + } label: { + HStack { + Text("Ascending") + Spacer() + if sortOrder == .ascending { + Image(systemName: "checkmark") + } + } + } + + Button { + sortOrder = .descending + } label: { + HStack { + Text("Descending") + Spacer() + if sortOrder == .descending { + Image(systemName: "checkmark") + } + } + } + } label: { + Label("Sort", systemImage: "arrow.up.arrow.down") + } + .environment(\.menuOrder, .fixed) + } + + func pauseAllTorrents() { + performTransmissionDebugAction( + .pauseAllTorrents, + store: store, + operation: { try await store.pauseAllTorrents() } + ) + } + + func resumeAllTorrents() { + performTransmissionDebugAction( + .resumeAllTorrents, + store: store, + operation: { try await store.resumeAllTorrents() } + ) + } } #endif diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index c34ab61..a2bf687 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -67,12 +67,7 @@ struct iOSTorrentDetail: View { store: store, peers: peers, peersFrom: peersFrom, - onRefresh: { - fetchTorrentPeers(transferId: torrent.id, store: store) { fetchedPeers, fetchedFrom in - peers = fetchedPeers - peersFrom = fetchedFrom - } - }, + onRefresh: { await loadSupplementalData(for: torrent.id) }, onDone: { /* no-op in push */ } ) .navigationBarTitleDisplayMode(.inline) @@ -169,26 +164,8 @@ struct iOSTorrentDetail: View { }) } } - .onAppear { - // Use shared function to fetch files - fetchTorrentFiles(transferId: torrent.id, store: store) { fetchedFiles, fetchedStats in - files = fetchedFiles - fileStats = fetchedStats - } - // Fetch peers initially - fetchTorrentPeers(transferId: torrent.id, store: store) { fetchedPeers, fetchedFrom in - peers = fetchedPeers - peersFrom = fetchedFrom - } - // Fetch pieces - let info = makeConfig(store: store) - getTorrentPieces(transferId: torrent.id, info: info) { count, size, bitfield in - pieceCount = count - pieceSize = size - piecesBitfield = bitfield - let haveSet = decodePiecesBitfield(base64String: bitfield, pieceCount: count) - piecesHaveCount = haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } - } + .task(id: torrent.id) { + await loadSupplementalData(for: torrent.id) } .toolbar { // Use shared toolbar @@ -196,34 +173,12 @@ struct iOSTorrentDetail: View { } .alert("Delete Torrent", isPresented: $showingDeleteConfirmation) { Button(role: .destructive) { - let info = makeConfig(store: store) - deleteTorrent(torrent: torrent, erase: true, config: info.config, auth: info.auth, onDel: { response in - handleTransmissionResponse(response, - onSuccess: { - dismiss() - }, - onError: { errorMessage in - deleteErrorMessage = errorMessage - showingDeleteError = true - } - ) - }) + performDelete(deleteLocalData: true) } label: { Text("Delete file(s)") } Button("Remove from list only") { - let info = makeConfig(store: store) - deleteTorrent(torrent: torrent, erase: false, config: info.config, auth: info.auth, onDel: { response in - handleTransmissionResponse(response, - onSuccess: { - dismiss() - }, - onError: { errorMessage in - deleteErrorMessage = errorMessage - showingDeleteError = true - } - ) - }) + performDelete(deleteLocalData: false) } Button("Cancel", role: .cancel) { } } message: { @@ -233,6 +188,55 @@ struct iOSTorrentDetail: View { } } + + private func performDelete(deleteLocalData: Bool) { + performTransmissionAction( + operation: { + try await store.removeTorrents( + ids: [torrent.id], + deleteLocalData: deleteLocalData + ) + }, + onSuccess: { + dismiss() + }, + onError: makeTransmissionBindingErrorHandler( + isPresented: $showingDeleteError, + message: $deleteErrorMessage + ) + ) + } + + @MainActor + private func loadSupplementalData(for torrentID: Int) async { + guard let snapshot = await performStructuredTransmissionOperation( + operation: { try await store.loadTorrentDetail(id: torrentID) }, + onError: { message in + deleteErrorMessage = message + showingDeleteError = true + } + ) else { + return + } + + apply(snapshot: snapshot) + } + + private func apply(snapshot: TransmissionTorrentDetailSnapshot) { + files = snapshot.files + fileStats = snapshot.fileStats + peers = snapshot.peers + peersFrom = snapshot.peersFrom + pieceCount = snapshot.pieceCount + pieceSize = snapshot.pieceSize + piecesBitfield = snapshot.piecesBitfieldBase64 + + let haveSet = decodePiecesBitfield( + base64String: snapshot.piecesBitfieldBase64, + pieceCount: snapshot.pieceCount + ) + piecesHaveCount = haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } + } } // Enhanced LabelTag component for detail views diff --git a/BitDream/Views/iOS/iOSTorrentFileDetail.swift b/BitDream/Views/iOS/iOSTorrentFileDetail.swift index b2be7f6..f7db66b 100644 --- a/BitDream/Views/iOS/iOSTorrentFileDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentFileDetail.swift @@ -66,6 +66,8 @@ struct iOSTorrentFileDetail: View { // Multi-select state @State private var isEditing = false @State private var selectedFileIds: Set = [] + @State private var showingError = false + @State private var errorMessage = "" private var fileRows: [TorrentFileRow] { let processedFiles = processFilesForDisplay(files, stats: mutableFileStats.isEmpty ? fileStats : mutableFileStats) @@ -164,7 +166,11 @@ struct iOSTorrentFileDetail: View { store: store, updateFileStatus: updateLocalFileStatus, updateFilePriority: updateLocalFilePriority, - revertData: revertToOriginalData + revertData: revertToOriginalData, + onError: makeTransmissionBindingErrorHandler( + isPresented: $showingError, + message: $errorMessage + ) ) } } @@ -196,6 +202,10 @@ struct iOSTorrentFileDetail: View { .onAppear { mutableFileStats = fileStats } + .onChange(of: fileStats) { _, newValue in + mutableFileStats = newValue + } + .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) } // MARK: - File Operations @@ -203,33 +213,39 @@ struct iOSTorrentFileDetail: View { private func setFileWanted(_ row: TorrentFileRow, wanted: Bool) { updateLocalFileStatus(fileIndex: row.fileIndex, wanted: wanted) - Task { @MainActor in - let response = await FileActionExecutor.setWanted( - torrentId: torrentId, - fileIndices: [row.fileIndex], - store: store, - wanted: wanted - ) - if response != .success { + performTransmissionAction( + operation: { + try await store.setFileWantedStatus( + torrentId: torrentId, + fileIndices: [row.fileIndex], + wanted: wanted + ) + }, + onError: { message in revertToOriginalData() + errorMessage = message + showingError = true } - } + ) } private func setFilePriority(_ row: TorrentFileRow, priority: FilePriority) { updateLocalFilePriority(fileIndex: row.fileIndex, priority: priority) - Task { @MainActor in - let response = await FileActionExecutor.setPriority( - torrentId: torrentId, - fileIndices: [row.fileIndex], - store: store, - priority: priority - ) - if response != .success { + performTransmissionAction( + operation: { + try await store.setFilePriority( + torrentId: torrentId, + fileIndices: [row.fileIndex], + priority: priority + ) + }, + onError: { message in revertToOriginalData() + errorMessage = message + showingError = true } - } + ) } // MARK: - Optimistic Updates @@ -268,6 +284,7 @@ struct BulkActionToolbar: View { let updateFileStatus: (Int, Bool) -> Void let updateFilePriority: (Int, FilePriority) -> Void let revertData: () -> Void + let onError: @MainActor @Sendable (String) -> Void var body: some View { VStack(spacing: 0) { @@ -337,18 +354,19 @@ struct BulkActionToolbar: View { updateFilePriority(fileIndex, priority) } - let info = makeConfig(store: store) - setFilePriority( - torrentId: torrentId, - fileIndices: fileIndices, - priority: priority, - info: info - ) { response in - if response != .success { - // Revert on failure + performTransmissionAction( + operation: { + try await store.setFilePriority( + torrentId: torrentId, + fileIndices: fileIndices, + priority: priority + ) + }, + onError: { message in revertData() + onError(message) } - } + ) } private func setBulkWanted(_ wanted: Bool) { @@ -360,18 +378,19 @@ struct BulkActionToolbar: View { updateFileStatus(fileIndex, wanted) } - let info = makeConfig(store: store) - setFileWantedStatus( - torrentId: torrentId, - fileIndices: fileIndices, - wanted: wanted, - info: info - ) { response in - if response != .success { - // Revert on failure + performTransmissionAction( + operation: { + try await store.setFileWantedStatus( + torrentId: torrentId, + fileIndices: fileIndices, + wanted: wanted + ) + }, + onError: { message in revertData() + onError(message) } - } + ) } } diff --git a/BitDream/Views/iOS/iOSTorrentListRow.swift b/BitDream/Views/iOS/iOSTorrentListRow.swift index aa1a725..7f96d9e 100644 --- a/BitDream/Views/iOS/iOSTorrentListRow.swift +++ b/BitDream/Views/iOS/iOSTorrentListRow.swift @@ -147,17 +147,17 @@ struct iOSTorrentListRow: View { } private func togglePlayback() { - let info = makeConfig(store: store) - playPauseTorrent(torrent: torrent, config: info.config, auth: info.auth) { response in - handleTransmissionResponse(response, onSuccess: {}, onError: presentError) - } + performTransmissionAction( + operation: { try await store.toggleTorrentPlayback(torrent) }, + onError: presentError + ) } private func performDelete(erase: Bool) { - let info = makeConfig(store: store) - deleteTorrent(torrent: torrent, erase: erase, config: info.config, auth: info.auth) { response in - handleTransmissionResponse(response, onSuccess: {}, onError: presentError) - } + performTransmissionAction( + operation: { try await store.removeTorrents(ids: [torrent.id], deleteLocalData: erase) }, + onError: presentError + ) } private func showRenameDialog() { @@ -184,7 +184,7 @@ private struct IOSTorrentActionsMenu: View { let onShowRename: () -> Void let onShowLabels: () -> Void let onShowDelete: () -> Void - let onError: (String) -> Void + let onError: @MainActor @Sendable (String) -> Void var body: some View { playbackSection @@ -201,7 +201,7 @@ private struct IOSTorrentActionsMenu: View { } Divider() Button("Ask For More Peers", systemImage: "arrow.left.arrow.right") { - reAnnounceToTrackers(torrent: torrent, store: store) + reannounce() } Button("Verify Local Data", systemImage: "checkmark.arrow.trianglehead.counterclockwise") { verifyTorrentAction() @@ -221,7 +221,7 @@ private struct IOSTorrentActionsMenu: View { if torrent.status == TorrentStatus.stopped.rawValue { Button("Resume Now", systemImage: "play.fill") { - resumeTorrentNow(torrent: torrent, store: store) + resumeNow() } } } @@ -259,58 +259,61 @@ private struct IOSTorrentActionsMenu: View { } private func togglePlayback() { - let info = makeConfig(store: store) - playPauseTorrent(torrent: torrent, config: info.config, auth: info.auth) { response in - handleResponse(response) + runAction { + try await store.toggleTorrentPlayback(torrent) } } private func updatePriority(_ priority: TorrentPriority) { - let info = makeConfig(store: store) - updateTorrent( - args: TorrentSetRequestArgs(ids: [torrent.id], priority: priority), - info: info, - onComplete: { _ in } - ) + runAction { + try await store.updateTorrentPriority(ids: [torrent.id], priority: priority) + } } private func queueMoveTopAction() { - let info = makeConfig(store: store) - queueMoveTop(ids: [torrent.id], info: info) { response in - handleResponse(response) + runAction { + try await store.moveTorrentsInQueue(.top, ids: [torrent.id]) } } private func queueMoveUpAction() { - let info = makeConfig(store: store) - queueMoveUp(ids: [torrent.id], info: info) { response in - handleResponse(response) + runAction { + try await store.moveTorrentsInQueue(.upward, ids: [torrent.id]) } } private func queueMoveDownAction() { - let info = makeConfig(store: store) - queueMoveDown(ids: [torrent.id], info: info) { response in - handleResponse(response) + runAction { + try await store.moveTorrentsInQueue(.downward, ids: [torrent.id]) } } private func queueMoveBottomAction() { - let info = makeConfig(store: store) - queueMoveBottom(ids: [torrent.id], info: info) { response in - handleResponse(response) + runAction { + try await store.moveTorrentsInQueue(.bottom, ids: [torrent.id]) } } private func verifyTorrentAction() { - let info = makeConfig(store: store) - verifyTorrent(torrent: torrent, config: info.config, auth: info.auth) { response in - handleResponse(response) + runAction { + try await store.verifyTorrents(ids: [torrent.id]) + } + } + + private func resumeNow() { + runAction { + try await store.startTorrentsNow(ids: [torrent.id]) + } + } + + private func reannounce() { + runAction { + try await store.reannounceTorrents(ids: [torrent.id]) } } - private func handleResponse(_ response: TransmissionResponse) { - handleTransmissionResponse(response, onSuccess: {}, onError: onError) + private func runAction(_ operation: @escaping @MainActor () async throws -> Void) { + performTransmissionAction(operation: operation, onError: onError) } } @@ -320,7 +323,7 @@ private struct IOSTorrentRenameSheet: View { let store: TransmissionStore @Binding var renameInput: String @Binding var isPresented: Bool - let onError: (String) -> Void + let onError: @MainActor @Sendable (String) -> Void private var trimmedRenameInput: String { renameInput.trimmingCharacters(in: .whitespacesAndNewlines) @@ -361,13 +364,13 @@ private struct IOSTorrentRenameSheet: View { private func saveRename() { guard isRenameValid else { return } let nameToSave = trimmedRenameInput - renameTorrentRoot(torrent: torrent, to: nameToSave, store: store) { error in - if let error { - onError(error) - } else { + performTransmissionAction( + operation: { try await store.renameTorrentRoot(torrent, to: nameToSave) }, + onSuccess: { (_: TorrentRenameResponseArgs) in isPresented = false - } - } + }, + onError: onError + ) } } @@ -378,7 +381,7 @@ private struct IOSTorrentMoveSheet: View { @Binding var movePath: String @Binding var moveShouldMove: Bool @Binding var isPresented: Bool - let onError: (String) -> Void + let onError: @MainActor @Sendable (String) -> Void private var isMoveDisabled: Bool { movePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -425,18 +428,21 @@ private struct IOSTorrentMoveSheet: View { } private func setLocation() { - let info = makeConfig(store: store) - let args = TorrentSetLocationRequestArgs( - ids: [torrent.id], - location: movePath.trimmingCharacters(in: .whitespacesAndNewlines), - move: moveShouldMove - ) - setTorrentLocation(args: args, info: info) { response in - handleTransmissionResponse(response, onSuccess: { - store.requestRefresh() + let location = movePath.trimmingCharacters(in: .whitespacesAndNewlines) + + performTransmissionAction( + operation: { + try await store.setTorrentLocation( + ids: [torrent.id], + location: location, + move: moveShouldMove + ) + }, + onSuccess: { isPresented = false - }, onError: onError) - } + }, + onError: onError + ) } } @@ -447,6 +453,8 @@ struct iOSLabelEditView: View { @State private var newTagInput: String = "" @FocusState private var isInputFocused: Bool @Environment(\.dismiss) private var dismiss + @State private var showingError = false + @State private var errorMessage = "" var store: TransmissionStore var torrentId: Int @@ -470,10 +478,22 @@ struct iOSLabelEditView: View { } labelInput = workingLabels.joined(separator: ", ") - - saveTorrentLabels(torrentId: torrentId, labels: workingLabels, store: store) { - dismiss() - } + let sortedLabels = Array(workingLabels).sorted() + + performTransmissionAction( + operation: { + try await store.updateTorrentLabels([ + TransmissionTorrentLabelsUpdate(ids: [torrentId], labels: sortedLabels) + ]) + }, + onSuccess: { + dismiss() + }, + onError: { message in + errorMessage = message + showingError = true + } + ) } var body: some View { @@ -529,6 +549,7 @@ struct iOSLabelEditView: View { } .navigationTitle("Edit Labels") .navigationBarTitleDisplayMode(.inline) + .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { diff --git a/BitDream/Views/iOS/iOSTorrentPeerDetail.swift b/BitDream/Views/iOS/iOSTorrentPeerDetail.swift index ed6efd5..97c22b6 100644 --- a/BitDream/Views/iOS/iOSTorrentPeerDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentPeerDetail.swift @@ -8,7 +8,7 @@ struct iOSTorrentPeerDetail: View { let store: TransmissionStore let peers: [Peer] let peersFrom: PeersFrom? - let onRefresh: () -> Void + let onRefresh: @MainActor () async -> Void let onDone: () -> Void @State private var searchText: String = "" @@ -29,7 +29,11 @@ struct iOSTorrentPeerDetail: View { VStack(spacing: 12) { Text(peers.isEmpty ? "No peers yet" : "No results") .foregroundColor(.secondary) - Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") } + Button { + Task { await onRefresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Peers") @@ -37,7 +41,9 @@ struct iOSTorrentPeerDetail: View { .searchable(text: $searchText, prompt: "Search peers") .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - Button(action: onRefresh) { + Button { + Task { await onRefresh() } + } label: { Image(systemName: "arrow.clockwise") } Button("Done", action: onDone) @@ -66,13 +72,15 @@ struct iOSTorrentPeerDetail: View { } } } - .refreshable { onRefresh() } + .refreshable { await onRefresh() } .navigationTitle("Peers") .navigationBarTitleDisplayMode(.inline) .searchable(text: $searchText, prompt: "Search peers") .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - Button(action: onRefresh) { + Button { + Task { await onRefresh() } + } label: { Image(systemName: "arrow.clockwise") } Button("Done", action: onDone) @@ -155,7 +163,7 @@ struct iOSTorrentPeerDetail: View { let store: TransmissionStore let peers: [Peer] let peersFrom: PeersFrom? - let onRefresh: () -> Void + let onRefresh: @MainActor () async -> Void let onDone: () -> Void var body: some View { EmptyView() } } diff --git a/BitDream/Views/macOS/macOSAddTorrent.swift b/BitDream/Views/macOS/macOSAddTorrent.swift index 48da9a6..6c7096d 100644 --- a/BitDream/Views/macOS/macOSAddTorrent.swift +++ b/BitDream/Views/macOS/macOSAddTorrent.swift @@ -248,24 +248,73 @@ private extension macOSAddTorrent { } func addMagnetTorrent() { - addTorrentAction( - alertInput: alertInput, - downloadDir: downloadDir, - store: store, - errorMessage: $errorMessage, - showingError: $showingError, - onSuccess: { dismiss() } + guard !alertInput.isEmpty else { return } + + performTransmissionAction( + operation: { + try await store.addTorrent( + magnetLink: alertInput, + saveLocation: downloadDir + ) + }, + onSuccess: { (_: TransmissionTorrentAddOutcome) in + dismiss() + }, + onError: { message in + presentAddTorrentSheetError( + detail: message, + errorMessage: $errorMessage, + showingError: $showingError + ) + } ) } func addSelectedTorrentFiles() { guard !selectedTorrentFiles.isEmpty else { return } + let torrentFiles = selectedTorrentFiles + let saveLocation = downloadDir - for torrentFile in selectedTorrentFiles { - addTorrentFile(fileData: torrentFile.data) - } + Task { @MainActor in + var failures: [(String, String)] = [] + + for torrentFile in torrentFiles { + do { + _ = try await store.addTorrent( + fileData: torrentFile.data, + saveLocation: saveLocation + ) + } catch { + let message = TransmissionUserFacingError.message(for: error) ?? error.localizedDescription + failures.append((torrentFile.name, message)) + } + } + + 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 + ) + } + return + } - dismiss() + dismiss() + } } func handleImporterResult(_ result: Result<[URL], Error>) { @@ -353,28 +402,6 @@ private extension macOSAddTorrent { store.addTorrentPrefill = nil } } - - func addTorrentFile(fileData: Data) { - let fileStream = fileData.base64EncodedString(options: []) - let info = makeConfig(store: store) - - addTorrent( - fileUrl: fileStream, - saveLocation: downloadDir, - auth: info.auth, - file: true, - config: info.config, - onAdd: { response in - if let presentation = TransmissionLegacyCompatibility.presentation(for: response.response) { - handleAddTorrentError( - "Failed to add torrent: \(presentation.message)", - errorMessage: $errorMessage, - showingError: $showingError - ) - } - } - ) - } } private struct TorrentSourceCard: View { diff --git a/BitDream/Views/macOS/macOSContentDetail.swift b/BitDream/Views/macOS/macOSContentDetail.swift index 4bdde46..01bb548 100644 --- a/BitDream/Views/macOS/macOSContentDetail.swift +++ b/BitDream/Views/macOS/macOSContentDetail.swift @@ -343,7 +343,25 @@ struct TorrentDropDelegate: DropDelegate { Task { @MainActor in do { let data = try await Self.readTorrentData(from: url) - addTorrentFromFileData(data, store: store) + guard store.host != nil else { + presentAddTorrentStoreError( + detail: addTorrentNoServerConfiguredMessage, + store: store + ) + return + } + performTransmissionAction( + operation: { + try await store.addTorrent( + fileData: data, + saveLocation: store.defaultDownloadDir + ) + }, + onSuccess: { (_: TransmissionTorrentAddOutcome) in }, + onError: { message in + presentAddTorrentStoreError(detail: message, store: store) + } + ) } catch { Self.logger.error("Failed to read dropped torrent file \(url.lastPathComponent): \(error.localizedDescription)") } diff --git a/BitDream/Views/macOS/macOSContentView.swift b/BitDream/Views/macOS/macOSContentView.swift index c557118..e6e5323 100644 --- a/BitDream/Views/macOS/macOSContentView.swift +++ b/BitDream/Views/macOS/macOSContentView.swift @@ -336,25 +336,17 @@ private extension macOSContentView { } func removeSelectedTorrentsFromMenu(deleteData: Bool) { - let selected = Array(selectedTorrents) - guard !selected.isEmpty else { return } - - let info = makeConfig(store: store) - - for torrent in selected { - deleteTorrent(torrent: torrent, erase: deleteData, config: info.config, auth: info.auth) { response in - handleTransmissionResponse(response, - onSuccess: {}, - onError: { error in - store.debugBrief = "Failed to remove torrent" - store.debugMessage = error - store.isError = true - } - ) - } - } + let ids = Array(selectedTorrents.map(\.id)) + guard !ids.isEmpty else { return } - selectedTorrentIds.removeAll() + performTransmissionDebugAction( + .removeTorrents, + store: store, + operation: { try await store.removeTorrents(ids: ids, deleteLocalData: deleteData) }, + onSuccess: { + selectedTorrentIds.removeAll() + } + ) } func handleSearchTextChange(oldValue: String, newValue: String) { diff --git a/BitDream/Views/macOS/macOSMenuCommands.swift b/BitDream/Views/macOS/macOSMenuCommands.swift index 4063ea7..b8b810c 100644 --- a/BitDream/Views/macOS/macOSMenuCommands.swift +++ b/BitDream/Views/macOS/macOSMenuCommands.swift @@ -204,81 +204,91 @@ struct TorrentCommands: Commands { private extension TorrentCommands { func pauseSelectedTorrents() { - TorrentActionExecutor.pause(ids: selectedTorrentIDs, store: store) { error in - presentDebugError("Failed to pause torrents", message: error) - } + performTransmissionDebugAction( + .pauseTorrents, + store: store, + operation: { try await store.pauseTorrents(ids: selectedTorrentIDs) } + ) } func resumeSelectedTorrents() { - TorrentActionExecutor.resume(ids: selectedTorrentIDs, store: store) { error in - presentDebugError("Failed to resume torrents", message: error) - } + performTransmissionDebugAction( + .resumeTorrents, + store: store, + operation: { try await store.resumeTorrents(ids: selectedTorrentIDs) } + ) } func resumeSelectedTorrentsNow() { - TorrentActionExecutor.resumeNow(torrents: Array(selectedTorrents), store: store) { error in - presentDebugError("Failed to resume torrents now", message: error) - } + performTransmissionDebugAction( + .resumeTorrentsNow, + store: store, + operation: { try await store.startTorrentsNow(ids: selectedTorrentIDs) } + ) } func pauseAllTorrents() { - TorrentActionExecutor.setAllPlayback(start: false, store: store) { error in - presentDebugError("Failed to pause all torrents", message: error) - } + performTransmissionDebugAction( + .pauseAllTorrents, + store: store, + operation: { try await store.pauseAllTorrents() } + ) } func resumeAllTorrents() { - TorrentActionExecutor.setAllPlayback(start: true, store: store) { error in - presentDebugError("Failed to resume all torrents", message: error) - } + performTransmissionDebugAction( + .resumeAllTorrents, + store: store, + operation: { try await store.resumeAllTorrents() } + ) } func reannounceSelectedTorrents() { - TorrentActionExecutor.reannounce(torrents: Array(selectedTorrents), store: store) { error in - presentDebugError("Failed to ask for more peers", message: error) - } + performTransmissionDebugAction( + .askForMorePeers, + store: store, + operation: { try await store.reannounceTorrents(ids: selectedTorrentIDs) } + ) } func verifySelectedTorrents() { - TorrentActionExecutor.verify(torrents: Array(selectedTorrents), store: store) { error in - presentDebugError("Failed to verify torrent", message: error) - } + performTransmissionDebugAction( + .verifyTorrents, + store: store, + operation: { try await store.verifyTorrents(ids: selectedTorrentIDs) } + ) } func moveSelectedTorrentsToFront() { - TorrentActionExecutor.moveInQueue(.top, ids: selectedTorrentIDs, store: store) { error in - presentQueueError(error) - } + performTransmissionGlobalAlertAction( + .queueMove, + store: store, + operation: { try await store.moveTorrentsInQueue(.top, ids: selectedTorrentIDs) } + ) } func moveSelectedTorrentsUp() { - TorrentActionExecutor.moveInQueue(.upward, ids: selectedTorrentIDs, store: store) { error in - presentQueueError(error) - } + performTransmissionGlobalAlertAction( + .queueMove, + store: store, + operation: { try await store.moveTorrentsInQueue(.upward, ids: selectedTorrentIDs) } + ) } func moveSelectedTorrentsDown() { - TorrentActionExecutor.moveInQueue(.downward, ids: selectedTorrentIDs, store: store) { error in - presentQueueError(error) - } + performTransmissionGlobalAlertAction( + .queueMove, + store: store, + operation: { try await store.moveTorrentsInQueue(.downward, ids: selectedTorrentIDs) } + ) } func moveSelectedTorrentsToBack() { - TorrentActionExecutor.moveInQueue(.bottom, ids: selectedTorrentIDs, store: store) { error in - presentQueueError(error) - } - } - - func presentDebugError(_ brief: String, message: String) { - store.debugBrief = brief - store.debugMessage = message - store.isError = true - } - - func presentQueueError(_ message: String) { - store.globalAlertTitle = "Queue Error" - store.globalAlertMessage = message - store.showGlobalAlert = true + performTransmissionGlobalAlertAction( + .queueMove, + store: store, + operation: { try await store.moveTorrentsInQueue(.bottom, ids: selectedTorrentIDs) } + ) } } diff --git a/BitDream/Views/macOS/macOSTorrentActionsMenu.swift b/BitDream/Views/macOS/macOSTorrentActionsMenu.swift index ca95746..a757e1c 100644 --- a/BitDream/Views/macOS/macOSTorrentActionsMenu.swift +++ b/BitDream/Views/macOS/macOSTorrentActionsMenu.swift @@ -159,54 +159,59 @@ struct TorrentContextMenu: View { } private func pauseTorrentsAction() { - TorrentActionExecutor.pause(ids: torrentIDs, store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.pauseTorrents(ids: torrentIDs) }, + onError: dialogState.presentError + ) } private func resumeTorrentsAction() { - TorrentActionExecutor.resume(ids: torrentIDs, store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.resumeTorrents(ids: torrentIDs) }, + onError: dialogState.presentError + ) } private func resumeNowAction() { - TorrentActionExecutor.resumeNow(torrents: Array(torrents), store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.startTorrentsNow(ids: torrentIDs) }, + onError: dialogState.presentError + ) } private func updatePriority(_ priority: TorrentPriority) { - let info = makeConfig(store: store) - updateTorrent( - args: TorrentSetRequestArgs(ids: torrentIDs, priority: priority), - info: info, - onComplete: { _ in } + performTransmissionAction( + operation: { try await store.updateTorrentPriority(ids: torrentIDs, priority: priority) }, + onError: dialogState.presentError ) } private func queueMoveTopAction() { - TorrentActionExecutor.moveInQueue(.top, ids: torrentIDs, store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.moveTorrentsInQueue(.top, ids: torrentIDs) }, + onError: dialogState.presentError + ) } private func queueMoveUpAction() { - TorrentActionExecutor.moveInQueue(.upward, ids: torrentIDs, store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.moveTorrentsInQueue(.upward, ids: torrentIDs) }, + onError: dialogState.presentError + ) } private func queueMoveDownAction() { - TorrentActionExecutor.moveInQueue(.downward, ids: torrentIDs, store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.moveTorrentsInQueue(.downward, ids: torrentIDs) }, + onError: dialogState.presentError + ) } private func queueMoveBottomAction() { - TorrentActionExecutor.moveInQueue(.bottom, ids: torrentIDs, store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.moveTorrentsInQueue(.bottom, ids: torrentIDs) }, + onError: dialogState.presentError + ) } private func showMoveDialog() { @@ -224,15 +229,17 @@ struct TorrentContextMenu: View { } private func verifyTorrentsAction() { - TorrentActionExecutor.verify(torrents: Array(torrents), store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.verifyTorrents(ids: torrentIDs) }, + onError: dialogState.presentError + ) } private func reannounceTorrentsAction() { - TorrentActionExecutor.reannounce(torrents: Array(torrents), store: store) { error in - dialogState.presentError(error) - } + performTransmissionAction( + operation: { try await store.reannounceTorrents(ids: torrentIDs) }, + onError: dialogState.presentError + ) } } @@ -298,7 +305,11 @@ struct TorrentActionsToolbarMenu: View { existingLabels: torrents.count == 1 ? Array(torrents.first!.labels) : [], store: store, selectedTorrents: torrents, - shouldSave: $shouldSave + shouldSave: $shouldSave, + onError: { message in + errorMessage = message + showingError = true + } ) HStack { @@ -321,39 +332,25 @@ struct TorrentActionsToolbarMenu: View { "Delete \(selectedTorrents.count > 1 ? "\(selectedTorrents.count) Torrents" : "Torrent")", isPresented: $deleteDialog) { Button(role: .destructive) { - let info = makeConfig(store: store) - for torrent in selectedTorrents { - deleteTorrent(torrent: torrent, erase: true, config: info.config, auth: info.auth, onDel: { response in - handleTransmissionResponse(response, - onSuccess: { - // Success - torrent deleted - }, - onError: { error in - errorMessage = error - showingError = true - } - ) - }) - } + deleteTorrents( + ids: Array(selectedTorrents.map(\.id)), + deleteLocalData: true, + store: store, + showingError: $showingError, + errorMessage: $errorMessage + ) deleteDialog.toggle() } label: { Text("Delete file(s)") } Button("Remove from list only") { - let info = makeConfig(store: store) - for torrent in selectedTorrents { - deleteTorrent(torrent: torrent, erase: false, config: info.config, auth: info.auth, onDel: { response in - handleTransmissionResponse(response, - onSuccess: { - // Success - torrent removed from list - }, - onError: { error in - errorMessage = error - showingError = true - } - ) - }) - } + deleteTorrents( + ids: Array(selectedTorrents.map(\.id)), + deleteLocalData: false, + store: store, + showingError: $showingError, + errorMessage: $errorMessage + ) deleteDialog.toggle() } } message: { @@ -382,14 +379,16 @@ struct TorrentActionsToolbarMenu: View { showingError = true return } - renameTorrentRoot(torrent: targetTorrent, to: newName, store: store) { err in - if let err = err { - errorMessage = err - showingError = true - } else { + performTransmissionAction( + operation: { try await store.renameTorrentRoot(targetTorrent, to: newName) }, + onSuccess: { (_: TorrentRenameResponseArgs) in renameDialog = false + }, + onError: { message in + errorMessage = message + showingError = true } - } + ) } ) .frame(width: 420) @@ -420,6 +419,8 @@ struct LabelEditSheetContent: View { @Binding var labelInput: String @Binding var shouldSave: Bool @Binding var isPresented: Bool + @Binding var showingError: Bool + @Binding var errorMessage: String var body: some View { VStack(spacing: 16) { @@ -431,7 +432,11 @@ struct LabelEditSheetContent: View { existingLabels: selectedTorrents.count == 1 ? Array(selectedTorrents.first!.labels) : [], store: store, selectedTorrents: selectedTorrents, - shouldSave: $shouldSave + shouldSave: $shouldSave, + onError: { message in + errorMessage = message + showingError = true + } ) HStack { @@ -482,14 +487,16 @@ struct RenameSheetContent: View { showingError = true return } - renameTorrentRoot(torrent: targetTorrent, to: newName, store: store) { err in - if let err = err { - errorMessage = err - showingError = true - } else { + performTransmissionAction( + operation: { try await store.renameTorrentRoot(targetTorrent, to: newName) }, + onSuccess: { (_: TorrentRenameResponseArgs) in isPresented = false + }, + onError: { message in + errorMessage = message + showingError = true } - } + ) } ) } @@ -512,35 +519,25 @@ extension View { isPresented: isPresented ) { Button(role: .destructive) { - let info = makeConfig(store: store) - for torrent in set { - deleteTorrent(torrent: torrent, erase: true, config: info.config, auth: info.auth, onDel: { response in - handleTransmissionResponse(response, - onSuccess: {}, - onError: { error in - errorMessage.wrappedValue = error - showingError.wrappedValue = true - } - ) - }) - } + deleteTorrents( + ids: Array(set.map(\.id)), + deleteLocalData: true, + store: store, + showingError: showingError, + errorMessage: errorMessage + ) isPresented.wrappedValue.toggle() } label: { Text("Delete file(s)") } Button("Remove from list only") { - let info = makeConfig(store: store) - for torrent in set { - deleteTorrent(torrent: torrent, erase: false, config: info.config, auth: info.auth, onDel: { response in - handleTransmissionResponse(response, - onSuccess: {}, - onError: { error in - errorMessage.wrappedValue = error - showingError.wrappedValue = true - } - ) - }) - } + deleteTorrents( + ids: Array(set.map(\.id)), + deleteLocalData: false, + store: store, + showingError: showingError, + errorMessage: errorMessage + ) isPresented.wrappedValue.toggle() } } message: { @@ -549,4 +546,21 @@ extension View { } } +@MainActor +private func deleteTorrents( + ids: [Int], + deleteLocalData: Bool, + store: TransmissionStore, + showingError: Binding, + errorMessage: Binding +) { + performTransmissionAction( + operation: { try await store.removeTorrents(ids: ids, deleteLocalData: deleteLocalData) }, + onError: makeTransmissionBindingErrorHandler( + isPresented: showingError, + message: errorMessage + ) + ) +} + #endif diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index 05a6e27..0805d02 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -209,18 +209,13 @@ struct macOSTorrentDetail: View { store: store, peers: peers, peersFrom: peersFrom, - onRefresh: { - fetchTorrentPeers(transferId: torrent.id, store: store) { fetchedPeers, fetchedFrom in - peers = fetchedPeers - peersFrom = fetchedFrom - } - }, + onRefresh: { await loadSupplementalData(for: torrent.id) }, onDone: { isShowingPeersSheet = false } ) .frame(minWidth: 1000, minHeight: 700) } .task(id: torrent.id) { - loadSupplementalData() + await loadSupplementalData(for: torrent.id) } .toolbar { // Use shared toolbar @@ -228,34 +223,12 @@ struct macOSTorrentDetail: View { } .alert("Delete Torrent", isPresented: $showingDeleteConfirmation) { Button(role: .destructive) { - let info = makeConfig(store: store) - deleteTorrent(torrent: torrent, erase: true, config: info.config, auth: info.auth, onDel: { response in - handleTransmissionResponse(response, - onSuccess: { - dismiss() - }, - onError: { errorMessage in - deleteErrorMessage = errorMessage - showingDeleteError = true - } - ) - }) + performDelete(deleteLocalData: true) } label: { Text("Delete file(s)") } Button("Remove from list only") { - let info = makeConfig(store: store) - deleteTorrent(torrent: torrent, erase: false, config: info.config, auth: info.auth, onDel: { response in - handleTransmissionResponse(response, - onSuccess: { - dismiss() - }, - onError: { errorMessage in - deleteErrorMessage = errorMessage - showingDeleteError = true - } - ) - }) + performDelete(deleteLocalData: false) } Button("Cancel", role: .cancel) { } } message: { @@ -264,35 +237,53 @@ struct macOSTorrentDetail: View { .transmissionErrorAlert(isPresented: $showingDeleteError, message: deleteErrorMessage) } - private func loadSupplementalData() { - files = [] - fileStats = [] - peers = [] - peersFrom = nil - pieceCount = 0 - pieceSize = 0 - piecesBitfield = "" - piecesHaveCount = 0 - - fetchTorrentFiles(transferId: torrent.id, store: store) { fetchedFiles, fetchedStats in - files = fetchedFiles - fileStats = fetchedStats + @MainActor + private func loadSupplementalData(for torrentID: Int) async { + guard let snapshot = await performStructuredTransmissionOperation( + operation: { try await store.loadTorrentDetail(id: torrentID) }, + onError: { message in + deleteErrorMessage = message + showingDeleteError = true + } + ) else { + return } - fetchTorrentPeers(transferId: torrent.id, store: store) { fetchedPeers, fetchedFrom in - peers = fetchedPeers - peersFrom = fetchedFrom - } + apply(snapshot: snapshot) + } - let info = makeConfig(store: store) - getTorrentPieces(transferId: torrent.id, info: info) { count, size, bitfield in - pieceCount = count - pieceSize = size - piecesBitfield = bitfield + private func performDelete(deleteLocalData: Bool) { + performTransmissionAction( + operation: { + try await store.removeTorrents( + ids: [torrent.id], + deleteLocalData: deleteLocalData + ) + }, + onSuccess: { + dismiss() + }, + onError: makeTransmissionBindingErrorHandler( + isPresented: $showingDeleteError, + message: $deleteErrorMessage + ) + ) + } - let haveSet = decodePiecesBitfield(base64String: bitfield, pieceCount: count) - piecesHaveCount = haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } - } + private func apply(snapshot: TransmissionTorrentDetailSnapshot) { + files = snapshot.files + fileStats = snapshot.fileStats + peers = snapshot.peers + peersFrom = snapshot.peersFrom + pieceCount = snapshot.pieceCount + pieceSize = snapshot.pieceSize + piecesBitfield = snapshot.piecesBitfieldBase64 + + let haveSet = decodePiecesBitfield( + base64String: snapshot.piecesBitfieldBase64, + pieceCount: snapshot.pieceCount + ) + piecesHaveCount = haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } } } diff --git a/BitDream/Views/macOS/macOSTorrentEdit.swift b/BitDream/Views/macOS/macOSTorrentEdit.swift index e3bb067..3a6c90a 100644 --- a/BitDream/Views/macOS/macOSTorrentEdit.swift +++ b/BitDream/Views/macOS/macOSTorrentEdit.swift @@ -67,13 +67,15 @@ struct LabelEditView: View { var store: TransmissionStore let selectedTorrents: Set @Binding var shouldSave: Bool + let onError: @MainActor @Sendable (String) -> Void init( labelInput: Binding, existingLabels: [String], store: TransmissionStore, selectedTorrents: Set, - shouldSave: Binding + shouldSave: Binding, + onError: @escaping @MainActor @Sendable (String) -> Void ) { self._labelInput = labelInput self.existingLabels = existingLabels @@ -81,6 +83,7 @@ struct LabelEditView: View { self.store = store self.selectedTorrents = selectedTorrents self._shouldSave = shouldSave + self.onError = onError } private func saveAndDismiss() { @@ -92,22 +95,33 @@ struct LabelEditView: View { if selectedTorrents.count == 1 { let torrent = selectedTorrents.first! - saveTorrentLabels(torrentId: torrent.id, labels: workingLabels, store: store) { - dismiss() - } + let sortedLabels = Array(workingLabels).sorted() + performTransmissionAction( + operation: { + try await store.updateTorrentLabels([ + TransmissionTorrentLabelsUpdate(ids: [torrent.id], labels: sortedLabels) + ]) + }, + onSuccess: { + dismiss() + }, + onError: onError + ) } else { - let info = makeConfig(store: store) - for torrent in selectedTorrents { - let mergedLabels = Set(torrent.labels).union(workingLabels) - let sortedLabels = Array(mergedLabels).sorted() - updateTorrent( - args: TorrentSetRequestArgs(ids: [torrent.id], labels: sortedLabels), - info: info, - onComplete: { _ in } + let updates = selectedTorrents.map { torrent in + TransmissionTorrentLabelsUpdate( + ids: [torrent.id], + labels: Array(Set(torrent.labels).union(workingLabels)).sorted() ) } - store.requestRefresh() - dismiss() + + performTransmissionAction( + operation: { try await store.updateTorrentLabels(updates) }, + onSuccess: { + dismiss() + }, + onError: onError + ) } } @@ -209,22 +223,25 @@ struct MoveSheetContent: View { Button("Cancel") { isPresented = false } .keyboardShortcut(.cancelAction) Button("Set Location") { - let info = makeConfig(store: store) let ids = Array(selectedTorrents.map(\.id)) - let args = TorrentSetLocationRequestArgs( - ids: ids, - location: movePath.trimmingCharacters(in: .whitespacesAndNewlines), - move: moveShouldMove - ) - setTorrentLocation(args: args, info: info) { response in - handleTransmissionResponse(response, onSuccess: { - store.requestRefresh() + let location = movePath.trimmingCharacters(in: .whitespacesAndNewlines) + + performTransmissionAction( + operation: { + try await store.setTorrentLocation( + ids: ids, + location: location, + move: moveShouldMove + ) + }, + onSuccess: { isPresented = false - }, onError: { error in - errorMessage = error - showingError = true - }) - } + }, + onError: makeTransmissionBindingErrorHandler( + isPresented: $showingError, + message: $errorMessage + ) + ) } .keyboardShortcut(.defaultAction) .buttonStyle(.borderedProminent) diff --git a/BitDream/Views/macOS/macOSTorrentFileDetail.swift b/BitDream/Views/macOS/macOSTorrentFileDetail.swift index 6489f4f..faea981 100644 --- a/BitDream/Views/macOS/macOSTorrentFileDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentFileDetail.swift @@ -40,8 +40,44 @@ struct macOSTorrentFileDetail: View { @State private var columnVisibility = Set(["name", "size", "progress", "downloaded", "priority", "status"]) @State private var mutableFileStats: [TorrentFileStats] = [] @State private var cachedRows: [TorrentFileRow] = [] + @State private var showingError = false + @State private var errorMessage = "" - private func recomputeRows() { + var body: some View { + VStack(spacing: 0) { + // Header with search and filters + HeaderView(viewModel: viewModel) + .padding(.horizontal) + .padding(.vertical, 12) + + Divider() + + // Table + filesTable + + // Footer with file count + FooterView(totalCount: cachedRows.count, filteredCount: filteredRows.count) + .padding(.horizontal) + .padding(.vertical, 8) + } + .frame(minHeight: 300) + .onAppear { + mutableFileStats = fileStats + recomputeRows() + } + .onChange(of: fileStats) { _, newValue in + mutableFileStats = newValue + recomputeRows() + } + .onChange(of: files) { _, _ in + recomputeRows() + } + .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) + } +} + +private extension macOSTorrentFileDetail { + func recomputeRows() { let statsToUse = mutableFileStats.isEmpty ? fileStats : mutableFileStats let processed = processFilesForDisplay(files, stats: statsToUse) cachedRows = processed.map { processed in @@ -57,15 +93,13 @@ struct macOSTorrentFileDetail: View { } } - private var filteredRows: [TorrentFileRow] { + var filteredRows: [TorrentFileRow] { cachedRows.filter { row in - // Search filter if !viewModel.searchText.isEmpty { let searchLower = viewModel.searchText.lowercased() if !row.name.lowercased().contains(searchLower) { return false } } - // Priority filters let priority = FilePriority(rawValue: row.priority) ?? .normal switch priority { case .low: @@ -76,16 +110,13 @@ struct macOSTorrentFileDetail: View { if !viewModel.showHighPriority { return false } } - // Wanted/Skip filter if row.wanted && !viewModel.showWantedFiles { return false } if !row.wanted && !viewModel.showSkippedFiles { return false } - // Completion filter let isComplete = row.percentDone >= 1.0 if isComplete && !viewModel.showCompleteFiles { return false } if !isComplete && !viewModel.showIncompleteFiles { return false } - // File type filter let fileType = fileTypeCategory(row.name) switch fileType { case .video: if !viewModel.showVideos { return false } @@ -102,7 +133,7 @@ struct macOSTorrentFileDetail: View { .sorted(using: viewModel.sortOrder) } - private var filesTable: some View { + var filesTable: some View { Table(filteredRows, selection: $viewModel.selection, sortOrder: $viewModel.sortOrder) { TableColumn("") { row in Toggle("", isOn: Binding( @@ -166,36 +197,36 @@ struct macOSTorrentFileDetail: View { .contextMenu(forSelectionType: TorrentFileRow.ID.self) { selection in if selection.isEmpty { Button("Select All") { - viewModel.selection = Set(filteredRows.map { $0.id }) + viewModel.selection = Set(filteredRows.map(\.id)) } } else { let selectedRows = filteredRows.filter { selection.contains($0.id) } Section("Status") { Toggle("Download", isOn: Binding( - get: { selectedRows.allSatisfy({ $0.wanted }) }, + get: { selectedRows.allSatisfy(\.wanted) }, set: { _ in setFilesWanted(selectedRows, wanted: true) } )) Toggle("Don't Download", isOn: Binding( - get: { selectedRows.allSatisfy({ !$0.wanted }) }, + get: { selectedRows.allSatisfy { !$0.wanted } }, set: { _ in setFilesWanted(selectedRows, wanted: false) } )) } Section("Priority") { Toggle("High", isOn: Binding( - get: { selectedRows.allSatisfy({ $0.priority == FilePriority.high.rawValue }) }, + get: { selectedRows.allSatisfy { $0.priority == FilePriority.high.rawValue } }, set: { _ in setFilesPriority(selectedRows, priority: .high) } )) Toggle("Normal", isOn: Binding( - get: { selectedRows.allSatisfy({ $0.priority == FilePriority.normal.rawValue }) }, + get: { selectedRows.allSatisfy { $0.priority == FilePriority.normal.rawValue } }, set: { _ in setFilesPriority(selectedRows, priority: .normal) } )) Toggle("Low", isOn: Binding( - get: { selectedRows.allSatisfy({ $0.priority == FilePriority.low.rawValue }) }, + get: { selectedRows.allSatisfy { $0.priority == FilePriority.low.rawValue } }, set: { _ in setFilesPriority(selectedRows, priority: .low) } )) } @@ -203,95 +234,77 @@ struct macOSTorrentFileDetail: View { } } - var body: some View { - VStack(spacing: 0) { - // Header with search and filters - HeaderView(viewModel: viewModel) - .padding(.horizontal) - .padding(.vertical, 12) - - Divider() + func setFilesWanted(_ selectedRows: [TorrentFileRow], wanted: Bool) { + let fileIndices = selectedRows.map(\.fileIndex) + let previousStats = snapshotStats(for: fileIndices) - // Table - filesTable + updateLocalFileStatus(selectedRows, wanted: wanted) - // Footer with file count - FooterView(totalCount: cachedRows.count, filteredCount: filteredRows.count) - .padding(.horizontal) - .padding(.vertical, 8) - } - .frame(minHeight: 300) - .onAppear { - mutableFileStats = fileStats - recomputeRows() - } + performTransmissionAction( + operation: { + try await store.setFileWantedStatus( + torrentId: torrentId, + fileIndices: fileIndices, + wanted: wanted + ) + }, + onError: { message in + revertStats(previousStats) + errorMessage = message + showingError = true + } + ) } - // MARK: - File Operations - - private func setFilesWanted(_ selectedRows: [TorrentFileRow], wanted: Bool) { - let fileIndices = selectedRows.map { $0.fileIndex } - - // Snapshot previous stats for revert-on-failure - let previousStats: [(index: Int, stats: TorrentFileStats)] = fileIndices.compactMap { idx in - guard idx < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { return nil } - let current = (mutableFileStats.isEmpty ? fileStats[idx] : mutableFileStats[idx]) - return (idx, current) - } + func setFilesPriority(_ selectedRows: [TorrentFileRow], priority: FilePriority) { + let fileIndices = selectedRows.map(\.fileIndex) + let previousStats = snapshotStats(for: fileIndices) - updateLocalFileStatus(selectedRows, wanted: wanted) + updateLocalFilePriority(selectedRows, priority: priority) - Task { @MainActor in - let response = await FileActionExecutor.setWanted( - torrentId: torrentId, - fileIndices: fileIndices, - store: store, - wanted: wanted - ) - if response != .success { - for (idx, old) in previousStats where idx < mutableFileStats.count { - mutableFileStats[idx] = old - } - recomputeRows() + performTransmissionAction( + operation: { + try await store.setFilePriority( + torrentId: torrentId, + fileIndices: fileIndices, + priority: priority + ) + }, + onError: { message in + revertStats(previousStats) + errorMessage = message + showingError = true } - } + ) } - private func setFilesPriority(_ selectedRows: [TorrentFileRow], priority: FilePriority) { - let fileIndices = selectedRows.map { $0.fileIndex } - - // Snapshot previous stats for revert-on-failure - let previousStats: [(index: Int, stats: TorrentFileStats)] = fileIndices.compactMap { idx in - guard idx < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { return nil } - let current = (mutableFileStats.isEmpty ? fileStats[idx] : mutableFileStats[idx]) + func snapshotStats(for fileIndices: [Int]) -> [(index: Int, stats: TorrentFileStats)] { + fileIndices.compactMap { idx in + guard idx < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { + return nil + } + let current = mutableFileStats.isEmpty ? fileStats[idx] : mutableFileStats[idx] return (idx, current) } + } - updateLocalFilePriority(selectedRows, priority: priority) - - Task { @MainActor in - let response = await FileActionExecutor.setPriority( - torrentId: torrentId, - fileIndices: fileIndices, - store: store, - priority: priority - ) - if response != .success { - for (idx, old) in previousStats where idx < mutableFileStats.count { - mutableFileStats[idx] = old - } - recomputeRows() - } + func revertStats(_ previousStats: [(index: Int, stats: TorrentFileStats)]) { + for (idx, old) in previousStats where idx < mutableFileStats.count { + mutableFileStats[idx] = old } + recomputeRows() } - private func updateLocalFileStatus(_ selectedRows: [TorrentFileRow], wanted: Bool) { - // Update local data optimistically by mutating stats + func updateLocalFileStatus(_ selectedRows: [TorrentFileRow], wanted: Bool) { for row in selectedRows { let idx = row.fileIndex guard idx < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { continue } let current = mutableFileStats.isEmpty ? fileStats[idx] : mutableFileStats[idx] - let updated = TorrentFileStats(bytesCompleted: current.bytesCompleted, wanted: wanted, priority: current.priority) + let updated = TorrentFileStats( + bytesCompleted: current.bytesCompleted, + wanted: wanted, + priority: current.priority + ) if mutableFileStats.isEmpty { mutableFileStats = fileStats } @@ -300,13 +313,16 @@ struct macOSTorrentFileDetail: View { recomputeRows() } - private func updateLocalFilePriority(_ selectedRows: [TorrentFileRow], priority: FilePriority) { - // Update local data optimistically by mutating stats + func updateLocalFilePriority(_ selectedRows: [TorrentFileRow], priority: FilePriority) { for row in selectedRows { let idx = row.fileIndex guard idx < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { continue } let current = mutableFileStats.isEmpty ? fileStats[idx] : mutableFileStats[idx] - let updated = TorrentFileStats(bytesCompleted: current.bytesCompleted, wanted: current.wanted, priority: priority.rawValue) + let updated = TorrentFileStats( + bytesCompleted: current.bytesCompleted, + wanted: current.wanted, + priority: priority.rawValue + ) if mutableFileStats.isEmpty { mutableFileStats = fileStats } diff --git a/BitDream/Views/macOS/macOSTorrentList.swift b/BitDream/Views/macOS/macOSTorrentList.swift index beac664..3f241d1 100644 --- a/BitDream/Views/macOS/macOSTorrentList.swift +++ b/BitDream/Views/macOS/macOSTorrentList.swift @@ -103,7 +103,9 @@ struct TorrentRowModifier: ViewModifier { selectedTorrents: affectedTorrents, labelInput: $labelInput, shouldSave: $shouldSave, - isPresented: $labelDialog + isPresented: $labelDialog, + showingError: $showingError, + errorMessage: $errorMessage ) .frame(width: 400) } diff --git a/BitDream/Views/macOS/macOSTorrentListCompact.swift b/BitDream/Views/macOS/macOSTorrentListCompact.swift index 2bf0550..8520ace 100644 --- a/BitDream/Views/macOS/macOSTorrentListCompact.swift +++ b/BitDream/Views/macOS/macOSTorrentListCompact.swift @@ -299,7 +299,9 @@ private extension macOSTorrentListCompact { selectedTorrents: selectedTorrentsSet, labelInput: $labelInput, shouldSave: $shouldSave, - isPresented: $labelDialog + isPresented: $labelDialog, + showingError: $showingError, + errorMessage: $errorMessage ) .frame(width: 400) } diff --git a/BitDream/Views/macOS/macOSTorrentPeerDetail.swift b/BitDream/Views/macOS/macOSTorrentPeerDetail.swift index 8781129..a86b81b 100644 --- a/BitDream/Views/macOS/macOSTorrentPeerDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentPeerDetail.swift @@ -8,7 +8,7 @@ struct macOSTorrentPeerDetail: View { let store: TransmissionStore let peers: [Peer] let peersFrom: PeersFrom? - let onRefresh: () -> Void + let onRefresh: @MainActor () async -> Void let onDone: () -> Void var body: some View { @@ -28,7 +28,9 @@ struct macOSTorrentPeerDetail: View { } Spacer() HStack(spacing: 8) { - Button(action: onRefresh) { + Button { + Task { await onRefresh() } + } label: { Label("Refresh", systemImage: "arrow.clockwise") } Button("Done", action: onDone) @@ -50,7 +52,11 @@ struct macOSTorrentPeerDetail: View { VStack(spacing: 12) { Text("No peers yet") .foregroundColor(.secondary) - Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") } + Button { + Task { await onRefresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { @@ -100,7 +106,7 @@ struct macOSTorrentPeerDetail: View { let store: TransmissionStore let peers: [Peer] let peersFrom: PeersFrom? - let onRefresh: () -> Void + let onRefresh: @MainActor () async -> Void let onDone: () -> Void var body: some View { EmptyView() } } diff --git a/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift b/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift index d0bfea8..909cf63 100644 --- a/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift +++ b/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift @@ -124,6 +124,57 @@ final class TransmissionConnectionQueryTests: XCTestCase { XCTAssertEqual(try capturedRequestFields(requests[0]), TransmissionTorrentQuerySpec.torrentPieces(id: 42).fields) } + func testFetchTorrentDetailSnapshotUsesNamedQueriesAndDecodesResponse() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "torrent-get": [ + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()) + ] + ]) + let connection = TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + + let snapshot = try await connection.fetchTorrentDetailSnapshot(id: 42) + + XCTAssertEqual(snapshot.files.first?.name, "Ubuntu.iso") + XCTAssertEqual(snapshot.fileStats.first?.wanted, true) + XCTAssertEqual(snapshot.peers.first?.clientName, "Transmission") + XCTAssertEqual(snapshot.pieceCount, 2) + XCTAssertEqual(snapshot.piecesBitfieldBase64, "Zm9v") + + let requests = await sender.capturedRequests() + XCTAssertEqual(requests.count, 3) + let fields = try requests.map(capturedRequestFields) + XCTAssertTrue(fields.contains(TransmissionTorrentQuerySpec.torrentFiles(id: 42).fields)) + XCTAssertTrue(fields.contains(TransmissionTorrentQuerySpec.torrentPeers(id: 42).fields)) + XCTAssertTrue(fields.contains(TransmissionTorrentQuerySpec.torrentPieces(id: 42).fields)) + } + + func testFetchTorrentDetailSnapshotPropagatesErrors() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "torrent-get": [ + .http(statusCode: 200, body: #"{"result":"server busy","arguments":{}}"#), + .http(statusCode: 200, body: #"{"result":"server busy","arguments":{}}"#), + .http(statusCode: 200, body: #"{"result":"server busy","arguments":{}}"#) + ] + ]) + let connection = TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + + await assertThrowsTransmissionError(.rpcFailure(expectedResult: "server busy")) { + _ = try await connection.fetchTorrentDetailSnapshot(id: 42) + } + } +} + +final class TransmissionConnectionSessionQueryTests: XCTestCase { func testFetchSessionStatsDecodesResponse() async throws { let sender = QueueSender(steps: [ .http(statusCode: 200, body: successStatsBody) @@ -315,6 +366,58 @@ private func makeTorrentPeersSuccessBody() -> String { """ } +private func makeTorrentDetailSuccessBody() -> String { + """ + { + "arguments": { + "torrents": [ + { + "files": [ + { "bytesCompleted": 1, "length": 2, "name": "Ubuntu.iso" } + ], + "fileStats": [ + { "bytesCompleted": 1, "wanted": true, "priority": 0 } + ], + "peers": [ + { + "address": "127.0.0.1", + "clientName": "Transmission", + "clientIsChoked": false, + "clientIsInterested": true, + "flagStr": "D", + "isDownloadingFrom": true, + "isEncrypted": false, + "isIncoming": false, + "isUploadingTo": false, + "isUTP": false, + "peerIsChoked": false, + "peerIsInterested": true, + "port": 51413, + "progress": 0.5, + "rateToClient": 100, + "rateToPeer": 200 + } + ], + "peersFrom": { + "fromCache": 0, + "fromDht": 1, + "fromIncoming": 0, + "fromLpd": 0, + "fromLtep": 0, + "fromPex": 0, + "fromTracker": 1 + }, + "pieceCount": 2, + "pieceSize": 16384, + "pieces": "Zm9v" + } + ] + }, + "result": "success" + } + """ +} + private extension CapturedRequest { func asURLRequest() -> URLRequest { var request = URLRequest(url: url ?? URL(string: "https://example.com")!) diff --git a/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift b/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift index b6b2ba9..17e7d76 100644 --- a/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift +++ b/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift @@ -131,4 +131,114 @@ final class TransmissionConnectionTests: XCTestCase { XCTAssertEqual(torrent.id, 14) XCTAssertEqual(torrent.name, "Ubuntu.iso") } + + func testRemoveTorrentsUsesSingleBatchPayload() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + + try await connection.removeTorrents(ids: [11, 12], deleteLocalData: true) + + let requests = await sender.capturedRequests() + XCTAssertEqual(try requestMethod(from: requests[0].asURLRequest()), "torrent-remove") + let arguments = try requestArguments(from: requests[0]) + XCTAssertEqual(arguments["ids"] as? [Int], [11, 12]) + XCTAssertEqual(arguments["delete-local-data"] as? Bool, true) + } + + func testSetTorrentPriorityUsesBandwidthPriority() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + + try await connection.setTorrentPriority(ids: [42], priority: .high) + + let requests = await sender.capturedRequests() + XCTAssertEqual(try requestMethod(from: requests[0].asURLRequest()), "torrent-set") + let arguments = try requestArguments(from: requests[0]) + XCTAssertEqual(arguments["ids"] as? [Int], [42]) + XCTAssertEqual(arguments["bandwidthPriority"] as? Int, TorrentPriority.high.rawValue) + } + + func testQueueMoveUsesSingleBatchRequest() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + + try await connection.queueMove(.bottom, ids: [3, 4, 5]) + + let requests = await sender.capturedRequests() + XCTAssertEqual(try requestMethod(from: requests[0].asURLRequest()), "queue-move-bottom") + let arguments = try requestArguments(from: requests[0]) + XCTAssertEqual(arguments["ids"] as? [Int], [3, 4, 5]) + } + + func testRenameTorrentPathDecodesArguments() async throws { + let sender = QueueSender(steps: [ + .http( + statusCode: 200, + body: #"{"result":"success","arguments":{"path":"Old Name","name":"New Name","id":42}}"# + ) + ]) + let connection = TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + + let response = try await connection.renameTorrentPath( + torrentID: 42, + path: "Old Name", + newName: "New Name" + ) + + XCTAssertEqual(response.id, 42) + XCTAssertEqual(response.path, "Old Name") + XCTAssertEqual(response.name, "New Name") + } + + func testMutationMethodsPropagateTransmissionErrors() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: #"{"result":"queue move failed","arguments":{}}"#) + ]) + let connection = TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + + await assertThrowsTransmissionError(.rpcFailure(expectedResult: "queue move failed")) { + try await connection.queueMove(.top, ids: [1, 2]) + } + } +} + +private extension CapturedRequest { + func asURLRequest() -> URLRequest { + var request = URLRequest(url: url ?? URL(string: "https://example.com")!) + request.httpMethod = httpMethod + request.httpBody = body + return request + } +} + +private func requestArguments(from request: CapturedRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.body) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + return try XCTUnwrap(object["arguments"] as? [String: Any]) } diff --git a/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterQueryTests.swift b/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterQueryTests.swift deleted file mode 100644 index 763c5fb..0000000 --- a/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterQueryTests.swift +++ /dev/null @@ -1,139 +0,0 @@ -import XCTest -@testable import BitDream - -final class TransmissionAdapterQueryTests: XCTestCase { - func testFetchTorrentFilesReturnsFirstTorrentPayload() async { - let adapter = makeLegacyAdapter(steps: [ - .http( - statusCode: 200, - body: """ - { - "arguments": { - "torrents": [ - { - "files": [ - { "bytesCompleted": 1, "length": 2, "name": "Ubuntu.iso" } - ], - "fileStats": [ - { "bytesCompleted": 1, "wanted": true, "priority": 0 } - ] - } - ] - }, - "result": "success" - } - """ - ) - ]) - - let result = await adapter.fetchTorrentFiles( - transferID: 42, - config: makeConfig(), - auth: makeAuth() - ) - - switch result { - case .success(let response): - XCTAssertEqual(response.files.first?.name, "Ubuntu.iso") - case .failure(let error): - XCTFail("Expected success, got \(error)") - } - } - - func testFetchTorrentPeersReturnsFirstTorrentPayload() async { - let adapter = makeLegacyAdapter(steps: [ - .http(statusCode: 200, body: makeCompatibilityTorrentPeersSuccessBody()) - ]) - - let result = await adapter.fetchTorrentPeers( - transferID: 42, - config: makeConfig(), - auth: makeAuth() - ) - - switch result { - case .success(let response): - XCTAssertEqual(response.peers.first?.clientName, "Transmission") - case .failure(let error): - XCTFail("Expected success, got \(error)") - } - } - - func testFetchTorrentPiecesReturnsFirstTorrentPayload() async { - let adapter = makeLegacyAdapter(steps: [ - .http( - statusCode: 200, - body: """ - { - "arguments": { - "torrents": [ - { - "pieceCount": 2, - "pieceSize": 16384, - "pieces": "Zm9v" - } - ] - }, - "result": "success" - } - """ - ) - ]) - - let result = await adapter.fetchTorrentPieces( - transferID: 42, - config: makeConfig(), - auth: makeAuth() - ) - - switch result { - case .success(let response): - XCTAssertEqual(response.pieces, "Zm9v") - case .failure(let error): - XCTFail("Expected success, got \(error)") - } - } -} - -private func makeCompatibilityTorrentPeersSuccessBody() -> String { - """ - { - "arguments": { - "torrents": [ - { - "peers": [ - { - "address": "127.0.0.1", - "clientName": "Transmission", - "clientIsChoked": false, - "clientIsInterested": true, - "flagStr": "D", - "isDownloadingFrom": true, - "isEncrypted": false, - "isIncoming": false, - "isUploadingTo": false, - "isUTP": false, - "peerIsChoked": false, - "peerIsInterested": true, - "port": 51413, - "progress": 0.5, - "rateToClient": 100, - "rateToPeer": 200 - } - ], - "peersFrom": { - "fromCache": 0, - "fromDht": 1, - "fromIncoming": 0, - "fromLpd": 0, - "fromLtep": 0, - "fromPex": 0, - "fromTracker": 1 - } - } - ] - }, - "result": "success" - } - """ -} diff --git a/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterStatusTests.swift b/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterStatusTests.swift deleted file mode 100644 index d4f60df..0000000 --- a/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterStatusTests.swift +++ /dev/null @@ -1,198 +0,0 @@ -import XCTest -@testable import BitDream - -// TODO: Remove these suites when the temporary compatibility adapter is deleted. -final class TransmissionAdapterStatusTests: XCTestCase { - func testStatusRequestMapsRPCFailureToFailed() async { - let adapter = makeLegacyAdapter(steps: [ - .http(statusCode: 200, body: #"{"result":"queue move failed","arguments":{}}"#) - ]) - - let response = await adapter.performStatusRequest( - method: "queue-move-top", - args: EmptyArguments(), - config: makeConfig(), - auth: makeAuth() - ) - - XCTAssertEqual(response, .failed) - } - - func testStatusRequestMapsUnauthorizedToUnauthorized() async { - let adapter = makeLegacyAdapter(steps: [ - .http(statusCode: 401, body: "") - ]) - - let response = await adapter.performStatusRequest( - method: "torrent-stop", - args: EmptyArguments(), - config: makeConfig(), - auth: makeAuth() - ) - - XCTAssertEqual(response, .unauthorized) - } - - func testStatusRequestMapsInvalidEndpointToConfigError() async { - var config = makeConfig() - config.host = "bad host" - - let adapter = makeLegacyAdapter(steps: []) - let response = await adapter.performStatusRequest( - method: "torrent-stop", - args: EmptyArguments(), - config: config, - auth: makeAuth() - ) - - XCTAssertEqual(response, .configError) - } - - func testStatusRequestMapsTimeoutToConfigError() async { - let adapter = makeLegacyAdapter(steps: [ - .error(URLError(.timedOut)) - ]) - - let response = await adapter.performStatusRequest( - method: "torrent-stop", - args: EmptyArguments(), - config: makeConfig(), - auth: makeAuth() - ) - - XCTAssertEqual(response, .configError) - } - - func testStatusRequestInherits409RetryBehaviorFromTransport() async { - let adapter = makeLegacyAdapter(steps: [ - .http(statusCode: 409, body: "", headers: [transmissionSessionTokenHeader: "fresh-token"]), - .http(statusCode: 200, body: successEmptyBody) - ]) - - let response = await adapter.performStatusRequest( - method: "torrent-stop", - args: EmptyArguments(), - config: makeConfig(), - auth: makeAuth() - ) - - XCTAssertEqual(response, .success) - } - - func testTorrentAddAddedOutcomeReturnsSuccessAndID() async { - let adapter = makeLegacyAdapter(steps: [ - .http( - statusCode: 200, - body: """ - { - "arguments": { - "torrent-added": { - "hashString": "abc", - "id": 12, - "name": "Ubuntu.iso" - } - }, - "result": "success" - } - """ - ) - ]) - - let result = await adapter.performTorrentAddRequest( - args: ["filename": "magnet:?xt=urn:btih:test"], - config: makeConfig(), - auth: makeAuth() - ) - - XCTAssertEqual(result.response, .success) - XCTAssertEqual(result.transferId, 12) - } - - func testTorrentAddDuplicateOutcomeReturnsSuccessAndID() async { - let adapter = makeLegacyAdapter(steps: [ - .http( - statusCode: 200, - body: """ - { - "arguments": { - "torrent-duplicate": { - "hashString": "abc", - "id": 14, - "name": "Ubuntu.iso" - } - }, - "result": "success" - } - """ - ) - ]) - - let result = await adapter.performTorrentAddRequest( - args: ["filename": "magnet:?xt=urn:btih:test"], - config: makeConfig(), - auth: makeAuth() - ) - - XCTAssertEqual(result.response, .success) - XCTAssertEqual(result.transferId, 14) - } - - func testTorrentAddMalformedSuccessPayloadFails() async { - let adapter = makeLegacyAdapter(steps: [ - .http(statusCode: 200, body: #"{"result":"success","arguments":{}}"#) - ]) - - let result = await adapter.performTorrentAddRequest( - args: ["filename": "magnet:?xt=urn:btih:test"], - config: makeConfig(), - auth: makeAuth() - ) - - XCTAssertEqual(result.response, .failed) - XCTAssertEqual(result.transferId, 0) - } - - func testDataRequestReturnsDecodedArguments() async throws { - let adapter = makeLegacyAdapter(steps: [ - .http(statusCode: 200, body: successStatsBody) - ]) - - let result = await adapter.performDataRequest( - method: "session-stats", - args: EmptyArguments(), - config: makeConfig(), - auth: makeAuth(), - responseType: SessionStats.self - ) - - switch result { - case .success(let stats): - XCTAssertEqual(stats.torrentCount, 4) - case .failure(let error): - XCTFail("Expected success, got \(error)") - } - } - - func testDataRequestWrapsFailuresInCompatibilityError() async { - let adapter = makeLegacyAdapter(steps: [ - .http(statusCode: 200, body: #"{"result":"server busy","arguments":{}}"#) - ]) - - let result = await adapter.performDataRequest( - method: "session-stats", - args: EmptyArguments(), - config: makeConfig(), - auth: makeAuth(), - responseType: SessionStats.self - ) - - switch result { - case .success: - XCTFail("Expected failure") - case .failure(let error): - XCTAssertTrue(error is TransmissionLegacyCompatibilityError) - XCTAssertFalse(error is TransmissionError) - XCTAssertEqual(error.localizedDescription, "server busy") - } - } -} diff --git a/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterTestSupport.swift b/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterTestSupport.swift deleted file mode 100644 index 990d14a..0000000 --- a/BitDreamTests/Transmission/Legacy/TransmissionCompatibilityAdapterTestSupport.swift +++ /dev/null @@ -1,10 +0,0 @@ -import XCTest -@testable import BitDream - -func makeLegacyAdapter(steps: [QueueSender.Step]) -> TransmissionLegacyAdapter { - TransmissionLegacyAdapter( - transport: TransmissionTransport(sender: QueueSender(steps: steps)) - ) -} - -let successEmptyBody = #"{"result":"success","arguments":{}}"# diff --git a/BitDreamTests/Transmission/Legacy/TransmissionErrorPresentationTests.swift b/BitDreamTests/Transmission/Legacy/TransmissionErrorPresentationTests.swift deleted file mode 100644 index 419f1c5..0000000 --- a/BitDreamTests/Transmission/Legacy/TransmissionErrorPresentationTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import XCTest -@testable import BitDream - -final class TransmissionErrorPresentationTests: XCTestCase { - func testPresentationMapsEveryTransmissionErrorCase() { - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .invalidEndpointConfiguration), - TransmissionErrorPresentation( - title: "Connection Error", - message: "Connection error. Please check your server settings." - ) - ) - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .unauthorized), - TransmissionErrorPresentation( - title: "Authentication Failed", - message: "Authentication failed. Please check your server credentials." - ) - ) - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .transport(underlyingDescription: "Offline")), - TransmissionErrorPresentation(title: "Connection Error", message: "Offline") - ) - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .timeout), - TransmissionErrorPresentation(title: "Connection Timed Out", message: "The request timed out.") - ) - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .cancelled), - TransmissionErrorPresentation(title: "Request Cancelled", message: "The request was cancelled.") - ) - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .httpStatus(code: 500, body: nil)), - TransmissionErrorPresentation(title: "Server Error", message: "Server returned HTTP 500.") - ) - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .rpcFailure(result: "busy")), - TransmissionErrorPresentation(title: "Operation Failed", message: "busy") - ) - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .invalidResponse), - TransmissionErrorPresentation(title: "Server Error", message: "The server returned an invalid response.") - ) - XCTAssertEqual( - TransmissionErrorPresenter.presentation(for: .decoding(underlyingDescription: "bad json")), - TransmissionErrorPresentation(title: "Server Error", message: "Failed to decode the server response.") - ) - } - - func testLegacyResponseMappingMatchesCompatibilityContract() { - XCTAssertEqual(TransmissionLegacyCompatibility.response(from: .unauthorized), .unauthorized) - XCTAssertEqual(TransmissionLegacyCompatibility.response(from: .invalidEndpointConfiguration), .configError) - XCTAssertEqual(TransmissionLegacyCompatibility.response(from: .timeout), .configError) - XCTAssertEqual(TransmissionLegacyCompatibility.response(from: .rpcFailure(result: "busy")), .failed) - XCTAssertEqual(TransmissionLegacyCompatibility.response(from: .httpStatus(code: 500, body: nil)), .failed) - XCTAssertEqual(TransmissionLegacyCompatibility.response(from: .invalidResponse), .failed) - XCTAssertEqual(TransmissionLegacyCompatibility.response(from: .decoding(underlyingDescription: "bad json")), .failed) - } - - func testLegacyLocalizedErrorUsesPresentationMessage() { - let error = TransmissionLegacyCompatibility.localizedError( - from: TransmissionError.rpcFailure(result: "server busy") - ) - - XCTAssertTrue(error is TransmissionLegacyCompatibilityError) - XCTAssertEqual(error.localizedDescription, "server busy") - } - - func testLegacyResponsePresentationRemainsStable() { - XCTAssertNil(TransmissionLegacyCompatibility.presentation(for: .success)) - XCTAssertEqual( - TransmissionLegacyCompatibility.presentation(for: .unauthorized), - TransmissionErrorPresentation( - title: "Authentication Failed", - message: "Authentication failed. Please check your server credentials." - ) - ) - XCTAssertEqual( - TransmissionLegacyCompatibility.presentation(for: .configError), - TransmissionErrorPresentation( - title: "Connection Error", - message: "Connection error. Please check your server settings." - ) - ) - XCTAssertEqual( - TransmissionLegacyCompatibility.presentation(for: .failed), - TransmissionErrorPresentation( - title: "Operation Failed", - message: "Operation failed. Please try again." - ) - ) - } -} diff --git a/BitDreamTests/Transmission/TransmissionTestSupport.swift b/BitDreamTests/Transmission/TransmissionTestSupport.swift index bc626da..8584346 100644 --- a/BitDreamTests/Transmission/TransmissionTestSupport.swift +++ b/BitDreamTests/Transmission/TransmissionTestSupport.swift @@ -15,6 +15,13 @@ let successStatsBody = """ } """ +let successEmptyBody = """ +{ + "arguments": {}, + "result": "success" +} +""" + func makeConfig() -> TransmissionConfig { var config = TransmissionConfig() config.scheme = "http" diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift new file mode 100644 index 0000000..a8e60ef --- /dev/null +++ b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift @@ -0,0 +1,471 @@ +import Foundation +import XCTest +@testable import BitDream + +@MainActor +final class TransmissionStoreTorrentOperationTests: XCTestCase { + func testRemoveTorrentsSchedulesRefreshAfterSuccess() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody), + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ], + "torrent-remove": [ + .http(statusCode: 200, body: successEmptyBody) + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { store.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + try await store.removeTorrents(ids: [1], deleteLocalData: false) + + let didRefresh = await waitUntil { + let requests = await sender.capturedRequests() + return requests.count == 7 + } + XCTAssertTrue(didRefresh) + + let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } + XCTAssertEqual(methods.filter { $0 == "torrent-remove" }.count, 1) + XCTAssertEqual(methods.filter { $0 == "session-stats" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "session-get" }.count, 2) + } + + func testSupersededMutationDoesNotRefreshOldHostAfterHostSwitch() async throws { + let sender = try makeSupersededMutationSender() + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "old.example.com")) + let didConnectOldHost = await waitUntil { store.defaultDownloadDir == "/downloads/old" } + XCTAssertTrue(didConnectOldHost) + + let removeTask = Task { + try? await store.removeTorrents(ids: [1], deleteLocalData: true) + } + + let didStartOldRemove = await waitUntil { + let requests = await sender.capturedRequests() + return requests.count == 4 + } + XCTAssertTrue(didStartOldRemove) + + store.setHost(host: makeHost(serverID: "server-2", server: "new.example.com")) + let didConnectNewHost = await waitUntil { + store.host?.serverID == "server-2" && store.defaultDownloadDir == "/downloads/new" + } + XCTAssertTrue(didConnectNewHost) + + await sender.resume(id: "old-remove") + _ = await removeTask.result + + let requests = await sender.capturedRequests() + let oldHostRequests = requests.filter { $0.url?.host == "old.example.com" } + let newHostRequests = requests.filter { $0.url?.host == "new.example.com" } + XCTAssertEqual(oldHostRequests.count, 4) + XCTAssertEqual(newHostRequests.count, 3) + XCTAssertEqual(store.host?.serverID, "server-2") + XCTAssertEqual(store.defaultDownloadDir, "/downloads/new") + } + + func testLoadTorrentDetailRejectsSupersededResultsAfterHostSwitch() async throws { + let sender = HostMethodScriptedSender(stepsByHostAndMethod: [ + "old.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .blocked(id: "old-detail", statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/old", version: "4.0.0")) + ] + ], + "new.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/new", version: "5.0.0")) + ] + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "old.example.com")) + let didConnectOldHost = await waitUntil { store.connectionStatus == .connected } + XCTAssertTrue(didConnectOldHost) + + let detailTask = Task { try await store.loadTorrentDetail(id: 42) } + + let didStartOldDetailLoad = await waitUntil { + let requests = await sender.capturedRequests() + return requests.filter { $0.url?.host == "old.example.com" && ($0.url?.absoluteString.contains("/transmission/rpc") ?? false) }.count >= 4 + } + XCTAssertTrue(didStartOldDetailLoad) + + store.setHost(host: makeHost(serverID: "server-2", server: "new.example.com")) + let didConnectNewHost = await waitUntil { store.defaultDownloadDir == "/downloads/new" } + XCTAssertTrue(didConnectNewHost) + + await sender.resume(id: "old-detail") + + do { + _ = try await detailTask.value + XCTFail("Expected superseded detail load to fail") + } catch { + XCTAssertNil(TransmissionUserFacingError.presentation(for: error)) + } + } + + func testUpdateTorrentLabelsSchedulesRefreshAfterPartialFailure() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody), + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ], + "torrent-set": [ + .http(statusCode: 200, body: successEmptyBody), + .http(statusCode: 200, body: rpcFailureBody(result: "labels failed")) + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { store.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + await assertLabelUpdateFails(store) + + let didRefresh = await waitUntil { + let requests = await sender.capturedRequests() + return requests.count == 8 + } + XCTAssertTrue(didRefresh) + + let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } + XCTAssertEqual(methods.filter { $0 == "torrent-set" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "session-stats" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "session-get" }.count, 2) + } + + func testUpdateTorrentLabelsDoesNotScheduleRefreshWhenFirstUpdateFails() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ], + "torrent-set": [ + .http(statusCode: 200, body: rpcFailureBody(result: "labels failed")) + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { store.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + await assertLabelUpdateFails(store) + + let didUnexpectedRefresh = await waitUntil(timeout: 0.2) { + let requests = await sender.capturedRequests() + return requests.count > 4 + } + XCTAssertFalse(didUnexpectedRefresh) + } + + func testSupersededPartialLabelUpdateDoesNotRefreshNewHostAfterHostSwitch() async throws { + let sender = try makeSupersededPartialLabelUpdateSender() + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "old.example.com")) + let didConnectOldHost = await waitUntil { store.defaultDownloadDir == "/downloads/old" } + XCTAssertTrue(didConnectOldHost) + + let labelTask = Task { + try? await store.updateTorrentLabels([ + TransmissionTorrentLabelsUpdate(ids: [1], labels: ["alpha"]), + TransmissionTorrentLabelsUpdate(ids: [2], labels: ["beta"]) + ]) + } + + let didStartOldLabelUpdate = await waitUntil { + let requests = await sender.capturedRequests() + return requests.filter { $0.url?.host == "old.example.com" }.count == 5 + } + XCTAssertTrue(didStartOldLabelUpdate) + + store.setHost(host: makeHost(serverID: "server-2", server: "new.example.com")) + let didConnectNewHost = await waitUntil { + store.host?.serverID == "server-2" && store.defaultDownloadDir == "/downloads/new" + } + XCTAssertTrue(didConnectNewHost) + + await sender.resume(id: "old-label-failure") + _ = await labelTask.result + + let requests = await sender.capturedRequests() + let oldHostRequests = requests.filter { $0.url?.host == "old.example.com" } + let newHostRequests = requests.filter { $0.url?.host == "new.example.com" } + XCTAssertEqual(oldHostRequests.count, 5) + XCTAssertEqual(newHostRequests.count, 3) + XCTAssertEqual(store.host?.serverID, "server-2") + XCTAssertEqual(store.defaultDownloadDir, "/downloads/new") + } +} + +private extension TransmissionStoreTorrentOperationTests { + func makeStore(sender: some TransmissionRPCRequestSending) -> TransmissionStore { + let factory = TransmissionConnectionFactory( + transport: TransmissionTransport(sender: sender), + credentialResolver: TransmissionCredentialResolver(resolvePassword: { source in + switch source { + case .resolvedPassword(let password): + return password + case .keychainCredential(let key): + return key == "test-key" ? "secret" : "" + } + }) + ) + + return TransmissionStore( + connectionFactory: factory, + snapshotWriter: WidgetSnapshotWriter( + writeServerIndex: { _ in }, + writeSessionSnapshot: { _, _, _, _, _ in }, + reloadTimelines: { } + ), + sleep: { _ in + try await Task.sleep(nanoseconds: .max) + }, + persistVersion: { _, _ in } + ) + } + + func makeSupersededMutationSender() throws -> HostMethodScriptedSender { + HostMethodScriptedSender(stepsByHostAndMethod: [ + "old.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/old", version: "4.0.0")) + ], + "torrent-remove": [ + .blocked(id: "old-remove", statusCode: 200, body: successEmptyBody) + ] + ], + "new.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/new", version: "5.0.0")) + ] + ] + ]) + } + + func makeSupersededPartialLabelUpdateSender() throws -> HostMethodScriptedSender { + HostMethodScriptedSender(stepsByHostAndMethod: [ + "old.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/old", version: "4.0.0")) + ], + "torrent-set": [ + .http(statusCode: 200, body: successEmptyBody), + .blocked(id: "old-label-failure", statusCode: 200, body: rpcFailureBody(result: "labels failed")) + ] + ], + "new.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/new", version: "5.0.0")) + ] + ] + ]) + } + + func makeHost(serverID: String, server: String) -> BitDream.Host { + BitDream.Host( + serverID: serverID, + isDefault: false, + isSSL: false, + credentialKey: "test-key", + name: serverID, + port: 9091, + server: server, + username: "demo", + version: nil + ) + } + + func waitUntil( + timeout: TimeInterval = 1, + _ predicate: @escaping @MainActor () async -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await predicate() { + return true + } + await Task.yield() + } + return false + } +} + +private extension CapturedRequest { + func asURLRequest() -> URLRequest { + var request = URLRequest(url: url ?? URL(string: "https://example.com")!) + request.httpMethod = httpMethod + request.httpBody = body + return request + } +} + +private func sessionSettingsBody(downloadDir: String, version: String) throws -> String { + let data = Data(try loadTransmissionFixture(named: "session-get.response.json").utf8) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var arguments = try XCTUnwrap(object["arguments"] as? [String: Any]) + arguments["download-dir"] = downloadDir + arguments["version"] = version + arguments["blocklist-size"] = 0 + object["arguments"] = arguments + let encoded = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return try XCTUnwrap(String(bytes: encoded, encoding: .utf8)) +} + +private func makeTorrentDetailSuccessBody() -> String { + """ + { + "arguments": { + "torrents": [ + { + "files": [ + { "bytesCompleted": 1, "length": 2, "name": "Ubuntu.iso" } + ], + "fileStats": [ + { "bytesCompleted": 1, "wanted": true, "priority": 0 } + ], + "peers": [ + { + "address": "127.0.0.1", + "clientName": "Transmission", + "clientIsChoked": false, + "clientIsInterested": true, + "flagStr": "D", + "isDownloadingFrom": true, + "isEncrypted": false, + "isIncoming": false, + "isUploadingTo": false, + "isUTP": false, + "peerIsChoked": false, + "peerIsInterested": true, + "port": 51413, + "progress": 0.5, + "rateToClient": 100, + "rateToPeer": 200 + } + ], + "peersFrom": { + "fromCache": 0, + "fromDht": 1, + "fromIncoming": 0, + "fromLpd": 0, + "fromLtep": 0, + "fromPex": 0, + "fromTracker": 1 + }, + "pieceCount": 2, + "pieceSize": 16384, + "pieces": "Zm9v" + } + ] + }, + "result": "success" + } + """ +} + +private func rpcFailureBody(result: String) -> String { + """ + { + "arguments": {}, + "result": "\(result)" + } + """ +} + +@MainActor +private func assertLabelUpdateFails(_ store: TransmissionStore) async { + do { + try await store.updateTorrentLabels([ + TransmissionTorrentLabelsUpdate(ids: [1], labels: ["alpha"]), + TransmissionTorrentLabelsUpdate(ids: [2], labels: ["beta"]) + ]) + XCTFail("Expected TransmissionError") + } catch let error as TransmissionTransportFailure { + ErrorExpectation.rpcFailure(expectedResult: "labels failed").assertMatches( + error.transmissionError, + file: #filePath, + line: #line + ) + } catch let error as TransmissionError { + ErrorExpectation.rpcFailure(expectedResult: "labels failed").assertMatches( + error, + file: #filePath, + line: #line + ) + } catch { + XCTFail("Expected TransmissionError, got \(error)", file: #filePath, line: #line) + } +} diff --git a/BitDreamTests/Views/TransmissionActionsTests.swift b/BitDreamTests/Views/TransmissionActionsTests.swift new file mode 100644 index 0000000..0fd5bd6 --- /dev/null +++ b/BitDreamTests/Views/TransmissionActionsTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import BitDream + +@MainActor +final class TransmissionActionsTests: XCTestCase { + func testPerformStructuredTransmissionOperationReturnsValueWithoutCallingOnError() async { + var errors: [String] = [] + + let result = await performStructuredTransmissionOperation( + operation: { 42 }, + onError: { errors.append($0) } + ) + + XCTAssertEqual(result, 42) + XCTAssertTrue(errors.isEmpty) + } + + func testPerformStructuredTransmissionOperationMapsErrorsAndReturnsNil() async { + var errors: [String] = [] + let expectedMessage = TransmissionUserFacingError.message(for: TransmissionError.unauthorized) + + let result: Int? = await performStructuredTransmissionOperation( + operation: { throw TransmissionError.unauthorized }, + onError: { errors.append($0) } + ) + + XCTAssertNil(result) + XCTAssertEqual(errors, [expectedMessage].compactMap { $0 }) + } + + func testPerformStructuredTransmissionOperationSuppressesCancelledParentTask() async { + let gate = TaskStartGate() + var errors: [String] = [] + + let task = Task { @MainActor in + await performStructuredTransmissionOperation( + operation: { + await gate.markStarted() + try await Task.sleep(nanoseconds: 1_000_000_000) + return 42 + }, + onError: { errors.append($0) } + ) + } + + await gate.waitUntilStarted() + task.cancel() + let result = await task.value + + XCTAssertNil(result) + XCTAssertTrue(errors.isEmpty) + } +} + +private actor TaskStartGate { + private var continuation: CheckedContinuation? + private var hasStarted = false + + func markStarted() { + hasStarted = true + continuation?.resume() + continuation = nil + } + + func waitUntilStarted() async { + guard !hasStarted else { return } + + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } +} From c74024ae9ecd239aadbf435e936aedeb94a0bcc3 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 8 Mar 2026 12:36:55 -0700 Subject: [PATCH 2/8] await connection activation; add supplemental store Replace direct TransmissionConnectionFactory usage with an injectable async resolver and add awaitActiveConnection to wait for in-flight connection activation. Update connection-using APIs to await the active connection and use the resolver when activating. Introduce TorrentDetailSupplementalState, TorrentDetailSupplementalPayload, TorrentDetailSupplementalStore and lightweight placeholder views to manage and display supplemental torrent detail data (files, peers, pieces) and refactor iOS/macOS TorrentDetail views to use the new store and extracted content components. Add unit tests for the supplemental state and tests ensuring operations wait for activation and cancel when host switches; include a blocking resolver helper and a torrent-add test fixture. --- BitDream/TransmissionStore.swift | 50 ++- BitDream/Views/Shared/TorrentDetail.swift | 228 ++++++++++ BitDream/Views/iOS/iOSTorrentDetail.swift | 313 +++++++++----- BitDream/Views/macOS/macOSTorrentDetail.swift | 392 ++++++++++-------- ...ansmissionStoreSessionOperationTests.swift | 110 ++++- ...ansmissionStoreTorrentOperationTests.swift | 60 +++ .../TorrentDetailSupplementalStateTests.swift | 250 +++++++++++ 7 files changed, 1115 insertions(+), 288 deletions(-) create mode 100644 BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift diff --git a/BitDream/TransmissionStore.swift b/BitDream/TransmissionStore.swift index a6b0000..abd7a96 100644 --- a/BitDream/TransmissionStore.swift +++ b/BitDream/TransmissionStore.swift @@ -126,7 +126,7 @@ final class TransmissionStore: NSObject, ObservableObject { // Confirmation dialog state for menu remove command @Published var showingMenuRemoveConfirmation = false - private let connectionFactory: TransmissionConnectionFactory + private let resolveConnection: @Sendable (TransmissionConnectionDescriptor) async throws -> TransmissionConnection private let snapshotWriter: WidgetSnapshotWriter private let sleep: @Sendable (TimeInterval) async throws -> Void private let persistVersion: @MainActor @Sendable (String, String) async -> Void @@ -150,13 +150,16 @@ final class TransmissionStore: NSObject, ObservableObject { init( connectionFactory: TransmissionConnectionFactory = TransmissionConnectionFactory(), + resolveConnection: (@Sendable (TransmissionConnectionDescriptor) async throws -> TransmissionConnection)? = nil, snapshotWriter: WidgetSnapshotWriter = .live, sleep: @escaping @Sendable (TimeInterval) async throws -> Void = TransmissionStore.liveSleep, persistVersion: @escaping @MainActor @Sendable (String, String) async -> Void = { serverID, version in await HostRepository.shared.persistVersionIfNeeded(serverID: serverID, version: version) } ) { - self.connectionFactory = connectionFactory + self.resolveConnection = resolveConnection ?? { descriptor in + try await connectionFactory.connection(for: descriptor) + } self.snapshotWriter = snapshotWriter self.sleep = sleep self.persistVersion = persistVersion @@ -260,28 +263,28 @@ extension TransmissionStore { } func applySessionSettings(_ args: TransmissionSessionSetRequestArgs) async throws -> TransmissionSessionResponseArguments { - let connectionState = try requireActiveConnection() + let connectionState = try await awaitActiveConnection() try await connectionState.connection.setSession(args) try ensureCurrent(connectionState) return try await refreshSessionConfiguration(for: connectionState) } func checkFreeSpace(path: String) async throws -> FreeSpaceResponse { - let connectionState = try requireActiveConnection() + let connectionState = try await awaitActiveConnection() let response = try await connectionState.connection.checkFreeSpace(path: path) try ensureCurrent(connectionState) return response } func testPort(ipProtocol: String? = nil) async throws -> PortTestResponse { - let connectionState = try requireActiveConnection() + let connectionState = try await awaitActiveConnection() let response = try await connectionState.connection.testPort(ipProtocol: ipProtocol) try ensureCurrent(connectionState) return response } func updateBlocklist() async throws -> BlocklistUpdateResponse { - let connectionState = try requireActiveConnection() + let connectionState = try await awaitActiveConnection() let response = try await connectionState.connection.updateBlocklist() try ensureCurrent(connectionState) _ = try await refreshSessionConfiguration(for: connectionState) @@ -407,7 +410,7 @@ extension TransmissionStore { let nonEmptyUpdates = updates.filter { !$0.ids.isEmpty } guard !nonEmptyUpdates.isEmpty else { return } - let connectionState = try requireActiveConnection() + let connectionState = try await awaitActiveConnection() var appliedAnyUpdate = false do { @@ -676,7 +679,7 @@ extension TransmissionStore { private func activateConnection(for host: Host, generation: UUID) async { do { - let connection = try await connectionFactory.connection(for: TransmissionConnectionDescriptor(host: host)) + let connection = try await resolveConnection(TransmissionConnectionDescriptor(host: host)) guard isCurrentGeneration(generation, hostID: host.serverID) else { return } @@ -851,19 +854,42 @@ extension TransmissionStore { currentConnectionGeneration == generation && host?.serverID == hostID } - private func requireActiveConnection() throws -> ActiveConnection { - guard let activeConnection else { + private func awaitActiveConnection() async throws -> ActiveConnection { + if let activeConnection { + try ensureCurrent(activeConnection) + return activeConnection + } + + guard let host else { throw TransmissionError.invalidEndpointConfiguration } - return activeConnection + let waitingGeneration = currentConnectionGeneration + let waitingHostID = host.serverID + + if let activationTask { + await activationTask.value + + guard isCurrentGeneration(waitingGeneration, hostID: waitingHostID) else { + throw CancellationError() + } + + guard let activeConnection else { + throw CancellationError() + } + + try ensureCurrent(activeConnection) + return activeConnection + } + + throw CancellationError() } private func performConnectionOperation( refreshOnSuccess: Bool = false, operation: (ActiveConnection) async throws -> Result ) async throws -> Result { - let connectionState = try requireActiveConnection() + let connectionState = try await awaitActiveConnection() let result = try await operation(connectionState) try ensureCurrent(connectionState) if refreshOnSuccess { diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index 8cdc348..de0216f 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -16,6 +16,234 @@ struct TorrentDetail: View { // MARK: - Shared Helpers +internal enum TorrentDetailSupplementalLoadStatus: Sendable, Equatable { + case idle + case loading + case loaded + case failed +} + +internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { + let files: [TorrentFile] + let fileStats: [TorrentFileStats] + let peers: [Peer] + let peersFrom: PeersFrom? + let pieceCount: Int + let pieceSize: Int64 + let piecesBitfieldBase64: String + let piecesHaveCount: Int + + static let empty = TorrentDetailSupplementalPayload( + files: [], + fileStats: [], + peers: [], + peersFrom: nil, + pieceCount: 0, + pieceSize: 0, + piecesBitfieldBase64: "", + piecesHaveCount: 0 + ) + + init( + files: [TorrentFile], + fileStats: [TorrentFileStats], + peers: [Peer], + peersFrom: PeersFrom?, + pieceCount: Int, + pieceSize: Int64, + piecesBitfieldBase64: String, + piecesHaveCount: Int + ) { + self.files = files + self.fileStats = fileStats + self.peers = peers + self.peersFrom = peersFrom + self.pieceCount = pieceCount + self.pieceSize = pieceSize + self.piecesBitfieldBase64 = piecesBitfieldBase64 + self.piecesHaveCount = piecesHaveCount + } + + init(snapshot: TransmissionTorrentDetailSnapshot) { + let haveSet = decodePiecesBitfield( + base64String: snapshot.piecesBitfieldBase64, + pieceCount: snapshot.pieceCount + ) + + self.init( + files: snapshot.files, + fileStats: snapshot.fileStats, + peers: snapshot.peers, + peersFrom: snapshot.peersFrom, + pieceCount: snapshot.pieceCount, + pieceSize: snapshot.pieceSize, + piecesBitfieldBase64: snapshot.piecesBitfieldBase64, + piecesHaveCount: haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } + ) + } +} + +internal struct TorrentDetailSupplementalState: Sendable { + private(set) var activeTorrentID: Int? + private(set) var activeRequestGeneration: Int = 0 + private(set) var status: TorrentDetailSupplementalLoadStatus = .idle + private(set) var payload: TorrentDetailSupplementalPayload = .empty + private(set) var hasLoadedPayload = false + + var shouldDisplayPayload: Bool { + hasLoadedPayload + } + + @discardableResult + mutating func beginLoading(for torrentID: Int) -> Int { + if activeTorrentID != torrentID { + payload = .empty + hasLoadedPayload = false + } + + activeTorrentID = torrentID + activeRequestGeneration += 1 + status = .loading + return activeRequestGeneration + } + + @discardableResult + mutating func apply( + snapshot: TransmissionTorrentDetailSnapshot, + for torrentID: Int, + generation: Int + ) -> Bool { + guard activeTorrentID == torrentID, activeRequestGeneration == generation else { + return false + } + + status = .loaded + payload = TorrentDetailSupplementalPayload(snapshot: snapshot) + hasLoadedPayload = true + return true + } + + @discardableResult + mutating func markFailed(for torrentID: Int, generation: Int) -> Bool { + guard activeTorrentID == torrentID, activeRequestGeneration == generation else { + return false + } + + status = .failed + return true + } +} + +@MainActor +internal final class TorrentDetailSupplementalStore: ObservableObject { + @Published private(set) var state = TorrentDetailSupplementalState() + + var payload: TorrentDetailSupplementalPayload { + state.payload + } + + var status: TorrentDetailSupplementalLoadStatus { + state.status + } + + var shouldDisplayPayload: Bool { + state.shouldDisplayPayload + } + + func load( + for torrentID: Int, + using store: TransmissionStore, + onError: @escaping @MainActor @Sendable (String) -> Void + ) async { + let requestGeneration = mutateState { state in + state.beginLoading(for: torrentID) + } + + guard let snapshot = await performStructuredTransmissionOperation( + operation: { try await store.loadTorrentDetail(id: torrentID) }, + onError: { [weak self] message in + guard let self else { return } + guard self.markFailure(for: torrentID, generation: requestGeneration) else { + return + } + onError(message) + } + ) else { + return + } + + mutateState { state in + _ = state.apply( + snapshot: snapshot, + for: torrentID, + generation: requestGeneration + ) + } + } + + func loadIfNeeded( + for torrentID: Int, + using store: TransmissionStore, + onError: @escaping @MainActor @Sendable (String) -> Void + ) async { + guard !state.shouldDisplayPayload else { + return + } + + guard state.status != .loading else { + return + } + + await load(for: torrentID, using: store, onError: onError) + } + + @discardableResult + private func markFailure(for torrentID: Int, generation: Int) -> Bool { + var nextState = state + let didMarkFailure = nextState.markFailed(for: torrentID, generation: generation) + state = nextState + return didMarkFailure + } + + @discardableResult + private func mutateState( + _ mutate: (inout TorrentDetailSupplementalState) -> Result + ) -> Result { + var nextState = state + let result = mutate(&nextState) + state = nextState + return result + } +} + +internal struct TorrentDetailLoadingPlaceholderView: View { + let title: String + let message: String + + var body: some View { + ContentUnavailableView { + Label(title, systemImage: "arrow.triangle.2.circlepath") + } description: { + Text(message) + } actions: { + ProgressView() + } + } +} + +internal struct TorrentDetailUnavailablePlaceholderView: View { + let title: String + let message: String + + var body: some View { + ContentUnavailableView { + Label(title, systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } + } +} + // Shared function to determine torrent status color func statusColor(for torrent: Torrent) -> Color { if torrent.statusCalc == TorrentStatusCalc.complete || torrent.statusCalc == TorrentStatusCalc.seeding { diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index a2bf687..e937585 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -8,27 +8,191 @@ struct iOSTorrentDetail: View { @ObservedObject var store: TransmissionStore var torrent: Torrent - @State public var files: [TorrentFile] = [] - @State public var fileStats: [TorrentFileStats] = [] - @State private var isShowingFilesSheet = false - @State private var peers: [Peer] = [] - @State private var peersFrom: PeersFrom? - @State private var isShowingPeersSheet = false - @State private var pieceCount: Int = 0 - @State private var pieceSize: Int64 = 0 - @State private var piecesBitfield: String = "" - @State private var piecesHaveCount: Int = 0 + @StateObject private var supplementalStore = TorrentDetailSupplementalStore() @State private var showingDeleteConfirmation = false - @State private var showingDeleteError = false - @State private var deleteErrorMessage = "" + @State private var showingError = false + @State private var errorMessage = "" + + private var supplementalPayload: TorrentDetailSupplementalPayload { + supplementalStore.payload + } + + private var shouldDisplaySupplementalPayload: Bool { + supplementalStore.shouldDisplayPayload + } var body: some View { - // Use shared formatting function let details = formatTorrentDetails(torrent: torrent) + IOSTorrentDetailContent( + torrent: torrent, + details: details, + supplementalPayload: supplementalPayload, + filesDestination: filesDestination, + peersDestination: peersDestination, + onDelete: { showingDeleteConfirmation = true } + ) + .task(id: torrent.id) { + await loadSupplementalData(for: torrent.id) + } + .toolbar { + TorrentDetailToolbar(torrent: torrent, store: store) + } + .alert("Delete Torrent", isPresented: $showingDeleteConfirmation) { + Button(role: .destructive) { + performDelete(deleteLocalData: true) + } label: { + Text("Delete file(s)") + } + Button("Remove from list only") { + performDelete(deleteLocalData: false) + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Do you want to delete the file(s) from the disk?") + } + .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) + } + + private func performDelete(deleteLocalData: Bool) { + performTransmissionAction( + operation: { + try await store.removeTorrents( + ids: [torrent.id], + deleteLocalData: deleteLocalData + ) + }, + onSuccess: { + dismiss() + }, + onError: makeTransmissionBindingErrorHandler( + isPresented: $showingError, + message: $errorMessage + ) + ) + } + + @MainActor + private func loadSupplementalData(for torrentID: Int) async { + await supplementalStore.load(for: torrentID, using: store) { message in + errorMessage = message + showingError = true + } + } + + @MainActor + private func loadSupplementalDataIfNeeded(for torrentID: Int) async { + await supplementalStore.loadIfNeeded(for: torrentID, using: store) { message in + errorMessage = message + showingError = true + } + } + + @ViewBuilder + private var filesDestination: some View { + if shouldDisplaySupplementalPayload { + iOSTorrentFileDetail( + files: supplementalPayload.files, + fileStats: supplementalPayload.fileStats, + torrentId: torrent.id, + store: store + ) + .navigationBarTitleDisplayMode(.inline) + } else { + switch supplementalStore.status { + case .idle: + TorrentDetailLoadingPlaceholderView( + title: "Loading Files", + message: "Fetching the latest files for this torrent." + ) + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadSupplementalDataIfNeeded(for: torrent.id) + } + case .loading: + TorrentDetailLoadingPlaceholderView( + title: "Loading Files", + message: "Fetching the latest files for this torrent." + ) + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + case .failed: + TorrentDetailUnavailablePlaceholderView( + title: "Files Unavailable", + message: "The latest file details could not be loaded." + ) + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadSupplementalDataIfNeeded(for: torrent.id) + } + case .loaded: + EmptyView() + } + } + } + + @ViewBuilder + private var peersDestination: some View { + if shouldDisplaySupplementalPayload { + iOSTorrentPeerDetail( + torrentName: torrent.name, + torrentId: torrent.id, + store: store, + peers: supplementalPayload.peers, + peersFrom: supplementalPayload.peersFrom, + onRefresh: { await loadSupplementalData(for: torrent.id) }, + onDone: { /* no-op in push */ } + ) + .navigationBarTitleDisplayMode(.inline) + } else { + switch supplementalStore.status { + case .idle: + TorrentDetailLoadingPlaceholderView( + title: "Loading Peers", + message: "Fetching the latest peers for this torrent." + ) + .navigationTitle("Peers") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadSupplementalDataIfNeeded(for: torrent.id) + } + case .loading: + TorrentDetailLoadingPlaceholderView( + title: "Loading Peers", + message: "Fetching the latest peers for this torrent." + ) + .navigationTitle("Peers") + .navigationBarTitleDisplayMode(.inline) + case .failed: + TorrentDetailUnavailablePlaceholderView( + title: "Peers Unavailable", + message: "The latest peer details could not be loaded." + ) + .navigationTitle("Peers") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadSupplementalDataIfNeeded(for: torrent.id) + } + case .loaded: + EmptyView() + } + } + } +} + +private struct IOSTorrentDetailContent: View { + let torrent: Torrent + let details: TorrentDetailsDisplay + let supplementalPayload: TorrentDetailSupplementalPayload + let filesDestination: FilesDestination + let peersDestination: PeersDestination + let onDelete: () -> Void + + var body: some View { NavigationStack { VStack { - // Use shared header view TorrentDetailHeaderView(torrent: torrent) Form { @@ -54,25 +218,21 @@ struct iOSTorrentDetail: View { } NavigationLink { - iOSTorrentFileDetail(files: files, fileStats: fileStats, torrentId: torrent.id, store: store) - .navigationBarTitleDisplayMode(.inline) + filesDestination } label: { - LabeledContent("Files", value: NumberFormatter.localizedString(from: NSNumber(value: files.count), number: .decimal)) + LabeledContent( + "Files", + value: NumberFormatter.localizedString( + from: NSNumber(value: supplementalPayload.files.count), + number: .decimal + ) + ) } NavigationLink { - iOSTorrentPeerDetail( - torrentName: torrent.name, - torrentId: torrent.id, - store: store, - peers: peers, - peersFrom: peersFrom, - onRefresh: { await loadSupplementalData(for: torrent.id) }, - onDone: { /* no-op in push */ } - ) - .navigationBarTitleDisplayMode(.inline) + peersDestination } label: { - LabeledContent("Peers", value: "\(peers.count)") + LabeledContent("Peers", value: "\(supplementalPayload.peers.count)") } } @@ -109,15 +269,20 @@ struct iOSTorrentDetail: View { } } - // Pieces section - if pieceCount > 0 && !piecesBitfield.isEmpty { + if supplementalPayload.pieceCount > 0 && !supplementalPayload.piecesBitfieldBase64.isEmpty { Section(header: Text("Pieces")) { VStack(alignment: .leading, spacing: 6) { - PiecesGridView(pieceCount: pieceCount, piecesBitfieldBase64: piecesBitfield) - .frame(maxWidth: .infinity, alignment: .leading) - Text("\(piecesHaveCount) of \(pieceCount) pieces • \(formatByteCount(pieceSize)) each") - .font(.caption) - .foregroundColor(.gray) + PiecesGridView( + pieceCount: supplementalPayload.pieceCount, + piecesBitfieldBase64: supplementalPayload.piecesBitfieldBase64 + ) + .frame(maxWidth: .infinity, alignment: .leading) + + Text( + "\(supplementalPayload.piecesHaveCount) of \(supplementalPayload.pieceCount) pieces • \(formatByteCount(supplementalPayload.pieceSize)) each" + ) + .font(.caption) + .foregroundColor(.gray) } .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } @@ -138,7 +303,6 @@ struct iOSTorrentDetail: View { } } - // Beautiful Dedicated Labels Section (Display Only) if !torrent.labels.isEmpty { Section(header: Text("Labels")) { FlowLayout(spacing: 6) { @@ -151,9 +315,7 @@ struct iOSTorrentDetail: View { } } - Button(role: .destructive, action: { - showingDeleteConfirmation = true - }, label: { + Button(role: .destructive, action: onDelete) { HStack { HStack { Image(systemName: "trash") @@ -161,81 +323,10 @@ struct iOSTorrentDetail: View { Spacer() } } - }) - } - } - .task(id: torrent.id) { - await loadSupplementalData(for: torrent.id) - } - .toolbar { - // Use shared toolbar - TorrentDetailToolbar(torrent: torrent, store: store) - } - .alert("Delete Torrent", isPresented: $showingDeleteConfirmation) { - Button(role: .destructive) { - performDelete(deleteLocalData: true) - } label: { - Text("Delete file(s)") - } - Button("Remove from list only") { - performDelete(deleteLocalData: false) + } } - Button("Cancel", role: .cancel) { } - } message: { - Text("Do you want to delete the file(s) from the disk?") } - .transmissionErrorAlert(isPresented: $showingDeleteError, message: deleteErrorMessage) } - - } - - private func performDelete(deleteLocalData: Bool) { - performTransmissionAction( - operation: { - try await store.removeTorrents( - ids: [torrent.id], - deleteLocalData: deleteLocalData - ) - }, - onSuccess: { - dismiss() - }, - onError: makeTransmissionBindingErrorHandler( - isPresented: $showingDeleteError, - message: $deleteErrorMessage - ) - ) - } - - @MainActor - private func loadSupplementalData(for torrentID: Int) async { - guard let snapshot = await performStructuredTransmissionOperation( - operation: { try await store.loadTorrentDetail(id: torrentID) }, - onError: { message in - deleteErrorMessage = message - showingDeleteError = true - } - ) else { - return - } - - apply(snapshot: snapshot) - } - - private func apply(snapshot: TransmissionTorrentDetailSnapshot) { - files = snapshot.files - fileStats = snapshot.fileStats - peers = snapshot.peers - peersFrom = snapshot.peersFrom - pieceCount = snapshot.pieceCount - pieceSize = snapshot.pieceSize - piecesBitfield = snapshot.piecesBitfieldBase64 - - let haveSet = decodePiecesBitfield( - base64String: snapshot.piecesBitfieldBase64, - pieceCount: snapshot.pieceCount - ) - piecesHaveCount = haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } } } diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index 0805d02..dcc856c 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -8,68 +8,256 @@ struct macOSTorrentDetail: View { @ObservedObject var store: TransmissionStore var torrent: Torrent - @State public var files: [TorrentFile] = [] - @State private var fileStats: [TorrentFileStats] = [] + @StateObject private var supplementalStore = TorrentDetailSupplementalStore() @State private var isShowingFilesSheet = false - @State private var peers: [Peer] = [] - @State private var peersFrom: PeersFrom? @State private var isShowingPeersSheet = false @State private var showingDeleteConfirmation = false - @State private var showingDeleteError = false - @State private var deleteErrorMessage = "" - @State private var pieceCount: Int = 0 - @State private var pieceSize: Int64 = 0 - @State private var piecesBitfield: String = "" - @State private var piecesHaveCount: Int = 0 + @State private var showingError = false + @State private var errorMessage = "" + + private var supplementalPayload: TorrentDetailSupplementalPayload { + supplementalStore.payload + } + + private var shouldDisplaySupplementalPayload: Bool { + supplementalStore.shouldDisplayPayload + } var body: some View { - // Use shared formatting function let details = formatTorrentDetails(torrent: torrent) + MacOSTorrentDetailContent( + torrent: torrent, + details: details, + supplementalPayload: supplementalPayload, + onShowFiles: { isShowingFilesSheet = true }, + onShowPeers: { isShowingPeersSheet = true }, + onDelete: { showingDeleteConfirmation = true } + ) + .sheet(isPresented: $isShowingFilesSheet) { + let totalSizeFormatted = formatByteCount(supplementalPayload.files.reduce(0) { $0 + $1.length }) + + VStack(spacing: 0) { + // Header with proper hierarchy + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Files") + .font(.title2) + .fontWeight(.semibold) + + Text("\(torrent.name) • \(supplementalPayload.files.count) files • \(totalSizeFormatted)") + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + Button("Done") { + isShowingFilesSheet = false + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + + Divider() + + filesSheetContent + } + .frame(minWidth: 1000, minHeight: 800) + } + .sheet(isPresented: $isShowingPeersSheet) { + peersSheetContent + .frame(minWidth: 1000, minHeight: 700) + } + .task(id: torrent.id) { + await loadSupplementalData(for: torrent.id) + } + .toolbar { + // Use shared toolbar + TorrentDetailToolbar(torrent: torrent, store: store) + } + .alert("Delete Torrent", isPresented: $showingDeleteConfirmation) { + Button(role: .destructive) { + performDelete(deleteLocalData: true) + } label: { + Text("Delete file(s)") + } + Button("Remove from list only") { + performDelete(deleteLocalData: false) + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Do you want to delete the file(s) from the disk?") + } + .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) + } + + @MainActor + private func loadSupplementalData(for torrentID: Int) async { + await supplementalStore.load(for: torrentID, using: store) { message in + errorMessage = message + showingError = true + } + } + + @MainActor + private func loadSupplementalDataIfNeeded(for torrentID: Int) async { + await supplementalStore.loadIfNeeded(for: torrentID, using: store) { message in + errorMessage = message + showingError = true + } + } + + private func performDelete(deleteLocalData: Bool) { + performTransmissionAction( + operation: { + try await store.removeTorrents( + ids: [torrent.id], + deleteLocalData: deleteLocalData + ) + }, + onSuccess: { + dismiss() + }, + onError: makeTransmissionBindingErrorHandler( + isPresented: $showingError, + message: $errorMessage + ) + ) + } + + @ViewBuilder + private var filesSheetContent: some View { + if shouldDisplaySupplementalPayload { + macOSTorrentFileDetail( + files: supplementalPayload.files, + fileStats: supplementalPayload.fileStats, + torrentId: torrent.id, + store: store + ) + } else { + switch supplementalStore.status { + case .idle: + TorrentDetailLoadingPlaceholderView( + title: "Loading Files", + message: "Fetching the latest files for this torrent." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + await loadSupplementalDataIfNeeded(for: torrent.id) + } + case .loading: + TorrentDetailLoadingPlaceholderView( + title: "Loading Files", + message: "Fetching the latest files for this torrent." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed: + TorrentDetailUnavailablePlaceholderView( + title: "Files Unavailable", + message: "The latest file details could not be loaded." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + await loadSupplementalDataIfNeeded(for: torrent.id) + } + case .loaded: + EmptyView() + } + } + } + + @ViewBuilder + private var peersSheetContent: some View { + if shouldDisplaySupplementalPayload { + macOSTorrentPeerDetail( + torrentName: torrent.name, + torrentId: torrent.id, + store: store, + peers: supplementalPayload.peers, + peersFrom: supplementalPayload.peersFrom, + onRefresh: { await loadSupplementalData(for: torrent.id) }, + onDone: { isShowingPeersSheet = false } + ) + } else { + switch supplementalStore.status { + case .idle: + TorrentDetailLoadingPlaceholderView( + title: "Loading Peers", + message: "Fetching the latest peers for this torrent." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + await loadSupplementalDataIfNeeded(for: torrent.id) + } + case .loading: + TorrentDetailLoadingPlaceholderView( + title: "Loading Peers", + message: "Fetching the latest peers for this torrent." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed: + TorrentDetailUnavailablePlaceholderView( + title: "Peers Unavailable", + message: "The latest peer details could not be loaded." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + await loadSupplementalDataIfNeeded(for: torrent.id) + } + case .loaded: + EmptyView() + } + } + } +} + +private struct MacOSTorrentDetailContent: View { + let torrent: Torrent + let details: TorrentDetailsDisplay + let supplementalPayload: TorrentDetailSupplementalPayload + let onShowFiles: () -> Void + let onShowPeers: () -> Void + let onDelete: () -> Void + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { TorrentDetailHeaderView(torrent: torrent) .padding(.bottom, 4) - // General section GroupBox { VStack(alignment: .leading, spacing: 12) { - // Native macOS section header macOSSectionHeader("General", icon: "info.circle") - DetailRow(label: "Name", value: torrent.name) - DetailRow(label: "Status") { TorrentStatusBadge(torrent: torrent) } - DetailRow(label: "Date Added", value: details.addedDate) - DetailRow(label: "Files") { - Button { - isShowingFilesSheet = true - } label: { + Button(action: onShowFiles) { HStack(spacing: 4) { Image(systemName: "document") .font(.system(size: 12)) .foregroundColor(.accentColor) - Text("\(files.count)") + Text("\(supplementalPayload.files.count)") .foregroundColor(.accentColor) } } .buttonStyle(.bordered) .help("View files in this torrent") } - DetailRow(label: "Peers") { - Button { - isShowingPeersSheet = true - } label: { + Button(action: onShowPeers) { HStack(spacing: 4) { Image(systemName: "person.2") .font(.system(size: 12)) .foregroundColor(.accentColor) - Text("\(peers.count)") + Text("\(supplementalPayload.peers.count)") .foregroundColor(.accentColor) } } @@ -82,12 +270,9 @@ struct macOSTorrentDetail: View { } .padding(.bottom, 8) - // Stats section GroupBox { VStack(alignment: .leading, spacing: 10) { - // Native macOS section header macOSSectionHeader("Stats", icon: "chart.bar") - DetailRow(label: "Size When Done", value: details.sizeWhenDoneFormatted) DetailRow(label: "Progress", value: details.percentComplete) DetailRow(label: "Downloaded", value: details.downloadedFormatted) @@ -99,18 +284,23 @@ struct macOSTorrentDetail: View { } .padding(.bottom, 8) - // Pieces section - if pieceCount > 0 && !piecesBitfield.isEmpty { + if supplementalPayload.pieceCount > 0 && !supplementalPayload.piecesBitfieldBase64.isEmpty { GroupBox { VStack(alignment: .leading, spacing: 10) { macOSSectionHeader("Pieces", icon: "square.grid.2x2") VStack(alignment: .leading, spacing: 8) { - PiecesGridView(pieceCount: pieceCount, piecesBitfieldBase64: piecesBitfield) - .frame(maxWidth: .infinity) - Text("\(piecesHaveCount) of \(pieceCount) pieces • \(formatByteCount(pieceSize)) each") - .font(.caption) - .foregroundColor(.secondary) + PiecesGridView( + pieceCount: supplementalPayload.pieceCount, + piecesBitfieldBase64: supplementalPayload.piecesBitfieldBase64 + ) + .frame(maxWidth: .infinity) + + Text( + "\(supplementalPayload.piecesHaveCount) of \(supplementalPayload.pieceCount) pieces • \(formatByteCount(supplementalPayload.pieceSize)) each" + ) + .font(.caption) + .foregroundColor(.secondary) } } .padding(.vertical, 16) @@ -119,12 +309,9 @@ struct macOSTorrentDetail: View { .padding(.bottom, 8) } - // Additional Info section GroupBox { VStack(alignment: .leading, spacing: 10) { - // Native macOS section header macOSSectionHeader("Additional Info", icon: "doc.text") - DetailRow(label: "Availability", value: details.percentAvailable) DetailRow(label: "Last Activity", value: details.activityDate) } @@ -133,14 +320,11 @@ struct macOSTorrentDetail: View { } .padding(.bottom, 8) - // Beautiful Dedicated Labels Section (Display Only) if !torrent.labels.isEmpty { GroupBox { VStack(alignment: .leading, spacing: 16) { - // Native macOS section header macOSSectionHeader("Labels", icon: "tag") - // Labels display FlowLayout(spacing: 6) { ForEach(torrent.labels.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, id: \.self) { label in DetailViewLabelTag(label: label, isLarge: false) @@ -154,136 +338,16 @@ struct macOSTorrentDetail: View { .padding(.bottom, 8) } - // Actions HStack { Spacer() - Button(role: .destructive, action: { - showingDeleteConfirmation = true - }, label: { + Button(role: .destructive, action: onDelete) { Label("Delete…", systemImage: "trash") - }) + } } .padding(.top, 8) } .padding(20) } - .sheet(isPresented: $isShowingFilesSheet) { - let totalSizeFormatted = formatByteCount(files.reduce(0) { $0 + $1.length }) - - VStack(spacing: 0) { - // Header with proper hierarchy - VStack(alignment: .leading, spacing: 4) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Files") - .font(.title2) - .fontWeight(.semibold) - - Text("\(torrent.name) • \(files.count) files • \(totalSizeFormatted)") - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer() - - Button("Done") { - isShowingFilesSheet = false - } - } - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - - Divider() - - macOSTorrentFileDetail(files: files, fileStats: fileStats, torrentId: torrent.id, store: store) - } - .frame(minWidth: 1000, minHeight: 800) - } - .sheet(isPresented: $isShowingPeersSheet) { - macOSTorrentPeerDetail( - torrentName: torrent.name, - torrentId: torrent.id, - store: store, - peers: peers, - peersFrom: peersFrom, - onRefresh: { await loadSupplementalData(for: torrent.id) }, - onDone: { isShowingPeersSheet = false } - ) - .frame(minWidth: 1000, minHeight: 700) - } - .task(id: torrent.id) { - await loadSupplementalData(for: torrent.id) - } - .toolbar { - // Use shared toolbar - TorrentDetailToolbar(torrent: torrent, store: store) - } - .alert("Delete Torrent", isPresented: $showingDeleteConfirmation) { - Button(role: .destructive) { - performDelete(deleteLocalData: true) - } label: { - Text("Delete file(s)") - } - Button("Remove from list only") { - performDelete(deleteLocalData: false) - } - Button("Cancel", role: .cancel) { } - } message: { - Text("Do you want to delete the file(s) from the disk?") - } - .transmissionErrorAlert(isPresented: $showingDeleteError, message: deleteErrorMessage) - } - - @MainActor - private func loadSupplementalData(for torrentID: Int) async { - guard let snapshot = await performStructuredTransmissionOperation( - operation: { try await store.loadTorrentDetail(id: torrentID) }, - onError: { message in - deleteErrorMessage = message - showingDeleteError = true - } - ) else { - return - } - - apply(snapshot: snapshot) - } - - private func performDelete(deleteLocalData: Bool) { - performTransmissionAction( - operation: { - try await store.removeTorrents( - ids: [torrent.id], - deleteLocalData: deleteLocalData - ) - }, - onSuccess: { - dismiss() - }, - onError: makeTransmissionBindingErrorHandler( - isPresented: $showingDeleteError, - message: $deleteErrorMessage - ) - ) - } - - private func apply(snapshot: TransmissionTorrentDetailSnapshot) { - files = snapshot.files - fileStats = snapshot.fileStats - peers = snapshot.peers - peersFrom = snapshot.peersFrom - pieceCount = snapshot.pieceCount - pieceSize = snapshot.pieceSize - piecesBitfield = snapshot.piecesBitfieldBase64 - - let haveSet = decodePiecesBitfield( - base64String: snapshot.piecesBitfieldBase64, - pieceCount: snapshot.pieceCount - ) - piecesHaveCount = haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } } } diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift index 71f2253..e1856f1 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift @@ -10,6 +10,35 @@ final class TransmissionStoreSessionOperationTests: XCTestCase { let args: TransmissionSessionSetRequestArgs } + func testApplySessionSettingsWaitsForActivationInFlight() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/updated", version: "4.0.1")) + ], + "session-set": [ + .http(statusCode: 200, body: successEmptyBody) + ] + ]) + let sleepController = ScriptedSleep(steps: [.suspend]) + let store = makeStore(sender: sender, sleepController: sleepController) + var args = TransmissionSessionSetRequestArgs() + args.downloadDir = "/downloads/updated" + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let refreshed = try await store.applySessionSettings(args) + + XCTAssertEqual(refreshed.downloadDir, "/downloads/updated") + XCTAssertEqual(store.sessionConfiguration?.downloadDir, "/downloads/updated") + XCTAssertEqual(store.defaultDownloadDir, "/downloads/updated") + } + func testApplySessionSettingsRefreshesSessionConfiguration() async throws { let sender = MethodQueueSender(stepsByMethod: [ "session-stats": [ @@ -102,6 +131,56 @@ final class TransmissionStoreSessionOperationTests: XCTestCase { XCTAssertEqual(response.blocklistSize, 42) XCTAssertEqual(store.sessionConfiguration?.blocklistSize, 42) } + + func testWaitingOperationCancelsWhenActivationIsSupersededByHostSwitch() async throws { + let sender = HostMethodScriptedSender(stepsByHostAndMethod: [ + "new.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/new", version: "5.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/newer", version: "5.0.1")) + ], + "session-set": [ + .http(statusCode: 200, body: successEmptyBody) + ] + ] + ]) + let sleepController = ScriptedSleep(steps: [.suspend]) + let resolver = BlockingConnectionResolver(blockedHost: "old.example.com") + let store = makeStore( + sender: sender, + sleepController: sleepController, + resolveConnection: { descriptor, factory in + try await resolver.resolve(descriptor, using: factory) + } + ) + var args = TransmissionSessionSetRequestArgs() + args.downloadDir = "/downloads/newer" + + store.setHost(host: makeHost(serverID: "server-1", server: "old.example.com")) + + let task = Task { @MainActor in + try await store.applySessionSettings(args) + } + + await Task.yield() + store.setHost(host: makeHost(serverID: "server-2", server: "new.example.com")) + + let switchedHosts = await didSwitchHosts(store) + XCTAssertTrue(switchedHosts) + + do { + _ = try await task.value + XCTFail("Expected waiting operation to be cancelled") + } catch { + XCTAssertNil(TransmissionUserFacingError.presentation(for: error)) + } + } } private extension TransmissionStoreSessionOperationTests { @@ -156,7 +235,8 @@ private extension TransmissionStoreSessionOperationTests { func makeStore( sender: some TransmissionRPCRequestSending, - sleepController: ScriptedSleep + sleepController: ScriptedSleep, + resolveConnection: (@Sendable (TransmissionConnectionDescriptor, TransmissionConnectionFactory) async throws -> TransmissionConnection)? = nil ) -> TransmissionStore { let factory = TransmissionConnectionFactory( transport: TransmissionTransport(sender: sender), @@ -169,9 +249,18 @@ private extension TransmissionStoreSessionOperationTests { } }) ) + let resolvedConnection: (@Sendable (TransmissionConnectionDescriptor) async throws -> TransmissionConnection)? + if let resolveConnection { + resolvedConnection = { descriptor in + try await resolveConnection(descriptor, factory) + } + } else { + resolvedConnection = nil + } return TransmissionStore( connectionFactory: factory, + resolveConnection: resolvedConnection, snapshotWriter: WidgetSnapshotWriter( writeServerIndex: { _ in }, writeSessionSnapshot: { _, _, _, _, _ in }, @@ -213,6 +302,25 @@ private extension TransmissionStoreSessionOperationTests { } } +private actor BlockingConnectionResolver { + let blockedHost: String + + init(blockedHost: String) { + self.blockedHost = blockedHost + } + + func resolve( + _ descriptor: TransmissionConnectionDescriptor, + using factory: TransmissionConnectionFactory + ) async throws -> TransmissionConnection { + if descriptor.host == blockedHost { + try await Task.sleep(nanoseconds: .max) + } + + return try await factory.connection(for: descriptor) + } +} + private actor ScriptedSleep { enum Step { case immediate diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift index a8e60ef..1f6dd12 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift @@ -4,6 +4,51 @@ import XCTest @MainActor final class TransmissionStoreTorrentOperationTests: XCTestCase { + func testAddTorrentWaitsForActivationInFlight() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody), + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ], + "torrent-add": [ + .http(statusCode: 200, body: makeTorrentAddSuccessBody()) + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let outcome = try await store.addTorrent( + magnetLink: "magnet:?xt=urn:btih:1234567890abcdef", + saveLocation: "/downloads/initial" + ) + + guard case .added(let torrent) = outcome else { + return XCTFail("Expected added torrent outcome") + } + + XCTAssertEqual(torrent.name, "Ubuntu.iso") + + let didRefresh = await waitUntil { + let requests = await sender.capturedRequests() + return requests.count == 7 && store.connectionStatus == .connected + } + XCTAssertTrue(didRefresh) + + let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } + XCTAssertEqual(methods.filter { $0 == "torrent-add" }.count, 1) + XCTAssertEqual(methods.filter { $0 == "session-stats" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "session-get" }.count, 2) + } + func testRemoveTorrentsSchedulesRefreshAfterSuccess() async throws { let sender = MethodQueueSender(stepsByMethod: [ "session-stats": [ @@ -436,6 +481,21 @@ private func makeTorrentDetailSuccessBody() -> String { """ } +private func makeTorrentAddSuccessBody() -> String { + """ + { + "arguments": { + "torrent-added": { + "hashString": "abcdef1234567890", + "id": 99, + "name": "Ubuntu.iso" + } + }, + "result": "success" + } + """ +} + private func rpcFailureBody(result: String) -> String { """ { diff --git a/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift b/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift new file mode 100644 index 0000000..f2ac92a --- /dev/null +++ b/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift @@ -0,0 +1,250 @@ +import XCTest +@testable import BitDream + +final class TorrentDetailSupplementalStateTests: XCTestCase { + func testBeginLoadingClearsPayloadForNewTorrentAndTracksActiveTorrent() { + var state = TorrentDetailSupplementalState() + let generation = state.beginLoading(for: 42) + + XCTAssertEqual(state.activeTorrentID, 42) + XCTAssertEqual(state.activeRequestGeneration, generation) + XCTAssertEqual(state.status, .loading) + XCTAssertEqual(state.payload, .empty) + XCTAssertFalse(state.shouldDisplayPayload) + } + + func testApplySnapshotPopulatesPayloadAndPieceCount() { + var state = TorrentDetailSupplementalState() + let snapshot = makeSnapshot( + pieceCount: 3, + piecesBitfieldBase64: Data([0b1010_0000]).base64EncodedString() + ) + + let generation = state.beginLoading(for: 7) + let didApply = state.apply(snapshot: snapshot, for: 7, generation: generation) + + XCTAssertTrue(didApply) + XCTAssertEqual(state.status, .loaded) + XCTAssertEqual(state.payload.files, snapshot.files) + XCTAssertEqual(state.payload.fileStats, snapshot.fileStats) + XCTAssertEqual(state.payload.peers, snapshot.peers) + XCTAssertEqual(state.payload.peersFrom, snapshot.peersFrom) + XCTAssertEqual(state.payload.pieceCount, 3) + XCTAssertEqual(state.payload.piecesHaveCount, 2) + XCTAssertTrue(state.shouldDisplayPayload) + } + + func testApplySnapshotIgnoresStaleRequest() { + var state = TorrentDetailSupplementalState() + let oldSnapshot = makeSnapshot(fileName: "old-file") + let newSnapshot = makeSnapshot(fileName: "new-file") + + let oldGeneration = state.beginLoading(for: 1) + XCTAssertTrue(state.apply(snapshot: oldSnapshot, for: 1, generation: oldGeneration)) + + let newGeneration = state.beginLoading(for: 2) + let didApplyStaleSnapshot = state.apply( + snapshot: oldSnapshot, + for: 1, + generation: oldGeneration + ) + let didApplyCurrentSnapshot = state.apply( + snapshot: newSnapshot, + for: 2, + generation: newGeneration + ) + + XCTAssertFalse(didApplyStaleSnapshot) + XCTAssertTrue(didApplyCurrentSnapshot) + XCTAssertEqual(state.activeTorrentID, 2) + XCTAssertEqual(state.activeRequestGeneration, newGeneration) + XCTAssertEqual(state.status, .loaded) + XCTAssertEqual(state.payload.files.map(\.name), ["new-file"]) + XCTAssertTrue(state.shouldDisplayPayload) + } + + func testBeginLoadingForSameTorrentPreservesLoadedPayload() { + var state = TorrentDetailSupplementalState() + let snapshot = makeSnapshot() + + let firstGeneration = state.beginLoading(for: 11) + XCTAssertTrue(state.apply(snapshot: snapshot, for: 11, generation: firstGeneration)) + + let secondGeneration = state.beginLoading(for: 11) + + XCTAssertEqual(state.activeTorrentID, 11) + XCTAssertEqual(state.activeRequestGeneration, secondGeneration) + XCTAssertNotEqual(firstGeneration, secondGeneration) + XCTAssertEqual(state.status, .loading) + XCTAssertEqual(state.payload.files, snapshot.files) + XCTAssertTrue(state.shouldDisplayPayload) + } + + func testBeginLoadingForDifferentTorrentClearsLoadedPayload() { + var state = TorrentDetailSupplementalState() + let snapshot = makeSnapshot() + + let firstGeneration = state.beginLoading(for: 11) + XCTAssertTrue(state.apply(snapshot: snapshot, for: 11, generation: firstGeneration)) + + let secondGeneration = state.beginLoading(for: 12) + + XCTAssertEqual(state.activeTorrentID, 12) + XCTAssertEqual(state.activeRequestGeneration, secondGeneration) + XCTAssertEqual(state.status, .loading) + XCTAssertEqual(state.payload, .empty) + XCTAssertFalse(state.shouldDisplayPayload) + } + + func testMarkFailedPreservesPayloadForActiveRequest() { + var state = TorrentDetailSupplementalState() + let snapshot = makeSnapshot() + + let generation = state.beginLoading(for: 11) + XCTAssertTrue(state.apply(snapshot: snapshot, for: 11, generation: generation)) + + let didMarkFailure = state.markFailed(for: 11, generation: generation) + + XCTAssertTrue(didMarkFailure) + XCTAssertEqual(state.status, .failed) + XCTAssertEqual(state.payload.files, snapshot.files) + XCTAssertTrue(state.shouldDisplayPayload) + } + + func testMarkFailedIgnoresStaleRequest() { + var state = TorrentDetailSupplementalState() + + let staleGeneration = state.beginLoading(for: 3) + let currentGeneration = state.beginLoading(for: 4) + + let didMarkFailure = state.markFailed(for: 3, generation: staleGeneration) + + XCTAssertFalse(didMarkFailure) + XCTAssertEqual(state.activeTorrentID, 4) + XCTAssertEqual(state.activeRequestGeneration, currentGeneration) + XCTAssertEqual(state.status, .loading) + XCTAssertEqual(state.payload, .empty) + XCTAssertFalse(state.shouldDisplayPayload) + } + + func testApplySnapshotRecoversAfterFailureWhileRetainingPayload() { + var state = TorrentDetailSupplementalState() + let oldSnapshot = makeSnapshot(fileName: "old-file") + let newSnapshot = makeSnapshot(fileName: "new-file") + + let firstGeneration = state.beginLoading(for: 7) + XCTAssertTrue(state.apply(snapshot: oldSnapshot, for: 7, generation: firstGeneration)) + XCTAssertTrue(state.markFailed(for: 7, generation: firstGeneration)) + let secondGeneration = state.beginLoading(for: 7) + + let didApply = state.apply(snapshot: newSnapshot, for: 7, generation: secondGeneration) + + XCTAssertTrue(didApply) + XCTAssertEqual(state.status, .loaded) + XCTAssertEqual(state.payload.files.map(\.name), ["new-file"]) + XCTAssertTrue(state.shouldDisplayPayload) + } + + func testApplySnapshotIgnoresOlderGenerationForSameTorrent() { + var state = TorrentDetailSupplementalState() + let olderSnapshot = makeSnapshot(fileName: "older-file") + let newerSnapshot = makeSnapshot(fileName: "newer-file") + + let olderGeneration = state.beginLoading(for: 9) + let newerGeneration = state.beginLoading(for: 9) + + let didApplyOlderSnapshot = state.apply( + snapshot: olderSnapshot, + for: 9, + generation: olderGeneration + ) + let didApplyNewerSnapshot = state.apply( + snapshot: newerSnapshot, + for: 9, + generation: newerGeneration + ) + + XCTAssertFalse(didApplyOlderSnapshot) + XCTAssertTrue(didApplyNewerSnapshot) + XCTAssertEqual(state.activeTorrentID, 9) + XCTAssertEqual(state.activeRequestGeneration, newerGeneration) + XCTAssertEqual(state.status, .loaded) + XCTAssertEqual(state.payload.files.map(\.name), ["newer-file"]) + XCTAssertTrue(state.shouldDisplayPayload) + } + + func testMarkFailedIgnoresOlderGenerationForSameTorrent() { + var state = TorrentDetailSupplementalState() + let snapshot = makeSnapshot(fileName: "retained-file") + + let initialGeneration = state.beginLoading(for: 13) + XCTAssertTrue(state.apply(snapshot: snapshot, for: 13, generation: initialGeneration)) + + let staleGeneration = state.beginLoading(for: 13) + let currentGeneration = state.beginLoading(for: 13) + + let didMarkStaleFailure = state.markFailed(for: 13, generation: staleGeneration) + + XCTAssertFalse(didMarkStaleFailure) + XCTAssertEqual(state.activeTorrentID, 13) + XCTAssertEqual(state.activeRequestGeneration, currentGeneration) + XCTAssertEqual(state.status, .loading) + XCTAssertEqual(state.payload.files.map(\.name), ["retained-file"]) + XCTAssertTrue(state.shouldDisplayPayload) + } +} + +private func makeSnapshot( + fileName: String = "sample-file", + pieceCount: Int = 1, + piecesBitfieldBase64: String = Data([0b1000_0000]).base64EncodedString() +) -> TransmissionTorrentDetailSnapshot { + TransmissionTorrentDetailSnapshot( + files: [ + TorrentFile( + bytesCompleted: 512, + length: 1024, + name: fileName + ) + ], + fileStats: [ + TorrentFileStats( + bytesCompleted: 512, + wanted: true, + priority: 0 + ) + ], + peers: [ + Peer( + address: "127.0.0.1", + clientName: "BitDreamTests", + clientIsChoked: false, + clientIsInterested: true, + flagStr: "D", + isDownloadingFrom: true, + isEncrypted: false, + isIncoming: false, + isUploadingTo: false, + isUTP: false, + peerIsChoked: false, + peerIsInterested: true, + port: 51413, + progress: 0.5, + rateToClient: 100, + rateToPeer: 0 + ) + ], + peersFrom: PeersFrom( + fromCache: 0, + fromDht: 1, + fromIncoming: 0, + fromLpd: 0, + fromLtep: 0, + fromPex: 0, + fromTracker: 1 + ), + pieceCount: pieceCount, + pieceSize: 1024, + piecesBitfieldBase64: piecesBitfieldBase64 + ) +} From 72653d90b9a1a50624a581a17095b26ea49c2639 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 8 Mar 2026 12:42:07 -0700 Subject: [PATCH 3/8] propagate activation failures to waiting ops Introduce ActivationFailure to record activation errors (generation + TransmissionError). Store activationFailure when connection activation fails (except cancellations) and clear it on reset or successful activation. Waiting operations now check for a matching activationFailure and throw it so they surface activation errors immediately. Adds a unit test (testWaitingOperationSurfacesCurrentActivationFailure) to verify unauthorized activation errors are propagated and reconnect state is entered. --- BitDream/TransmissionStore.swift | 25 ++++++++++++ ...ansmissionStoreSessionOperationTests.swift | 38 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/BitDream/TransmissionStore.swift b/BitDream/TransmissionStore.swift index abd7a96..a7fd40e 100644 --- a/BitDream/TransmissionStore.swift +++ b/BitDream/TransmissionStore.swift @@ -24,6 +24,11 @@ final class TransmissionStore: NSObject, ObservableObject { let generation: UUID } + private struct ActivationFailure: Sendable { + let generation: UUID + let error: TransmissionError + } + private enum ConnectionAttemptReason { case hostSelection case hostConfigurationChange @@ -132,6 +137,7 @@ final class TransmissionStore: NSObject, ObservableObject { private let persistVersion: @MainActor @Sendable (String, String) async -> Void private var activeConnection: ActiveConnection? + private var activationFailure: ActivationFailure? private var currentConnectionGeneration = UUID() private var activationTask: Task? private var fullRefreshTask: Task? @@ -665,6 +671,7 @@ extension TransmissionStore { resetReconnectBackoff() } + activationFailure = nil torrents = [] sessionStats = nil sessionConfiguration = nil @@ -691,9 +698,22 @@ extension TransmissionStore { connection: connection, generation: generation ) + activationFailure = nil activeConnection = connectionState await performFullRefresh(for: connectionState) } catch { + let transmissionError = TransmissionErrorResolver.transmissionError(from: error) + if case .cancelled = transmissionError { + return + } + + if isCurrentGeneration(generation, hostID: host.serverID) { + activationFailure = ActivationFailure( + generation: generation, + error: transmissionError + ) + } + handleReadError(error, generation: generation) } } @@ -874,6 +894,11 @@ extension TransmissionStore { throw CancellationError() } + if let activationFailure, + activationFailure.generation == waitingGeneration { + throw activationFailure.error + } + guard let activeConnection else { throw CancellationError() } diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift index e1856f1..6871552 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift @@ -181,6 +181,44 @@ final class TransmissionStoreSessionOperationTests: XCTestCase { XCTAssertNil(TransmissionUserFacingError.presentation(for: error)) } } + + func testWaitingOperationSurfacesCurrentActivationFailure() async throws { + let sender = MethodQueueSender(stepsByMethod: [:]) + let sleepController = ScriptedSleep(steps: [.suspend]) + let store = makeStore( + sender: sender, + sleepController: sleepController, + resolveConnection: { descriptor, _ in + guard descriptor.host == "example.com" else { + throw TransmissionError.invalidEndpointConfiguration + } + throw TransmissionError.unauthorized + } + ) + var args = TransmissionSessionSetRequestArgs() + args.downloadDir = "/downloads/updated" + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + + do { + _ = try await store.applySessionSettings(args) + XCTFail("Expected waiting operation to surface activation failure") + } catch let error as TransmissionError { + guard case .unauthorized = error else { + return XCTFail("Expected unauthorized error, got \(error)") + } + let presentation = try XCTUnwrap(TransmissionUserFacingError.presentation(for: error)) + XCTAssertEqual(presentation.title, "Authentication Failed") + } catch { + XCTFail("Expected TransmissionError.unauthorized, got \(error)") + } + + let didEnterReconnectState = await waitUntil { + store.connectionStatus == .reconnecting + && store.lastErrorMessage == "Authentication failed. Please check your server credentials." + } + XCTAssertTrue(didEnterReconnectState) + } } private extension TransmissionStoreSessionOperationTests { From 379a776fdff49d0fb7fbbaf2a1a11d7076deea59 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 8 Mar 2026 13:42:16 -0700 Subject: [PATCH 4/8] surface stored activation errors; refactor state Handle stored activation failures in TransmissionStore by surfacing saved activation errors when applicable. Refactor TorrentDetailSupplementalState to require a torrent ID for visibility checks, add visiblePayload(for:) and markCancelled(for:generation:) to manage cancellation behavior, and adapt the TorrentDetailSupplementalStore to use these APIs. Update iOS/macOS torrent detail views to use per-torrent payload accessors. Rework iOSTorrentFileDetail to centralize optimistic updates, add snapshot/revert helpers, and expose bulk setBulkWanted/setBulkPriority operations; update BulkActionToolbar accordingly. Add tests for stored unauthorized activation failure and expand supplemental state tests to cover cancellation and visibility semantics. --- BitDream/TransmissionStore.swift | 6 + BitDream/Views/Shared/TorrentDetail.swift | 44 +++- BitDream/Views/iOS/iOSTorrentDetail.swift | 4 +- BitDream/Views/iOS/iOSTorrentFileDetail.swift | 199 +++++++++--------- BitDream/Views/macOS/macOSTorrentDetail.swift | 4 +- ...ansmissionStoreTorrentOperationTests.swift | 69 ++++++ .../TorrentDetailSupplementalStateTests.swift | 84 +++++++- 7 files changed, 294 insertions(+), 116 deletions(-) diff --git a/BitDream/TransmissionStore.swift b/BitDream/TransmissionStore.swift index a7fd40e..2f3d913 100644 --- a/BitDream/TransmissionStore.swift +++ b/BitDream/TransmissionStore.swift @@ -907,6 +907,12 @@ extension TransmissionStore { return activeConnection } + if let activationFailure, + activationFailure.generation == waitingGeneration, + isCurrentGeneration(waitingGeneration, hostID: waitingHostID) { + throw activationFailure.error + } + throw CancellationError() } diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index de0216f..f5aa2aa 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -90,8 +90,12 @@ internal struct TorrentDetailSupplementalState: Sendable { private(set) var payload: TorrentDetailSupplementalPayload = .empty private(set) var hasLoadedPayload = false - var shouldDisplayPayload: Bool { - hasLoadedPayload + func shouldDisplayPayload(for torrentID: Int) -> Bool { + activeTorrentID == torrentID && hasLoadedPayload + } + + func visiblePayload(for torrentID: Int) -> TorrentDetailSupplementalPayload { + shouldDisplayPayload(for: torrentID) ? payload : .empty } @discardableResult @@ -132,6 +136,20 @@ internal struct TorrentDetailSupplementalState: Sendable { status = .failed return true } + + @discardableResult + mutating func markCancelled(for torrentID: Int, generation: Int) -> Bool { + guard activeTorrentID == torrentID, activeRequestGeneration == generation else { + return false + } + + guard status == .loading else { + return false + } + + status = hasLoadedPayload ? .loaded : .idle + return true + } } @MainActor @@ -146,8 +164,12 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { state.status } - var shouldDisplayPayload: Bool { - state.shouldDisplayPayload + func payload(for torrentID: Int) -> TorrentDetailSupplementalPayload { + state.visiblePayload(for: torrentID) + } + + func shouldDisplayPayload(for torrentID: Int) -> Bool { + state.shouldDisplayPayload(for: torrentID) } func load( @@ -169,6 +191,7 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { onError(message) } ) else { + _ = markCancellation(for: torrentID, generation: requestGeneration) return } @@ -186,7 +209,7 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { using store: TransmissionStore, onError: @escaping @MainActor @Sendable (String) -> Void ) async { - guard !state.shouldDisplayPayload else { + guard !state.shouldDisplayPayload(for: torrentID) else { return } @@ -205,6 +228,17 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { return didMarkFailure } + @discardableResult + private func markCancellation(for torrentID: Int, generation: Int) -> Bool { + var nextState = state + let didMarkCancellation = nextState.markCancelled( + for: torrentID, + generation: generation + ) + state = nextState + return didMarkCancellation + } + @discardableResult private func mutateState( _ mutate: (inout TorrentDetailSupplementalState) -> Result diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index e937585..e65dd1c 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -14,11 +14,11 @@ struct iOSTorrentDetail: View { @State private var errorMessage = "" private var supplementalPayload: TorrentDetailSupplementalPayload { - supplementalStore.payload + supplementalStore.payload(for: torrent.id) } private var shouldDisplaySupplementalPayload: Bool { - supplementalStore.shouldDisplayPayload + supplementalStore.shouldDisplayPayload(for: torrent.id) } var body: some View { diff --git a/BitDream/Views/iOS/iOSTorrentFileDetail.swift b/BitDream/Views/iOS/iOSTorrentFileDetail.swift index f7db66b..b219dcc 100644 --- a/BitDream/Views/iOS/iOSTorrentFileDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentFileDetail.swift @@ -50,7 +50,6 @@ struct iOSTorrentFileDetail: View { @State private var sortProperty: FileSortProperty = .name @State private var sortOrder: SortOrder = .ascending - // Filter toggles - same as macOS @State private var showWantedFiles = true @State private var showSkippedFiles = true @State private var showCompleteFiles = true @@ -63,7 +62,6 @@ struct iOSTorrentFileDetail: View { @State private var showOther = true @State private var showFilterSheet = false - // Multi-select state @State private var isEditing = false @State private var selectedFileIds: Set = [] @State private var showingError = false @@ -93,7 +91,6 @@ struct iOSTorrentFileDetail: View { private var filteredAndSortedFileRows: [TorrentFileRow] { let filtered = fileRows.filter { row in - // Search filter if !searchText.isEmpty { let searchLower = searchText.lowercased() if !row.name.lowercased().contains(searchLower) { @@ -101,16 +98,13 @@ struct iOSTorrentFileDetail: View { } } - // Wanted/Skip filter - same logic as macOS if row.wanted && !showWantedFiles { return false } if !row.wanted && !showSkippedFiles { return false } - // Completion filter - same logic as macOS let isComplete = row.percentDone >= 1.0 if isComplete && !showCompleteFiles { return false } if !isComplete && !showIncompleteFiles { return false } - // File type filter - same logic as macOS let fileType = fileTypeCategory(row.name) switch fileType { case .video: if !showVideos { return false } @@ -137,7 +131,6 @@ struct iOSTorrentFileDetail: View { ) } - // File count footer as a List section Section { EmptyView() } footer: { @@ -162,15 +155,8 @@ struct iOSTorrentFileDetail: View { selectedCount: selectedFileIds.count, selectedFileIds: $selectedFileIds, allFileRows: filteredAndSortedFileRows, - torrentId: torrentId, - store: store, - updateFileStatus: updateLocalFileStatus, - updateFilePriority: updateLocalFilePriority, - revertData: revertToOriginalData, - onError: makeTransmissionBindingErrorHandler( - isPresented: $showingError, - message: $errorMessage - ) + setBulkWanted: setBulkWanted, + setBulkPriority: setBulkPriority ) } } @@ -207,11 +193,12 @@ struct iOSTorrentFileDetail: View { } .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) } +} - // MARK: - File Operations - - private func setFileWanted(_ row: TorrentFileRow, wanted: Bool) { - updateLocalFileStatus(fileIndex: row.fileIndex, wanted: wanted) +private extension iOSTorrentFileDetail { + func setFileWanted(_ row: TorrentFileRow, wanted: Bool) { + let previousStats = snapshotStats(for: [row.fileIndex]) + updateLocalFileStatus(fileIndices: [row.fileIndex], wanted: wanted) performTransmissionAction( operation: { @@ -222,15 +209,16 @@ struct iOSTorrentFileDetail: View { ) }, onError: { message in - revertToOriginalData() + revertStats(previousStats) errorMessage = message showingError = true } ) } - private func setFilePriority(_ row: TorrentFileRow, priority: FilePriority) { - updateLocalFilePriority(fileIndex: row.fileIndex, priority: priority) + func setFilePriority(_ row: TorrentFileRow, priority: FilePriority) { + let previousStats = snapshotStats(for: [row.fileIndex]) + updateLocalFilePriority(fileIndices: [row.fileIndex], priority: priority) performTransmissionAction( operation: { @@ -241,64 +229,121 @@ struct iOSTorrentFileDetail: View { ) }, onError: { message in - revertToOriginalData() + revertStats(previousStats) errorMessage = message showingError = true } ) } - // MARK: - Optimistic Updates + func setBulkWanted(fileIndices: [Int], wanted: Bool) { + let previousStats = snapshotStats(for: fileIndices) + updateLocalFileStatus(fileIndices: fileIndices, wanted: wanted) - private func updateLocalFileStatus(fileIndex: Int, wanted: Bool) { - guard fileIndex < mutableFileStats.count else { return } - mutableFileStats[fileIndex] = TorrentFileStats( - bytesCompleted: mutableFileStats[fileIndex].bytesCompleted, - wanted: wanted, - priority: mutableFileStats[fileIndex].priority + performTransmissionAction( + operation: { + try await store.setFileWantedStatus( + torrentId: torrentId, + fileIndices: fileIndices, + wanted: wanted + ) + }, + onError: { message in + revertStats(previousStats) + errorMessage = message + showingError = true + } ) } - private func updateLocalFilePriority(fileIndex: Int, priority: FilePriority) { - guard fileIndex < mutableFileStats.count else { return } - mutableFileStats[fileIndex] = TorrentFileStats( - bytesCompleted: mutableFileStats[fileIndex].bytesCompleted, - wanted: mutableFileStats[fileIndex].wanted, - priority: priority.rawValue + func setBulkPriority(fileIndices: [Int], priority: FilePriority) { + let previousStats = snapshotStats(for: fileIndices) + updateLocalFilePriority(fileIndices: fileIndices, priority: priority) + + performTransmissionAction( + operation: { + try await store.setFilePriority( + torrentId: torrentId, + fileIndices: fileIndices, + priority: priority + ) + }, + onError: { message in + revertStats(previousStats) + errorMessage = message + showingError = true + } ) } - private func revertToOriginalData() { - mutableFileStats = fileStats + func snapshotStats(for fileIndices: [Int]) -> [(index: Int, stats: TorrentFileStats)] { + fileIndices.compactMap { fileIndex in + guard fileIndex < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { + return nil + } + + let currentStats = mutableFileStats.isEmpty ? fileStats[fileIndex] : mutableFileStats[fileIndex] + return (fileIndex, currentStats) + } + } + + func revertStats(_ previousStats: [(index: Int, stats: TorrentFileStats)]) { + if mutableFileStats.isEmpty { + mutableFileStats = fileStats + } + + for (fileIndex, previousStats) in previousStats where fileIndex < mutableFileStats.count { + mutableFileStats[fileIndex] = previousStats + } } -} -// MARK: - Bulk Action Toolbar + func updateLocalFileStatus(fileIndices: [Int], wanted: Bool) { + if mutableFileStats.isEmpty { + mutableFileStats = fileStats + } + + for fileIndex in fileIndices where fileIndex < mutableFileStats.count { + mutableFileStats[fileIndex] = TorrentFileStats( + bytesCompleted: mutableFileStats[fileIndex].bytesCompleted, + wanted: wanted, + priority: mutableFileStats[fileIndex].priority + ) + } + } + + func updateLocalFilePriority(fileIndices: [Int], priority: FilePriority) { + if mutableFileStats.isEmpty { + mutableFileStats = fileStats + } + + for fileIndex in fileIndices where fileIndex < mutableFileStats.count { + mutableFileStats[fileIndex] = TorrentFileStats( + bytesCompleted: mutableFileStats[fileIndex].bytesCompleted, + wanted: mutableFileStats[fileIndex].wanted, + priority: priority.rawValue + ) + } + } +} struct BulkActionToolbar: View { let selectedCount: Int @Binding var selectedFileIds: Set let allFileRows: [TorrentFileRow] - let torrentId: Int - let store: TransmissionStore - let updateFileStatus: (Int, Bool) -> Void - let updateFilePriority: (Int, FilePriority) -> Void - let revertData: () -> Void - let onError: @MainActor @Sendable (String) -> Void + let setBulkWanted: ([Int], Bool) -> Void + let setBulkPriority: ([Int], FilePriority) -> Void var body: some View { VStack(spacing: 0) { Divider() HStack(spacing: 16) { - // Selection count Text("\(selectedCount) selected") .font(.subheadline) .foregroundColor(.secondary) Spacer() - // Select All/Deselect All - selection controls, not actions Button(selectedCount == allFileRows.count ? "Deselect All" : "Select All") { if selectedCount == allFileRows.count { selectedFileIds.removeAll() @@ -308,7 +353,6 @@ struct BulkActionToolbar: View { } .font(.subheadline) - // Actions menu - EXACTLY same as context menu Menu { Section("Status") { Button("Download") { @@ -346,51 +390,17 @@ struct BulkActionToolbar: View { } private func setBulkPriority(_ priority: FilePriority) { - let selectedRows = allFileRows.filter { selectedFileIds.contains($0.id) } - let fileIndices = selectedRows.map { $0.fileIndex } - - // Optimistic updates for all selected files - for fileIndex in fileIndices { - updateFilePriority(fileIndex, priority) - } - - performTransmissionAction( - operation: { - try await store.setFilePriority( - torrentId: torrentId, - fileIndices: fileIndices, - priority: priority - ) - }, - onError: { message in - revertData() - onError(message) - } - ) + let fileIndices = allFileRows + .filter { selectedFileIds.contains($0.id) } + .map(\.fileIndex) + setBulkPriority(fileIndices, priority) } private func setBulkWanted(_ wanted: Bool) { - let selectedRows = allFileRows.filter { selectedFileIds.contains($0.id) } - let fileIndices = selectedRows.map { $0.fileIndex } - - // Optimistic updates for all selected files - for fileIndex in fileIndices { - updateFileStatus(fileIndex, wanted) - } - - performTransmissionAction( - operation: { - try await store.setFileWantedStatus( - torrentId: torrentId, - fileIndices: fileIndices, - wanted: wanted - ) - }, - onError: { message in - revertData() - onError(message) - } - ) + let fileIndices = allFileRows + .filter { selectedFileIds.contains($0.id) } + .map(\.fileIndex) + setBulkWanted(fileIndices, wanted) } } @@ -406,7 +416,6 @@ struct FileActionButtonsView: View { var body: some View { HStack(spacing: 12) { - // Filter button Button { showFilterSheet = true } label: { @@ -422,9 +431,7 @@ struct FileActionButtonsView: View { .cornerRadius(16) } - // Sort menu Menu { - // Sort properties ForEach(FileSortProperty.allCases, id: \.self) { property in Button { sortProperty = property @@ -441,7 +448,6 @@ struct FileActionButtonsView: View { Divider() - // Sort order Button { sortOrder = .ascending } label: { @@ -480,7 +486,6 @@ struct FileActionButtonsView: View { Spacer() - // Edit button - separated on the right Button { withAnimation { isEditing.toggle() diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index dcc856c..c5db438 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -16,11 +16,11 @@ struct macOSTorrentDetail: View { @State private var errorMessage = "" private var supplementalPayload: TorrentDetailSupplementalPayload { - supplementalStore.payload + supplementalStore.payload(for: torrent.id) } private var shouldDisplaySupplementalPayload: Bool { - supplementalStore.shouldDisplayPayload + supplementalStore.shouldDisplayPayload(for: torrent.id) } var body: some View { diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift index 1f6dd12..71237ed 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift @@ -180,6 +180,22 @@ final class TransmissionStoreTorrentOperationTests: XCTestCase { } } + func testTorrentOperationSurfacesStoredActivationFailureAfterActivationCompletes() async throws { + let store = makeUnauthorizedActivationStore() + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + + let didEnterReconnectState = await waitUntil { + store.connectionStatus == .reconnecting + && store.lastErrorMessage == "Authentication failed. Please check your server credentials." + } + XCTAssertTrue(didEnterReconnectState) + + try await assertUnauthorizedStoredActivationFailure(store) + } +} + +@MainActor +extension TransmissionStoreTorrentOperationTests { func testUpdateTorrentLabelsSchedulesRefreshAfterPartialFailure() async throws { let sender = MethodQueueSender(stepsByMethod: [ "session-stats": [ @@ -291,6 +307,59 @@ final class TransmissionStoreTorrentOperationTests: XCTestCase { } private extension TransmissionStoreTorrentOperationTests { + func makeUnauthorizedActivationStore() -> TransmissionStore { + let sender = MethodQueueSender(stepsByMethod: [:]) + let factory = TransmissionConnectionFactory( + transport: TransmissionTransport(sender: sender), + credentialResolver: TransmissionCredentialResolver(resolvePassword: { source in + switch source { + case .resolvedPassword(let password): + return password + case .keychainCredential(let key): + return key == "test-key" ? "secret" : "" + } + }) + ) + + return TransmissionStore( + connectionFactory: factory, + resolveConnection: { descriptor in + guard descriptor.host == "example.com" else { + throw TransmissionError.invalidEndpointConfiguration + } + throw TransmissionError.unauthorized + }, + snapshotWriter: WidgetSnapshotWriter( + writeServerIndex: { _ in }, + writeSessionSnapshot: { _, _, _, _, _ in }, + reloadTimelines: { } + ), + sleep: { _ in + try await Task.sleep(nanoseconds: .max) + }, + persistVersion: { _, _ in } + ) + } + + func assertUnauthorizedStoredActivationFailure(_ store: TransmissionStore) async throws { + do { + try await store.removeTorrents(ids: [1], deleteLocalData: false) + XCTFail("Expected removeTorrents to surface stored activation failure") + } catch let error as TransmissionError { + guard case .unauthorized = error else { + return XCTFail("Expected unauthorized error, got \(error)") + } + let presentation = try XCTUnwrap(TransmissionUserFacingError.presentation(for: error)) + XCTAssertEqual(presentation.title, "Authentication Failed") + XCTAssertEqual( + presentation.message, + "Authentication failed. Please check your server credentials." + ) + } catch { + XCTFail("Expected TransmissionError.unauthorized, got \(error)") + } + } + func makeStore(sender: some TransmissionRPCRequestSending) -> TransmissionStore { let factory = TransmissionConnectionFactory( transport: TransmissionTransport(sender: sender), diff --git a/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift b/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift index f2ac92a..f7685e0 100644 --- a/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift +++ b/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift @@ -10,7 +10,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertEqual(state.activeRequestGeneration, generation) XCTAssertEqual(state.status, .loading) XCTAssertEqual(state.payload, .empty) - XCTAssertFalse(state.shouldDisplayPayload) + XCTAssertFalse(state.shouldDisplayPayload(for: 42)) } func testApplySnapshotPopulatesPayloadAndPieceCount() { @@ -31,7 +31,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertEqual(state.payload.peersFrom, snapshot.peersFrom) XCTAssertEqual(state.payload.pieceCount, 3) XCTAssertEqual(state.payload.piecesHaveCount, 2) - XCTAssertTrue(state.shouldDisplayPayload) + XCTAssertTrue(state.shouldDisplayPayload(for: 7)) } func testApplySnapshotIgnoresStaleRequest() { @@ -60,7 +60,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertEqual(state.activeRequestGeneration, newGeneration) XCTAssertEqual(state.status, .loaded) XCTAssertEqual(state.payload.files.map(\.name), ["new-file"]) - XCTAssertTrue(state.shouldDisplayPayload) + XCTAssertTrue(state.shouldDisplayPayload(for: 2)) } func testBeginLoadingForSameTorrentPreservesLoadedPayload() { @@ -77,7 +77,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertNotEqual(firstGeneration, secondGeneration) XCTAssertEqual(state.status, .loading) XCTAssertEqual(state.payload.files, snapshot.files) - XCTAssertTrue(state.shouldDisplayPayload) + XCTAssertTrue(state.shouldDisplayPayload(for: 11)) } func testBeginLoadingForDifferentTorrentClearsLoadedPayload() { @@ -93,7 +93,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertEqual(state.activeRequestGeneration, secondGeneration) XCTAssertEqual(state.status, .loading) XCTAssertEqual(state.payload, .empty) - XCTAssertFalse(state.shouldDisplayPayload) + XCTAssertFalse(state.shouldDisplayPayload(for: 12)) } func testMarkFailedPreservesPayloadForActiveRequest() { @@ -108,7 +108,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertTrue(didMarkFailure) XCTAssertEqual(state.status, .failed) XCTAssertEqual(state.payload.files, snapshot.files) - XCTAssertTrue(state.shouldDisplayPayload) + XCTAssertTrue(state.shouldDisplayPayload(for: 11)) } func testMarkFailedIgnoresStaleRequest() { @@ -124,7 +124,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertEqual(state.activeRequestGeneration, currentGeneration) XCTAssertEqual(state.status, .loading) XCTAssertEqual(state.payload, .empty) - XCTAssertFalse(state.shouldDisplayPayload) + XCTAssertFalse(state.shouldDisplayPayload(for: 4)) } func testApplySnapshotRecoversAfterFailureWhileRetainingPayload() { @@ -142,7 +142,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertTrue(didApply) XCTAssertEqual(state.status, .loaded) XCTAssertEqual(state.payload.files.map(\.name), ["new-file"]) - XCTAssertTrue(state.shouldDisplayPayload) + XCTAssertTrue(state.shouldDisplayPayload(for: 7)) } func testApplySnapshotIgnoresOlderGenerationForSameTorrent() { @@ -170,7 +170,7 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertEqual(state.activeRequestGeneration, newerGeneration) XCTAssertEqual(state.status, .loaded) XCTAssertEqual(state.payload.files.map(\.name), ["newer-file"]) - XCTAssertTrue(state.shouldDisplayPayload) + XCTAssertTrue(state.shouldDisplayPayload(for: 9)) } func testMarkFailedIgnoresOlderGenerationForSameTorrent() { @@ -190,7 +190,71 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertEqual(state.activeRequestGeneration, currentGeneration) XCTAssertEqual(state.status, .loading) XCTAssertEqual(state.payload.files.map(\.name), ["retained-file"]) - XCTAssertTrue(state.shouldDisplayPayload) + XCTAssertTrue(state.shouldDisplayPayload(for: 13)) + } + + func testMarkCancelledRestoresIdleStateWhenNoPayloadExists() { + var state = TorrentDetailSupplementalState() + + let generation = state.beginLoading(for: 21) + let didMarkCancellation = state.markCancelled(for: 21, generation: generation) + + XCTAssertTrue(didMarkCancellation) + XCTAssertEqual(state.activeTorrentID, 21) + XCTAssertEqual(state.activeRequestGeneration, generation) + XCTAssertEqual(state.status, .idle) + XCTAssertEqual(state.payload, .empty) + XCTAssertFalse(state.shouldDisplayPayload(for: 21)) + } + + func testMarkCancelledRestoresLoadedStateWhenPayloadExists() { + var state = TorrentDetailSupplementalState() + let snapshot = makeSnapshot(fileName: "retained-file") + + let firstGeneration = state.beginLoading(for: 22) + XCTAssertTrue(state.apply(snapshot: snapshot, for: 22, generation: firstGeneration)) + let secondGeneration = state.beginLoading(for: 22) + + let didMarkCancellation = state.markCancelled(for: 22, generation: secondGeneration) + + XCTAssertTrue(didMarkCancellation) + XCTAssertEqual(state.activeTorrentID, 22) + XCTAssertEqual(state.activeRequestGeneration, secondGeneration) + XCTAssertEqual(state.status, .loaded) + XCTAssertEqual(state.payload.files.map(\.name), ["retained-file"]) + XCTAssertTrue(state.shouldDisplayPayload(for: 22)) + } + + func testMarkCancelledIgnoresOlderGenerationForSameTorrent() { + var state = TorrentDetailSupplementalState() + let snapshot = makeSnapshot(fileName: "retained-file") + + let firstGeneration = state.beginLoading(for: 23) + XCTAssertTrue(state.apply(snapshot: snapshot, for: 23, generation: firstGeneration)) + let staleGeneration = state.beginLoading(for: 23) + let currentGeneration = state.beginLoading(for: 23) + + let didMarkCancellation = state.markCancelled(for: 23, generation: staleGeneration) + + XCTAssertFalse(didMarkCancellation) + XCTAssertEqual(state.activeTorrentID, 23) + XCTAssertEqual(state.activeRequestGeneration, currentGeneration) + XCTAssertEqual(state.status, .loading) + XCTAssertEqual(state.payload.files.map(\.name), ["retained-file"]) + XCTAssertTrue(state.shouldDisplayPayload(for: 23)) + } + + func testShouldDisplayPayloadRequiresMatchingActiveTorrentID() { + var state = TorrentDetailSupplementalState() + let snapshot = makeSnapshot(fileName: "retained-file") + + let generation = state.beginLoading(for: 24) + XCTAssertTrue(state.apply(snapshot: snapshot, for: 24, generation: generation)) + + XCTAssertTrue(state.shouldDisplayPayload(for: 24)) + XCTAssertFalse(state.shouldDisplayPayload(for: 25)) + XCTAssertEqual(state.visiblePayload(for: 24).files.map(\.name), ["retained-file"]) + XCTAssertEqual(state.visiblePayload(for: 25), .empty) } } From 14ccc55373d1d3882c07336de02332a513c1f5f0 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 8 Mar 2026 16:00:39 -0700 Subject: [PATCH 5/8] add file-mutation handling and label caching Introduce optimistic file-stats mutation handling and label caching. Add TorrentDetailFileStatsMutation and plumbing to apply committed file mutations in TorrentDetailSupplementalState/Store, plus helpers to apply mutations from iOS/macOS file-detail views via closures. Cache availableLabels and labelCounts in TransmissionStore and recompute on torrents changes to avoid repeated scans. Extract common torrent summary fields in TransmissionTorrentQuerySpec. Move iOS file controls into a new iOSTorrentFileDetailControls.swift and update project file. Refactor macOS label-edit flows with shared helper functions (sharedLabels, bulkLabelUpdates). Add tests for transmission mutation requests and move shared test helpers into TransmissionTestSupport. --- BitDream.xcodeproj/project.pbxproj | 4 + .../Transmission/TransmissionTorrents.swift | 15 +- BitDream/TransmissionStore.swift | 38 ++- BitDream/Views/Shared/TorrentDetail.swift | 89 +++++- BitDream/Views/Shared/TorrentFileDetail.swift | 31 +- BitDream/Views/iOS/iOSTorrentDetail.swift | 20 +- BitDream/Views/iOS/iOSTorrentFileDetail.swift | 293 ++---------------- .../iOS/iOSTorrentFileDetailControls.swift | 266 ++++++++++++++++ .../Views/macOS/macOSTorrentActionsMenu.swift | 92 ++---- BitDream/Views/macOS/macOSTorrentDetail.swift | 20 +- BitDream/Views/macOS/macOSTorrentEdit.swift | 56 +++- .../Views/macOS/macOSTorrentFileDetail.swift | 21 ++ ...missionConnectionMutationMethodTests.swift | 210 +++++++++++++ .../TransmissionConnectionQueryTests.swift | 15 - .../TransmissionConnectionTests.swift | 15 - .../TransmissionTestSupport.swift | 83 +++++ ...sionStoreFullRefreshDegradationTests.swift | 11 - ...nsmissionStorePlaybackOperationTests.swift | 125 ++++++++ ...ransmissionStoreRetrySchedulingTests.swift | 11 - ...ansmissionStoreTorrentOperationTests.swift | 75 ----- .../Views/TorrentBulkLabelEditTests.swift | 150 +++++++++ .../TorrentDetailSupplementalStoreTests.swift | 110 +++++++ 22 files changed, 1246 insertions(+), 504 deletions(-) create mode 100644 BitDream/Views/iOS/iOSTorrentFileDetailControls.swift create mode 100644 BitDreamTests/Transmission/Connection/TransmissionConnectionMutationMethodTests.swift create mode 100644 BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift create mode 100644 BitDreamTests/Views/TorrentBulkLabelEditTests.swift create mode 100644 BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift diff --git a/BitDream.xcodeproj/project.pbxproj b/BitDream.xcodeproj/project.pbxproj index 3f69fde..b317fe6 100644 --- a/BitDream.xcodeproj/project.pbxproj +++ b/BitDream.xcodeproj/project.pbxproj @@ -96,6 +96,7 @@ 4E2000034000000000C1D2E3 /* TorrentSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2000064000000000C1D2E3 /* TorrentSettings.swift */; }; 4E2000084000000000C1D2E3 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2000094000000000C1D2E3 /* SettingsViewModel.swift */; }; 4F7000024000000000A1B2C3 /* iOSTorrentFileRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7000014000000000A1B2C3 /* iOSTorrentFileRow.swift */; }; + 4F7000044000000000A1B2C3 /* iOSTorrentFileDetailControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7000034000000000A1B2C3 /* iOSTorrentFileDetailControls.swift */; }; 4F8000044000000000A1B2C3 /* macOSContentSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8000014000000000A1B2C3 /* macOSContentSidebar.swift */; }; 4F8000054000000000A1B2C3 /* macOSContentDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8000024000000000A1B2C3 /* macOSContentDetail.swift */; }; 4F8000064000000000A1B2C3 /* macOSContentToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8000034000000000A1B2C3 /* macOSContentToolbar.swift */; }; @@ -232,6 +233,7 @@ 4E2000064000000000C1D2E3 /* TorrentSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentSettings.swift; sourceTree = ""; }; 4E2000094000000000C1D2E3 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 4F7000014000000000A1B2C3 /* iOSTorrentFileRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSTorrentFileRow.swift; sourceTree = ""; }; + 4F7000034000000000A1B2C3 /* iOSTorrentFileDetailControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSTorrentFileDetailControls.swift; sourceTree = ""; }; 4F8000014000000000A1B2C3 /* macOSContentSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = macOSContentSidebar.swift; sourceTree = ""; }; 4F8000024000000000A1B2C3 /* macOSContentDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = macOSContentDetail.swift; sourceTree = ""; }; 4F8000034000000000A1B2C3 /* macOSContentToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = macOSContentToolbar.swift; sourceTree = ""; }; @@ -417,6 +419,7 @@ 4B642D7A2D7E1EF6003CEADC /* iOSServerList.swift */, 4BC5C2B12D7D415100D80AB4 /* iOSTorrentDetail.swift */, 4B6F1EBD2D86C926003D8F6E /* iOSTorrentFileDetail.swift */, + 4F7000034000000000A1B2C3 /* iOSTorrentFileDetailControls.swift */, 4F7000014000000000A1B2C3 /* iOSTorrentFileRow.swift */, 4B6F1EB32D82DB2D003D8F6E /* iOSTorrentListRow.swift */, 4BF593BA2E89C8C400A8C1FC /* iOSTorrentPeerDetail.swift */, @@ -768,6 +771,7 @@ 4B1DAE06295E6FC80037E9FB /* TorrentDetail.swift in Sources */, 4E2000034000000000C1D2E3 /* TorrentSettings.swift in Sources */, 4B6F1EBE2D86C926003D8F6E /* iOSTorrentFileDetail.swift in Sources */, + 4F7000044000000000A1B2C3 /* iOSTorrentFileDetailControls.swift in Sources */, 4F7000024000000000A1B2C3 /* iOSTorrentFileRow.swift in Sources */, 4B73985C2E70097C00D06E5D /* AppFileOpenDelegate.swift in Sources */, 4BCF274F2E774EDC00F6BF76 /* DataWriter.swift in Sources */, diff --git a/BitDream/Transmission/TransmissionTorrents.swift b/BitDream/Transmission/TransmissionTorrents.swift index 821a36b..d4a6398 100644 --- a/BitDream/Transmission/TransmissionTorrents.swift +++ b/BitDream/Transmission/TransmissionTorrents.swift @@ -25,7 +25,7 @@ internal struct TransmissionTorrentDetailQuerySpec: Sendable { } internal enum TransmissionTorrentQuerySpec { - static let torrentSummary = TransmissionTorrentListQuerySpec(fields: [ + private static let summaryFields: [String] = [ "activityDate", "addedDate", "desiredAvailable", "error", "errorString", "eta", "haveUnchecked", "haveValid", "id", "isFinished", "isStalled", "labels", "leftUntilDone", "magnetLink", "metadataPercentComplete", @@ -33,17 +33,10 @@ internal enum TransmissionTorrentQuerySpec { "percentDone", "primary-mime-type", "downloadDir", "queuePosition", "rateDownload", "rateUpload", "sizeWhenDone", "totalSize", "status", "uploadRatio", "uploadedEver", "downloadedEver" - ]) + ] - static let widgetSummary = TransmissionTorrentListQuerySpec(fields: [ - "activityDate", "addedDate", "desiredAvailable", "error", "errorString", - "eta", "haveUnchecked", "haveValid", "id", "isFinished", "isStalled", - "labels", "leftUntilDone", "magnetLink", "metadataPercentComplete", - "name", "peersConnected", "peersGettingFromUs", "peersSendingToUs", - "percentDone", "primary-mime-type", "downloadDir", "queuePosition", - "rateDownload", "rateUpload", "sizeWhenDone", "totalSize", "status", - "uploadRatio", "uploadedEver", "downloadedEver" - ]) + static let torrentSummary = TransmissionTorrentListQuerySpec(fields: summaryFields) + static let widgetSummary = TransmissionTorrentListQuerySpec(fields: summaryFields) static func torrentFiles(id: Int) -> TransmissionTorrentDetailQuerySpec { TransmissionTorrentDetailQuerySpec(fields: ["files", "fileStats"], id: id) diff --git a/BitDream/TransmissionStore.swift b/BitDream/TransmissionStore.swift index 2f3d913..ef16ff0 100644 --- a/BitDream/TransmissionStore.swift +++ b/BitDream/TransmissionStore.swift @@ -69,7 +69,9 @@ final class TransmissionStore: NSObject, ObservableObject { } } - @Published var torrents: [Torrent] = [] + @Published var torrents: [Torrent] = [] { + didSet { recomputeLabelCounts() } + } @Published var sessionStats: SessionStats? @Published var setup: Bool = false @Published var host: Host? @@ -131,6 +133,10 @@ final class TransmissionStore: NSObject, ObservableObject { // Confirmation dialog state for menu remove command @Published var showingMenuRemoveConfirmation = false + // MARK: - Label Management (cached) + private(set) var availableLabels: [String] = [] + private var labelCounts: [String: Int] = [:] + private let resolveConnection: @Sendable (TransmissionConnectionDescriptor) async throws -> TransmissionConnection private let snapshotWriter: WidgetSnapshotWriter private let sleep: @Sendable (TimeInterval) async throws -> Void @@ -609,19 +615,29 @@ extension TransmissionStore { // MARK: - Label Management - /// Get all unique labels from current torrents, sorted alphabetically - var availableLabels: [String] { - let allLabels = torrents.flatMap { $0.labels } - return Array(Set(allLabels)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - } - /// Get count of torrents that have the specified label func torrentCount(for label: String) -> Int { - return torrents.filter { torrent in - torrent.labels.contains { torrentLabel in - torrentLabel.lowercased() == label.lowercased() + labelCounts[label.lowercased(), default: 0] + } + + private func recomputeLabelCounts() { + var counts: [String: Int] = [:] + var normalizedToDisplay: [String: String] = [:] + + for torrent in torrents { + for label in torrent.labels { + let key = label.lowercased() + counts[key, default: 0] += 1 + if normalizedToDisplay[key] == nil { + normalizedToDisplay[key] = label + } } - }.count + } + + labelCounts = counts + availableLabels = normalizedToDisplay.values.sorted { + $0.localizedCaseInsensitiveCompare($1) == .orderedAscending + } } func requestRefresh() { diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index f5aa2aa..d4aa0c8 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -23,6 +23,11 @@ internal enum TorrentDetailSupplementalLoadStatus: Sendable, Equatable { case failed } +internal enum TorrentDetailFileStatsMutation: Sendable, Equatable { + case wanted(Bool) + case priority(FilePriority) +} + internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { let files: [TorrentFile] let fileStats: [TorrentFileStats] @@ -81,6 +86,19 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { piecesHaveCount: haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } ) } + + func updating(fileStats: [TorrentFileStats]) -> Self { + Self( + files: files, + fileStats: fileStats, + peers: peers, + peersFrom: peersFrom, + pieceCount: pieceCount, + pieceSize: pieceSize, + piecesBitfieldBase64: piecesBitfieldBase64, + piecesHaveCount: piecesHaveCount + ) + } } internal struct TorrentDetailSupplementalState: Sendable { @@ -150,12 +168,42 @@ internal struct TorrentDetailSupplementalState: Sendable { status = hasLoadedPayload ? .loaded : .idle return true } + + @discardableResult + mutating func applyCommittedFileStatsMutation( + _ mutation: TorrentDetailFileStatsMutation, + for torrentID: Int, + fileIndices: [Int] + ) -> Bool { + guard activeTorrentID == torrentID, hasLoadedPayload else { + return false + } + + var updatedFileStats = payload.fileStats + var didApply = false + + for fileIndex in fileIndices where updatedFileStats.indices.contains(fileIndex) { + updatedFileStats[fileIndex] = updatedFileStats[fileIndex].applying(mutation) + didApply = true + } + + guard didApply else { + return false + } + + payload = payload.updating(fileStats: updatedFileStats) + return true + } } @MainActor internal final class TorrentDetailSupplementalStore: ObservableObject { @Published private(set) var state = TorrentDetailSupplementalState() + init(state: TorrentDetailSupplementalState = TorrentDetailSupplementalState()) { + self.state = state + } + var payload: TorrentDetailSupplementalPayload { state.payload } @@ -172,6 +220,15 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { state.shouldDisplayPayload(for: torrentID) } + @discardableResult + func applyCommittedFileStatsMutation( + _ mutation: TorrentDetailFileStatsMutation, + for torrentID: Int, + fileIndices: [Int] + ) -> Bool { + mutateState { $0.applyCommittedFileStatsMutation(mutation, for: torrentID, fileIndices: fileIndices) } + } + func load( for torrentID: Int, using store: TransmissionStore, @@ -222,21 +279,12 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { @discardableResult private func markFailure(for torrentID: Int, generation: Int) -> Bool { - var nextState = state - let didMarkFailure = nextState.markFailed(for: torrentID, generation: generation) - state = nextState - return didMarkFailure + mutateState { $0.markFailed(for: torrentID, generation: generation) } } @discardableResult private func markCancellation(for torrentID: Int, generation: Int) -> Bool { - var nextState = state - let didMarkCancellation = nextState.markCancelled( - for: torrentID, - generation: generation - ) - state = nextState - return didMarkCancellation + mutateState { $0.markCancelled(for: torrentID, generation: generation) } } @discardableResult @@ -250,6 +298,25 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { } } +private extension TorrentFileStats { + func applying(_ mutation: TorrentDetailFileStatsMutation) -> Self { + switch mutation { + case .wanted(let wanted): + TorrentFileStats( + bytesCompleted: bytesCompleted, + wanted: wanted, + priority: priority + ) + case .priority(let priority): + TorrentFileStats( + bytesCompleted: bytesCompleted, + wanted: wanted, + priority: priority.rawValue + ) + } + } +} + internal struct TorrentDetailLoadingPlaceholderView: View { let title: String let message: String diff --git a/BitDream/Views/Shared/TorrentFileDetail.swift b/BitDream/Views/Shared/TorrentFileDetail.swift index 7cee7ca..8b6c1d2 100644 --- a/BitDream/Views/Shared/TorrentFileDetail.swift +++ b/BitDream/Views/Shared/TorrentFileDetail.swift @@ -132,12 +132,39 @@ struct TorrentFileDetail: View { let fileStats: [TorrentFileStats] let torrentId: Int let store: TransmissionStore + let onCommittedFileStatsMutation: @MainActor @Sendable ([Int], TorrentDetailFileStatsMutation) -> Void + + init( + files: [TorrentFile], + fileStats: [TorrentFileStats], + torrentId: Int, + store: TransmissionStore, + onCommittedFileStatsMutation: @escaping @MainActor @Sendable ([Int], TorrentDetailFileStatsMutation) -> Void = { _, _ in } + ) { + self.files = files + self.fileStats = fileStats + self.torrentId = torrentId + self.store = store + self.onCommittedFileStatsMutation = onCommittedFileStatsMutation + } var body: some View { #if os(iOS) - iOSTorrentFileDetail(files: files, fileStats: fileStats, torrentId: torrentId, store: store) + iOSTorrentFileDetail( + files: files, + fileStats: fileStats, + torrentId: torrentId, + store: store, + onCommittedFileStatsMutation: onCommittedFileStatsMutation + ) #elseif os(macOS) - macOSTorrentFileDetail(files: files, fileStats: fileStats, torrentId: torrentId, store: store) + macOSTorrentFileDetail( + files: files, + fileStats: fileStats, + torrentId: torrentId, + store: store, + onCommittedFileStatsMutation: onCommittedFileStatsMutation + ) #endif } } diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index e65dd1c..4d5a286 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -88,6 +88,18 @@ struct iOSTorrentDetail: View { } } + @MainActor + private func applyCommittedFileStatsMutation( + fileIndices: [Int], + mutation: TorrentDetailFileStatsMutation + ) { + supplementalStore.applyCommittedFileStatsMutation( + mutation, + for: torrent.id, + fileIndices: fileIndices + ) + } + @ViewBuilder private var filesDestination: some View { if shouldDisplaySupplementalPayload { @@ -95,7 +107,13 @@ struct iOSTorrentDetail: View { files: supplementalPayload.files, fileStats: supplementalPayload.fileStats, torrentId: torrent.id, - store: store + store: store, + onCommittedFileStatsMutation: { fileIndices, mutation in + applyCommittedFileStatsMutation( + fileIndices: fileIndices, + mutation: mutation + ) + } ) .navigationBarTitleDisplayMode(.inline) } else { diff --git a/BitDream/Views/iOS/iOSTorrentFileDetail.swift b/BitDream/Views/iOS/iOSTorrentFileDetail.swift index b219dcc..c43f34e 100644 --- a/BitDream/Views/iOS/iOSTorrentFileDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentFileDetail.swift @@ -44,6 +44,21 @@ struct iOSTorrentFileDetail: View { let fileStats: [TorrentFileStats] let torrentId: Int let store: TransmissionStore + let onCommittedFileStatsMutation: @MainActor @Sendable ([Int], TorrentDetailFileStatsMutation) -> Void + + init( + files: [TorrentFile], + fileStats: [TorrentFileStats], + torrentId: Int, + store: TransmissionStore, + onCommittedFileStatsMutation: @escaping @MainActor @Sendable ([Int], TorrentDetailFileStatsMutation) -> Void = { _, _ in } + ) { + self.files = files + self.fileStats = fileStats + self.torrentId = torrentId + self.store = store + self.onCommittedFileStatsMutation = onCommittedFileStatsMutation + } @State private var mutableFileStats: [TorrentFileStats] = [] @State private var searchText = "" @@ -208,6 +223,9 @@ private extension iOSTorrentFileDetail { wanted: wanted ) }, + onSuccess: { + onCommittedFileStatsMutation([row.fileIndex], .wanted(wanted)) + }, onError: { message in revertStats(previousStats) errorMessage = message @@ -228,6 +246,9 @@ private extension iOSTorrentFileDetail { priority: priority ) }, + onSuccess: { + onCommittedFileStatsMutation([row.fileIndex], .priority(priority)) + }, onError: { message in revertStats(previousStats) errorMessage = message @@ -248,6 +269,9 @@ private extension iOSTorrentFileDetail { wanted: wanted ) }, + onSuccess: { + onCommittedFileStatsMutation(fileIndices, .wanted(wanted)) + }, onError: { message in revertStats(previousStats) errorMessage = message @@ -268,6 +292,9 @@ private extension iOSTorrentFileDetail { priority: priority ) }, + onSuccess: { + onCommittedFileStatsMutation(fileIndices, .priority(priority)) + }, onError: { message in revertStats(previousStats) errorMessage = message @@ -326,270 +353,4 @@ private extension iOSTorrentFileDetail { } } -struct BulkActionToolbar: View { - let selectedCount: Int - @Binding var selectedFileIds: Set - let allFileRows: [TorrentFileRow] - let setBulkWanted: ([Int], Bool) -> Void - let setBulkPriority: ([Int], FilePriority) -> Void - - var body: some View { - VStack(spacing: 0) { - Divider() - - HStack(spacing: 16) { - Text("\(selectedCount) selected") - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - - Button(selectedCount == allFileRows.count ? "Deselect All" : "Select All") { - if selectedCount == allFileRows.count { - selectedFileIds.removeAll() - } else { - selectedFileIds = Set(allFileRows.map { $0.id }) - } - } - .font(.subheadline) - - Menu { - Section("Status") { - Button("Download") { - setBulkWanted(true) - } - - Button("Don't Download") { - setBulkWanted(false) - } - } - - Section("Priority") { - Button("High Priority") { - setBulkPriority(.high) - } - - Button("Normal Priority") { - setBulkPriority(.normal) - } - - Button("Low Priority") { - setBulkPriority(.low) - } - } - } label: { - Text("Actions") - .font(.subheadline) - } - .disabled(selectedCount == 0) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(.background) - } - } - - private func setBulkPriority(_ priority: FilePriority) { - let fileIndices = allFileRows - .filter { selectedFileIds.contains($0.id) } - .map(\.fileIndex) - setBulkPriority(fileIndices, priority) - } - - private func setBulkWanted(_ wanted: Bool) { - let fileIndices = allFileRows - .filter { selectedFileIds.contains($0.id) } - .map(\.fileIndex) - setBulkWanted(fileIndices, wanted) - } -} - -// MARK: - File Action Buttons View - -struct FileActionButtonsView: View { - let hasActiveFilters: Bool - @Binding var sortProperty: FileSortProperty - @Binding var sortOrder: SortOrder - @Binding var isEditing: Bool - @Binding var selectedFileIds: Set - @Binding var showFilterSheet: Bool - - var body: some View { - HStack(spacing: 12) { - Button { - showFilterSheet = true - } label: { - HStack(spacing: 4) { - Image(systemName: hasActiveFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") - Text("Filter") - } - .font(.subheadline) - .foregroundColor(hasActiveFilters ? .white : .accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(hasActiveFilters ? Color.accentColor : Color.accentColor.opacity(0.1)) - .cornerRadius(16) - } - - Menu { - ForEach(FileSortProperty.allCases, id: \.self) { property in - Button { - sortProperty = property - } label: { - HStack { - Text(property.rawValue) - Spacer() - if sortProperty == property { - Image(systemName: "checkmark") - } - } - } - } - - Divider() - - Button { - sortOrder = .ascending - } label: { - HStack { - Text("Ascending") - Spacer() - if sortOrder == .ascending { - Image(systemName: "checkmark") - } - } - } - - Button { - sortOrder = .descending - } label: { - HStack { - Text("Descending") - Spacer() - if sortOrder == .descending { - Image(systemName: "checkmark") - } - } - } - } label: { - HStack(spacing: 4) { - Image(systemName: "arrow.up.arrow.down") - Text("Sort") - } - .font(.subheadline) - .foregroundColor(.accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.accentColor.opacity(0.1)) - .cornerRadius(16) - } - - Spacer() - - Button { - withAnimation { - isEditing.toggle() - if !isEditing { - selectedFileIds.removeAll() - } - } - } label: { - HStack(spacing: 4) { - Image(systemName: isEditing ? "checkmark" : "pencil") - Text(isEditing ? "Done" : "Edit") - } - .font(.subheadline) - .foregroundColor(isEditing ? .white : .accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(isEditing ? Color.accentColor : Color.accentColor.opacity(0.1)) - .cornerRadius(16) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.background) - } -} - -// MARK: - Filter Sheet - -struct FilterSheet: View { - @Environment(\.dismiss) private var dismiss - - @Binding var showWantedFiles: Bool - @Binding var showSkippedFiles: Bool - @Binding var showCompleteFiles: Bool - @Binding var showIncompleteFiles: Bool - @Binding var showVideos: Bool - @Binding var showAudio: Bool - @Binding var showImages: Bool - @Binding var showDocuments: Bool - @Binding var showArchives: Bool - @Binding var showOther: Bool - - var body: some View { - NavigationView { - List { - Section("Status") { - Toggle(FileStatus.wanted, isOn: $showWantedFiles) - Toggle(FileStatus.skip, isOn: $showSkippedFiles) - } - - Section("Progress") { - Toggle(FileCompletion.complete, isOn: $showCompleteFiles) - Toggle(FileCompletion.incomplete, isOn: $showIncompleteFiles) - } - - Section("File Types") { - Toggle(ContentTypeCategory.video.title, isOn: $showVideos) - Toggle(ContentTypeCategory.audio.title, isOn: $showAudio) - Toggle(ContentTypeCategory.image.title, isOn: $showImages) - Toggle(ContentTypeCategory.document.title, isOn: $showDocuments) - Toggle(ContentTypeCategory.archive.title, isOn: $showArchives) - Toggle(ContentTypeCategory.other.title, isOn: $showOther) - } - - Section { - Button("Reset All Filters") { - showWantedFiles = true - showSkippedFiles = true - showCompleteFiles = true - showIncompleteFiles = true - showVideos = true - showAudio = true - showImages = true - showDocuments = true - showArchives = true - showOther = true - } - .foregroundColor(.accentColor) - } - } - .navigationTitle("Filters") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - dismiss() - } - } - } - } - } -} - -// MARK: - Preview - -#Preview("iOS Torrent Files") { - NavigationView { - iOSTorrentFileDetail( - files: TorrentFilePreviewData.sampleFiles, - fileStats: TorrentFilePreviewData.sampleFileStats, - torrentId: 1, - store: TransmissionStore() - ) - } -} - #endif diff --git a/BitDream/Views/iOS/iOSTorrentFileDetailControls.swift b/BitDream/Views/iOS/iOSTorrentFileDetailControls.swift new file mode 100644 index 0000000..1e2aa10 --- /dev/null +++ b/BitDream/Views/iOS/iOSTorrentFileDetailControls.swift @@ -0,0 +1,266 @@ +import Foundation +import SwiftUI + +#if os(iOS) + +struct BulkActionToolbar: View { + let selectedCount: Int + @Binding var selectedFileIds: Set + let allFileRows: [TorrentFileRow] + let setBulkWanted: ([Int], Bool) -> Void + let setBulkPriority: ([Int], FilePriority) -> Void + + var body: some View { + VStack(spacing: 0) { + Divider() + + HStack(spacing: 16) { + Text("\(selectedCount) selected") + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Button(selectedCount == allFileRows.count ? "Deselect All" : "Select All") { + if selectedCount == allFileRows.count { + selectedFileIds.removeAll() + } else { + selectedFileIds = Set(allFileRows.map { $0.id }) + } + } + .font(.subheadline) + + Menu { + Section("Status") { + Button("Download") { + setBulkWanted(true) + } + + Button("Don't Download") { + setBulkWanted(false) + } + } + + Section("Priority") { + Button("High Priority") { + setBulkPriority(.high) + } + + Button("Normal Priority") { + setBulkPriority(.normal) + } + + Button("Low Priority") { + setBulkPriority(.low) + } + } + } label: { + Text("Actions") + .font(.subheadline) + } + .disabled(selectedCount == 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.background) + } + } + + private func setBulkPriority(_ priority: FilePriority) { + let fileIndices = allFileRows + .filter { selectedFileIds.contains($0.id) } + .map(\.fileIndex) + setBulkPriority(fileIndices, priority) + } + + private func setBulkWanted(_ wanted: Bool) { + let fileIndices = allFileRows + .filter { selectedFileIds.contains($0.id) } + .map(\.fileIndex) + setBulkWanted(fileIndices, wanted) + } +} + +struct FileActionButtonsView: View { + let hasActiveFilters: Bool + @Binding var sortProperty: FileSortProperty + @Binding var sortOrder: SortOrder + @Binding var isEditing: Bool + @Binding var selectedFileIds: Set + @Binding var showFilterSheet: Bool + + var body: some View { + HStack(spacing: 12) { + Button { + showFilterSheet = true + } label: { + HStack(spacing: 4) { + Image(systemName: hasActiveFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + Text("Filter") + } + .font(.subheadline) + .foregroundColor(hasActiveFilters ? .white : .accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(hasActiveFilters ? Color.accentColor : Color.accentColor.opacity(0.1)) + .cornerRadius(16) + } + + Menu { + ForEach(FileSortProperty.allCases, id: \.self) { property in + Button { + sortProperty = property + } label: { + HStack { + Text(property.rawValue) + Spacer() + if sortProperty == property { + Image(systemName: "checkmark") + } + } + } + } + + Divider() + + Button { + sortOrder = .ascending + } label: { + HStack { + Text("Ascending") + Spacer() + if sortOrder == .ascending { + Image(systemName: "checkmark") + } + } + } + + Button { + sortOrder = .descending + } label: { + HStack { + Text("Descending") + Spacer() + if sortOrder == .descending { + Image(systemName: "checkmark") + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.up.arrow.down") + Text("Sort") + } + .font(.subheadline) + .foregroundColor(.accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(16) + } + + Spacer() + + Button { + withAnimation { + isEditing.toggle() + if !isEditing { + selectedFileIds.removeAll() + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: isEditing ? "checkmark" : "pencil") + Text(isEditing ? "Done" : "Edit") + } + .font(.subheadline) + .foregroundColor(isEditing ? .white : .accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isEditing ? Color.accentColor : Color.accentColor.opacity(0.1)) + .cornerRadius(16) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.background) + } +} + +struct FilterSheet: View { + @Environment(\.dismiss) private var dismiss + + @Binding var showWantedFiles: Bool + @Binding var showSkippedFiles: Bool + @Binding var showCompleteFiles: Bool + @Binding var showIncompleteFiles: Bool + @Binding var showVideos: Bool + @Binding var showAudio: Bool + @Binding var showImages: Bool + @Binding var showDocuments: Bool + @Binding var showArchives: Bool + @Binding var showOther: Bool + + var body: some View { + NavigationView { + List { + Section("Status") { + Toggle(FileStatus.wanted, isOn: $showWantedFiles) + Toggle(FileStatus.skip, isOn: $showSkippedFiles) + } + + Section("Progress") { + Toggle(FileCompletion.complete, isOn: $showCompleteFiles) + Toggle(FileCompletion.incomplete, isOn: $showIncompleteFiles) + } + + Section("File Types") { + Toggle(ContentTypeCategory.video.title, isOn: $showVideos) + Toggle(ContentTypeCategory.audio.title, isOn: $showAudio) + Toggle(ContentTypeCategory.image.title, isOn: $showImages) + Toggle(ContentTypeCategory.document.title, isOn: $showDocuments) + Toggle(ContentTypeCategory.archive.title, isOn: $showArchives) + Toggle(ContentTypeCategory.other.title, isOn: $showOther) + } + + Section { + Button("Reset All Filters") { + showWantedFiles = true + showSkippedFiles = true + showCompleteFiles = true + showIncompleteFiles = true + showVideos = true + showAudio = true + showImages = true + showDocuments = true + showArchives = true + showOther = true + } + .foregroundColor(.accentColor) + } + } + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} + +#Preview("iOS Torrent Files") { + NavigationView { + iOSTorrentFileDetail( + files: TorrentFilePreviewData.sampleFiles, + fileStats: TorrentFilePreviewData.sampleFileStats, + torrentId: 1, + store: TransmissionStore() + ) + } +} + +#endif diff --git a/BitDream/Views/macOS/macOSTorrentActionsMenu.swift b/BitDream/Views/macOS/macOSTorrentActionsMenu.swift index a757e1c..52d7607 100644 --- a/BitDream/Views/macOS/macOSTorrentActionsMenu.swift +++ b/BitDream/Views/macOS/macOSTorrentActionsMenu.swift @@ -294,38 +294,15 @@ struct TorrentActionsToolbarMenu: View { Label("Actions", systemImage: "ellipsis.circle") } .sheet(isPresented: $labelDialog) { - let torrents = selectedTorrents - let titleSuffix = torrents.count > 1 ? " (\(torrents.count) torrents)" : "" - VStack(spacing: 16) { - Text("Edit Labels\(titleSuffix)") - .font(.headline) - - LabelEditView( - labelInput: $labelInput, - existingLabels: torrents.count == 1 ? Array(torrents.first!.labels) : [], - store: store, - selectedTorrents: torrents, - shouldSave: $shouldSave, - onError: { message in - errorMessage = message - showingError = true - } - ) - - HStack { - Button("Cancel") { - labelDialog = false - } - .keyboardShortcut(.escape) - - Button("Save") { - shouldSave = true - } - .keyboardShortcut(.return, modifiers: .command) - .buttonStyle(.borderedProminent) - } - } - .padding() + LabelEditSheetContent( + store: store, + selectedTorrents: selectedTorrents, + labelInput: $labelInput, + shouldSave: $shouldSave, + isPresented: $labelDialog, + showingError: $showingError, + errorMessage: $errorMessage + ) .frame(width: 400) } .alert( @@ -358,42 +335,17 @@ struct TorrentActionsToolbarMenu: View { } .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) .sheet(isPresented: $renameDialog) { - // Resolve target torrent using captured id or current selection - let targetTorrent: Torrent? = { - if let id = renameTargetId { - return store.torrents.first { $0.id == id } - } - return selectedTorrents.first - }() - if let targetTorrent { - RenameSheetView( - title: "Rename Torrent", - name: $renameInput, - currentName: targetTorrent.name, - onCancel: { - renameDialog = false - }, - onSave: { newName in - if let validation = validateNewName(newName, current: targetTorrent.name) { - errorMessage = validation - showingError = true - return - } - performTransmissionAction( - operation: { try await store.renameTorrentRoot(targetTorrent, to: newName) }, - onSuccess: { (_: TorrentRenameResponseArgs) in - renameDialog = false - }, - onError: { message in - errorMessage = message - showingError = true - } - ) - } - ) - .frame(width: 420) - .padding() - } + RenameSheetContent( + store: store, + selectedTorrents: selectedTorrents, + renameInput: $renameInput, + renameTargetId: $renameTargetId, + isPresented: $renameDialog, + showingError: $showingError, + errorMessage: $errorMessage + ) + .frame(width: 420) + .padding() } .sheet(isPresented: $moveDialog) { MoveSheetContent( @@ -429,7 +381,9 @@ struct LabelEditSheetContent: View { LabelEditView( labelInput: $labelInput, - existingLabels: selectedTorrents.count == 1 ? Array(selectedTorrents.first!.labels) : [], + existingLabels: selectedTorrents.count == 1 + ? Array(selectedTorrents.first!.labels) + : sharedLabels(for: selectedTorrents), store: store, selectedTorrents: selectedTorrents, shouldSave: $shouldSave, diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index c5db438..6e232e9 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -112,6 +112,18 @@ struct macOSTorrentDetail: View { } } + @MainActor + private func applyCommittedFileStatsMutation( + fileIndices: [Int], + mutation: TorrentDetailFileStatsMutation + ) { + supplementalStore.applyCommittedFileStatsMutation( + mutation, + for: torrent.id, + fileIndices: fileIndices + ) + } + private func performDelete(deleteLocalData: Bool) { performTransmissionAction( operation: { @@ -137,7 +149,13 @@ struct macOSTorrentDetail: View { files: supplementalPayload.files, fileStats: supplementalPayload.fileStats, torrentId: torrent.id, - store: store + store: store, + onCommittedFileStatsMutation: { fileIndices, mutation in + applyCommittedFileStatsMutation( + fileIndices: fileIndices, + mutation: mutation + ) + } ) } else { switch supplementalStore.status { diff --git a/BitDream/Views/macOS/macOSTorrentEdit.swift b/BitDream/Views/macOS/macOSTorrentEdit.swift index 3a6c90a..59f13be 100644 --- a/BitDream/Views/macOS/macOSTorrentEdit.swift +++ b/BitDream/Views/macOS/macOSTorrentEdit.swift @@ -108,11 +108,15 @@ struct LabelEditView: View { onError: onError ) } else { - let updates = selectedTorrents.map { torrent in - TransmissionTorrentLabelsUpdate( - ids: [torrent.id], - labels: Array(Set(torrent.labels).union(workingLabels)).sorted() - ) + let updates = bulkLabelUpdates( + for: selectedTorrents, + existingLabels: existingLabels, + workingLabels: workingLabels + ) + + guard !updates.isEmpty else { + dismiss() + return } performTransmissionAction( @@ -252,4 +256,46 @@ struct MoveSheetContent: View { } } +internal func sharedLabels(for torrents: Set) -> [String] { + guard var shared = torrents.first.map({ Set($0.labels) }) else { + return [] + } + + for torrent in torrents.dropFirst() { + shared.formIntersection(torrent.labels) + } + + return shared.sorted() +} + +internal func bulkLabelUpdates( + for torrents: Set, + existingLabels: [String], + workingLabels: Set +) -> [TransmissionTorrentLabelsUpdate] { + let existingLabelSet = Set(existingLabels) + let removedSharedLabels = existingLabelSet.subtracting(workingLabels) + let addedLabels = workingLabels.subtracting(existingLabelSet) + + guard !removedSharedLabels.isEmpty || !addedLabels.isEmpty else { + return [] + } + + return torrents.compactMap { torrent in + var labels = Set(torrent.labels) + labels.subtract(removedSharedLabels) + labels.formUnion(addedLabels) + + let sortedLabels = labels.sorted() + guard sortedLabels != torrent.labels.sorted() else { + return nil + } + + return TransmissionTorrentLabelsUpdate(ids: [torrent.id], labels: sortedLabels) + } + .sorted { lhs, rhs in + (lhs.ids.first ?? 0) < (rhs.ids.first ?? 0) + } +} + #endif diff --git a/BitDream/Views/macOS/macOSTorrentFileDetail.swift b/BitDream/Views/macOS/macOSTorrentFileDetail.swift index faea981..a3d4b85 100644 --- a/BitDream/Views/macOS/macOSTorrentFileDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentFileDetail.swift @@ -35,6 +35,21 @@ struct macOSTorrentFileDetail: View { let fileStats: [TorrentFileStats] let torrentId: Int let store: TransmissionStore + let onCommittedFileStatsMutation: @MainActor @Sendable ([Int], TorrentDetailFileStatsMutation) -> Void + + init( + files: [TorrentFile], + fileStats: [TorrentFileStats], + torrentId: Int, + store: TransmissionStore, + onCommittedFileStatsMutation: @escaping @MainActor @Sendable ([Int], TorrentDetailFileStatsMutation) -> Void = { _, _ in } + ) { + self.files = files + self.fileStats = fileStats + self.torrentId = torrentId + self.store = store + self.onCommittedFileStatsMutation = onCommittedFileStatsMutation + } @StateObject private var viewModel = FileTableViewModel() @State private var columnVisibility = Set(["name", "size", "progress", "downloaded", "priority", "status"]) @@ -248,6 +263,9 @@ private extension macOSTorrentFileDetail { wanted: wanted ) }, + onSuccess: { + onCommittedFileStatsMutation(fileIndices, .wanted(wanted)) + }, onError: { message in revertStats(previousStats) errorMessage = message @@ -270,6 +288,9 @@ private extension macOSTorrentFileDetail { priority: priority ) }, + onSuccess: { + onCommittedFileStatsMutation(fileIndices, .priority(priority)) + }, onError: { message in revertStats(previousStats) errorMessage = message diff --git a/BitDreamTests/Transmission/Connection/TransmissionConnectionMutationMethodTests.swift b/BitDreamTests/Transmission/Connection/TransmissionConnectionMutationMethodTests.swift new file mode 100644 index 0000000..8e905ad --- /dev/null +++ b/BitDreamTests/Transmission/Connection/TransmissionConnectionMutationMethodTests.swift @@ -0,0 +1,210 @@ +import XCTest +@testable import BitDream + +final class TransmissionConnectionMutationTests: XCTestCase { + func testPauseTorrentsUsesStopMethod() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.pauseTorrents(ids: [7, 8]) + + try await assertCapturedRequest( + sender: sender, + method: "torrent-stop", + expectedArguments: ["ids": [7, 8]] + ) + } + + func testResumeTorrentsUsesStartMethod() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.resumeTorrents(ids: [9]) + + try await assertCapturedRequest( + sender: sender, + method: "torrent-start", + expectedArguments: ["ids": [9]] + ) + } + + func testPauseAllTorrentsUsesStopMethodWithoutArguments() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.pauseAllTorrents() + + try await assertCapturedRequest( + sender: sender, + method: "torrent-stop", + expectedArguments: [:] + ) + } + + func testResumeAllTorrentsUsesStartMethodWithoutArguments() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.resumeAllTorrents() + + try await assertCapturedRequest( + sender: sender, + method: "torrent-start", + expectedArguments: [:] + ) + } + + func testStartTorrentsNowUsesStartNowMethod() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.startTorrentsNow(ids: [3, 4]) + + try await assertCapturedRequest( + sender: sender, + method: "torrent-start-now", + expectedArguments: ["ids": [3, 4]] + ) + } + + func testReannounceTorrentsUsesReannounceMethod() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.reannounceTorrents(ids: [12]) + + try await assertCapturedRequest( + sender: sender, + method: "torrent-reannounce", + expectedArguments: ["ids": [12]] + ) + } + + func testVerifyTorrentsUsesVerifyMethod() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.verifyTorrents(ids: [14, 15]) + + try await assertCapturedRequest( + sender: sender, + method: "torrent-verify", + expectedArguments: ["ids": [14, 15]] + ) + } + + func testSetTorrentLocationUsesLocationPayload() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.setTorrentLocation(ids: [5, 6], location: "/new/path", move: true) + + try await assertCapturedRequest( + sender: sender, + method: "torrent-set-location", + expectedArguments: [ + "ids": [5, 6], + "location": "/new/path", + "move": true + ] + ) + } + + func testSetFileWantedStatusUsesWantedAndUnwantedKeys() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody), + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.setFileWantedStatus(torrentID: 42, fileIndices: [1, 3], wanted: true) + try await connection.setFileWantedStatus(torrentID: 42, fileIndices: [2], wanted: false) + + let requests = await sender.capturedRequests() + XCTAssertEqual(requests.count, 2) + + let wantedArguments = try requestArguments(from: requests[0]) + XCTAssertEqual(try requestMethod(from: requests[0].asURLRequest()), "torrent-set") + XCTAssertEqual(wantedArguments["ids"] as? [Int], [42]) + XCTAssertEqual(wantedArguments["files-wanted"] as? [Int], [1, 3]) + XCTAssertNil(wantedArguments["files-unwanted"]) + + let unwantedArguments = try requestArguments(from: requests[1]) + XCTAssertEqual(try requestMethod(from: requests[1].asURLRequest()), "torrent-set") + XCTAssertEqual(unwantedArguments["ids"] as? [Int], [42]) + XCTAssertEqual(unwantedArguments["files-unwanted"] as? [Int], [2]) + XCTAssertNil(unwantedArguments["files-wanted"]) + } + + func testSetFilePriorityUsesExpectedPriorityKeys() async throws { + let sender = QueueSender(steps: [ + .http(statusCode: 200, body: successEmptyBody), + .http(statusCode: 200, body: successEmptyBody), + .http(statusCode: 200, body: successEmptyBody) + ]) + let connection = try makeConnection(sender: sender) + + try await connection.setFilePriority(torrentID: 11, fileIndices: [0], priority: .low) + try await connection.setFilePriority(torrentID: 11, fileIndices: [1], priority: .normal) + try await connection.setFilePriority(torrentID: 11, fileIndices: [2, 3], priority: .high) + + let requests = await sender.capturedRequests() + XCTAssertEqual(requests.count, 3) + + let lowArguments = try requestArguments(from: requests[0]) + XCTAssertEqual(try requestMethod(from: requests[0].asURLRequest()), "torrent-set") + XCTAssertEqual(lowArguments["ids"] as? [Int], [11]) + XCTAssertEqual(lowArguments["priority-low"] as? [Int], [0]) + + let normalArguments = try requestArguments(from: requests[1]) + XCTAssertEqual(try requestMethod(from: requests[1].asURLRequest()), "torrent-set") + XCTAssertEqual(normalArguments["ids"] as? [Int], [11]) + XCTAssertEqual(normalArguments["priority-normal"] as? [Int], [1]) + + let highArguments = try requestArguments(from: requests[2]) + XCTAssertEqual(try requestMethod(from: requests[2].asURLRequest()), "torrent-set") + XCTAssertEqual(highArguments["ids"] as? [Int], [11]) + XCTAssertEqual(highArguments["priority-high"] as? [Int], [2, 3]) + } +} + +private extension TransmissionConnectionMutationTests { + func makeConnection(sender: QueueSender) throws -> TransmissionConnection { + TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + } + + func assertCapturedRequest( + sender: QueueSender, + method: String, + expectedArguments: [String: Any], + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + let requests = await sender.capturedRequests() + XCTAssertEqual(requests.count, 1, file: file, line: line) + XCTAssertEqual(try requestMethod(from: requests[0].asURLRequest()), method, file: file, line: line) + + let arguments = try requestArguments(from: requests[0]) + XCTAssertEqual(arguments as NSDictionary, expectedArguments as NSDictionary, file: file, line: line) + } +} diff --git a/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift b/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift index 909cf63..b0ec605 100644 --- a/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift +++ b/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift @@ -417,18 +417,3 @@ private func makeTorrentDetailSuccessBody() -> String { } """ } - -private extension CapturedRequest { - func asURLRequest() -> URLRequest { - var request = URLRequest(url: url ?? URL(string: "https://example.com")!) - request.httpMethod = httpMethod - request.httpBody = body - return request - } -} - -private func requestArguments(from request: CapturedRequest) throws -> [String: Any] { - let body = try XCTUnwrap(request.body) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - return try XCTUnwrap(object["arguments"] as? [String: Any]) -} diff --git a/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift b/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift index 17e7d76..f738256 100644 --- a/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift +++ b/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift @@ -227,18 +227,3 @@ final class TransmissionConnectionTests: XCTestCase { } } } - -private extension CapturedRequest { - func asURLRequest() -> URLRequest { - var request = URLRequest(url: url ?? URL(string: "https://example.com")!) - request.httpMethod = httpMethod - request.httpBody = body - return request - } -} - -private func requestArguments(from request: CapturedRequest) throws -> [String: Any] { - let body = try XCTUnwrap(request.body) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - return try XCTUnwrap(object["arguments"] as? [String: Any]) -} diff --git a/BitDreamTests/Transmission/TransmissionTestSupport.swift b/BitDreamTests/Transmission/TransmissionTestSupport.swift index 8584346..357de7c 100644 --- a/BitDreamTests/Transmission/TransmissionTestSupport.swift +++ b/BitDreamTests/Transmission/TransmissionTestSupport.swift @@ -366,6 +366,89 @@ func makeHTTPResponse( return response } +extension CapturedRequest { + func asURLRequest() -> URLRequest { + var request = URLRequest(url: url ?? URL(string: "https://example.com")!) + request.httpMethod = httpMethod + request.httpBody = body + return request + } +} + +func requestArguments(from request: CapturedRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.body) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + return try XCTUnwrap(object["arguments"] as? [String: Any]) +} + +func sessionSettingsBody(downloadDir: String, version: String) throws -> String { + let data = Data(try loadTransmissionFixture(named: "session-get.response.json").utf8) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var arguments = try XCTUnwrap(object["arguments"] as? [String: Any]) + arguments["download-dir"] = downloadDir + arguments["version"] = version + arguments["blocklist-size"] = 0 + object["arguments"] = arguments + let encoded = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return try XCTUnwrap(String(bytes: encoded, encoding: .utf8)) +} + +@MainActor +func makeStore(sender: some TransmissionRPCRequestSending) -> TransmissionStore { + let factory = TransmissionConnectionFactory( + transport: TransmissionTransport(sender: sender), + credentialResolver: TransmissionCredentialResolver(resolvePassword: { source in + switch source { + case .resolvedPassword(let password): + return password + case .keychainCredential(let key): + return key == "test-key" ? "secret" : "" + } + }) + ) + + return TransmissionStore( + connectionFactory: factory, + snapshotWriter: WidgetSnapshotWriter( + writeServerIndex: { _ in }, + writeSessionSnapshot: { _, _, _, _, _ in }, + reloadTimelines: { } + ), + sleep: { _ in + try await Task.sleep(nanoseconds: .max) + }, + persistVersion: { _, _ in } + ) +} + +func makeHost(serverID: String, server: String) -> BitDream.Host { + BitDream.Host( + serverID: serverID, + isDefault: false, + isSSL: false, + credentialKey: "test-key", + name: serverID, + port: 9091, + server: server, + username: "demo", + version: nil + ) +} + +func waitUntil( + timeout: TimeInterval = 1, + _ predicate: @escaping @MainActor () async -> Bool +) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await predicate() { + return true + } + await Task.yield() + } + return false +} + extension XCTestCase { func assertThrowsTransmissionError( _ expectation: ErrorExpectation, diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreFullRefreshDegradationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreFullRefreshDegradationTests.swift index 0da2ccd..056e802 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStoreFullRefreshDegradationTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStoreFullRefreshDegradationTests.swift @@ -169,14 +169,3 @@ private extension TransmissionStoreFullRefreshTests { return false } } - -private func sessionSettingsBody(downloadDir: String, version: String) throws -> String { - let data = Data(try loadTransmissionFixture(named: "session-get.response.json").utf8) - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - var arguments = try XCTUnwrap(object["arguments"] as? [String: Any]) - arguments["download-dir"] = downloadDir - arguments["version"] = version - object["arguments"] = arguments - let encoded = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - return try XCTUnwrap(String(bytes: encoded, encoding: .utf8)) -} diff --git a/BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift new file mode 100644 index 0000000..29759e3 --- /dev/null +++ b/BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift @@ -0,0 +1,125 @@ +import Foundation +import XCTest +@testable import BitDream + +@MainActor +final class TransmissionStorePlaybackOperationTests: XCTestCase { + func testToggleTorrentPlaybackUsesResumeForStoppedTorrentAndSchedulesRefresh() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody), + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ], + "torrent-start": [ + .http(statusCode: 200, body: successEmptyBody) + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { store.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + try await store.toggleTorrentPlayback(makeTorrent(id: 1, status: .stopped)) + + let didRefresh = await waitUntil { + let requests = await sender.capturedRequests() + return requests.count == 7 + } + XCTAssertTrue(didRefresh) + + let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } + XCTAssertEqual(methods.filter { $0 == "torrent-start" }.count, 1) + XCTAssertEqual(methods.filter { $0 == "torrent-stop" }.count, 0) + XCTAssertEqual(methods.filter { $0 == "session-stats" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "session-get" }.count, 2) + } + + func testToggleTorrentPlaybackUsesPauseForActiveTorrentAndSchedulesRefresh() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody), + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ], + "torrent-stop": [ + .http(statusCode: 200, body: successEmptyBody) + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { store.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + try await store.toggleTorrentPlayback(makeTorrent(id: 1, status: .downloading)) + + let didRefresh = await waitUntil { + let requests = await sender.capturedRequests() + return requests.count == 7 + } + XCTAssertTrue(didRefresh) + + let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } + XCTAssertEqual(methods.filter { $0 == "torrent-stop" }.count, 1) + XCTAssertEqual(methods.filter { $0 == "torrent-start" }.count, 0) + XCTAssertEqual(methods.filter { $0 == "session-stats" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 2) + XCTAssertEqual(methods.filter { $0 == "session-get" }.count, 2) + } +} + +private extension TransmissionStorePlaybackOperationTests { + func makeTorrent(id: Int, status: TorrentStatus) -> Torrent { + Torrent( + activityDate: 0, + addedDate: 0, + desiredAvailable: 0, + error: 0, + errorString: "", + eta: 0, + haveUnchecked: 0, + haveValid: 0, + id: id, + isFinished: false, + isStalled: false, + labels: [], + leftUntilDone: 0, + magnetLink: "", + metadataPercentComplete: 1, + name: "Torrent \(id)", + peersConnected: 0, + peersGettingFromUs: 0, + peersSendingToUs: 0, + percentDone: status == .stopped ? 0 : 0.5, + primaryMimeType: nil, + downloadDir: "/downloads", + queuePosition: 0, + rateDownload: 0, + rateUpload: 0, + sizeWhenDone: 0, + status: status.rawValue, + totalSize: 0, + uploadRatio: 0, + uploadedEver: 0, + downloadedEver: 0 + ) + } + +} diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreRetrySchedulingTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreRetrySchedulingTests.swift index 0b400f1..7a6cd6c 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStoreRetrySchedulingTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStoreRetrySchedulingTests.swift @@ -302,14 +302,3 @@ private actor ScriptedSleep { } } } - -private func sessionSettingsBody(downloadDir: String, version: String) throws -> String { - let data = Data(try loadTransmissionFixture(named: "session-get.response.json").utf8) - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - var arguments = try XCTUnwrap(object["arguments"] as? [String: Any]) - arguments["download-dir"] = downloadDir - arguments["version"] = version - object["arguments"] = arguments - let encoded = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - return try XCTUnwrap(String(bytes: encoded, encoding: .utf8)) -} diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift index 71237ed..0912b76 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift @@ -360,33 +360,6 @@ private extension TransmissionStoreTorrentOperationTests { } } - func makeStore(sender: some TransmissionRPCRequestSending) -> TransmissionStore { - let factory = TransmissionConnectionFactory( - transport: TransmissionTransport(sender: sender), - credentialResolver: TransmissionCredentialResolver(resolvePassword: { source in - switch source { - case .resolvedPassword(let password): - return password - case .keychainCredential(let key): - return key == "test-key" ? "secret" : "" - } - }) - ) - - return TransmissionStore( - connectionFactory: factory, - snapshotWriter: WidgetSnapshotWriter( - writeServerIndex: { _ in }, - writeSessionSnapshot: { _, _, _, _, _ in }, - reloadTimelines: { } - ), - sleep: { _ in - try await Task.sleep(nanoseconds: .max) - }, - persistVersion: { _, _ in } - ) - } - func makeSupersededMutationSender() throws -> HostMethodScriptedSender { HostMethodScriptedSender(stepsByHostAndMethod: [ "old.example.com": [ @@ -448,54 +421,6 @@ private extension TransmissionStoreTorrentOperationTests { ]) } - func makeHost(serverID: String, server: String) -> BitDream.Host { - BitDream.Host( - serverID: serverID, - isDefault: false, - isSSL: false, - credentialKey: "test-key", - name: serverID, - port: 9091, - server: server, - username: "demo", - version: nil - ) - } - - func waitUntil( - timeout: TimeInterval = 1, - _ predicate: @escaping @MainActor () async -> Bool - ) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if await predicate() { - return true - } - await Task.yield() - } - return false - } -} - -private extension CapturedRequest { - func asURLRequest() -> URLRequest { - var request = URLRequest(url: url ?? URL(string: "https://example.com")!) - request.httpMethod = httpMethod - request.httpBody = body - return request - } -} - -private func sessionSettingsBody(downloadDir: String, version: String) throws -> String { - let data = Data(try loadTransmissionFixture(named: "session-get.response.json").utf8) - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - var arguments = try XCTUnwrap(object["arguments"] as? [String: Any]) - arguments["download-dir"] = downloadDir - arguments["version"] = version - arguments["blocklist-size"] = 0 - object["arguments"] = arguments - let encoded = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - return try XCTUnwrap(String(bytes: encoded, encoding: .utf8)) } private func makeTorrentDetailSuccessBody() -> String { diff --git a/BitDreamTests/Views/TorrentBulkLabelEditTests.swift b/BitDreamTests/Views/TorrentBulkLabelEditTests.swift new file mode 100644 index 0000000..59d1683 --- /dev/null +++ b/BitDreamTests/Views/TorrentBulkLabelEditTests.swift @@ -0,0 +1,150 @@ +import XCTest +@testable import BitDream + +@MainActor +final class TorrentBulkLabelEditTests: XCTestCase { + func testSharedLabelsReturnsIntersectionAcrossSelection() { + let torrents = Set([ + makeTorrent(id: 1, labels: ["movie", "4k"]), + makeTorrent(id: 2, labels: ["movie"]), + makeTorrent(id: 3, labels: ["movie", "tv"]) + ]) + + XCTAssertEqual(sharedLabels(for: torrents), ["movie"]) + } + + func testBulkLabelUpdatesRemovingSharedLabelRemovesItFromEverySelectedTorrent() { + let torrents = Set([ + makeTorrent(id: 1, labels: ["movie", "4k"]), + makeTorrent(id: 2, labels: ["movie"]), + makeTorrent(id: 3, labels: ["movie", "tv"]) + ]) + + let updates = bulkLabelUpdates( + for: torrents, + existingLabels: ["movie"], + workingLabels: [] + ) + + assertUpdates( + updates, + equal: [ + ([1], ["4k"]), + ([2], []), + ([3], ["tv"]) + ] + ) + } + + func testBulkLabelUpdatesAddingLabelAddsItToEverySelectedTorrent() { + let torrents = Set([ + makeTorrent(id: 1, labels: ["movie", "4k"]), + makeTorrent(id: 2, labels: ["movie"]), + makeTorrent(id: 3, labels: ["movie", "tv"]) + ]) + + let updates = bulkLabelUpdates( + for: torrents, + existingLabels: ["movie"], + workingLabels: ["movie", "favorite"] + ) + + assertUpdates( + updates, + equal: [ + ([1], ["4k", "favorite", "movie"]), + ([2], ["favorite", "movie"]), + ([3], ["favorite", "movie", "tv"]) + ] + ) + } + + func testBulkLabelUpdatesPreservePartialLabelsWhenUnchanged() { + let torrents = Set([ + makeTorrent(id: 1, labels: ["movie", "4k"]), + makeTorrent(id: 2, labels: ["movie"]), + makeTorrent(id: 3, labels: ["movie", "tv"]) + ]) + + let updates = bulkLabelUpdates( + for: torrents, + existingLabels: ["movie"], + workingLabels: ["movie"] + ) + + XCTAssertTrue(updates.isEmpty) + } + + func testBulkLabelUpdatesAreNoLongerAddOnlyForSharedLabels() { + let torrents = Set([ + makeTorrent(id: 1, labels: ["movie", "4k"]), + makeTorrent(id: 2, labels: ["movie"]) + ]) + + let updates = bulkLabelUpdates( + for: torrents, + existingLabels: ["movie"], + workingLabels: [] + ) + + assertUpdates( + updates, + equal: [ + ([1], ["4k"]), + ([2], []) + ] + ) + } +} + +private extension TorrentBulkLabelEditTests { + func assertUpdates( + _ updates: [TransmissionTorrentLabelsUpdate], + equal expected: [([Int], [String])], + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(updates.count, expected.count, file: file, line: line) + + for (update, expectedUpdate) in zip(updates, expected) { + XCTAssertEqual(update.ids, expectedUpdate.0, file: file, line: line) + XCTAssertEqual(update.labels, expectedUpdate.1, file: file, line: line) + } + } + + func makeTorrent(id: Int, labels: [String]) -> Torrent { + Torrent( + activityDate: 0, + addedDate: 0, + desiredAvailable: 0, + error: 0, + errorString: "", + eta: 0, + haveUnchecked: 0, + haveValid: 0, + id: id, + isFinished: false, + isStalled: false, + labels: labels, + leftUntilDone: 0, + magnetLink: "", + metadataPercentComplete: 1, + name: "Torrent \(id)", + peersConnected: 0, + peersGettingFromUs: 0, + peersSendingToUs: 0, + percentDone: 1, + primaryMimeType: nil, + downloadDir: "/downloads", + queuePosition: 0, + rateDownload: 0, + rateUpload: 0, + sizeWhenDone: 0, + status: TorrentStatus.stopped.rawValue, + totalSize: 0, + uploadRatio: 0, + uploadedEver: 0, + downloadedEver: 0 + ) + } +} diff --git a/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift b/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift new file mode 100644 index 0000000..f282ff6 --- /dev/null +++ b/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import BitDream + +@MainActor +final class TorrentDetailSupplementalStoreTests: XCTestCase { + func testPayloadForActiveTorrentReflectsCommittedWantedMutationWithoutReload() { + let store = makeLoadedStore() + + XCTAssertTrue( + store.applyCommittedFileStatsMutation( + .wanted(false), + for: 42, + fileIndices: [0] + ) + ) + + let payload = store.payload(for: 42) + XCTAssertFalse(payload.fileStats[0].wanted) + XCTAssertEqual(payload.fileStats[0].priority, FilePriority.normal.rawValue) + XCTAssertEqual(payload.fileStats[1], makeDetailSnapshot().fileStats[1]) + } + + func testApplyCommittedPriorityMutationUpdatesCachedPayload() { + let store = makeLoadedStore() + + XCTAssertTrue( + store.applyCommittedFileStatsMutation( + .priority(.high), + for: 42, + fileIndices: [1] + ) + ) + + let payload = store.payload(for: 42) + XCTAssertEqual(payload.fileStats[1].priority, FilePriority.high.rawValue) + XCTAssertFalse(payload.fileStats[1].wanted) + XCTAssertEqual(payload.fileStats[0], makeDetailSnapshot().fileStats[0]) + } + + func testApplyCommittedMutationForDifferentTorrentIsNoOp() { + let store = makeLoadedStore() + let initialPayload = store.payload(for: 42) + + XCTAssertFalse( + store.applyCommittedFileStatsMutation( + .wanted(false), + for: 99, + fileIndices: [0] + ) + ) + + XCTAssertEqual(store.payload(for: 42), initialPayload) + } + + func testApplyCommittedMutationWithOutOfRangeIndicesIsNoOp() { + let store = makeLoadedStore() + let initialPayload = store.payload(for: 42) + + XCTAssertFalse( + store.applyCommittedFileStatsMutation( + .priority(.low), + for: 42, + fileIndices: [10] + ) + ) + + XCTAssertEqual(store.payload(for: 42), initialPayload) + } +} + +private extension TorrentDetailSupplementalStoreTests { + func makeLoadedStore(torrentID: Int = 42) -> TorrentDetailSupplementalStore { + var state = TorrentDetailSupplementalState() + let generation = state.beginLoading(for: torrentID) + XCTAssertTrue( + state.apply( + snapshot: makeDetailSnapshot(), + for: torrentID, + generation: generation + ) + ) + return TorrentDetailSupplementalStore(state: state) + } + + func makeDetailSnapshot() -> TransmissionTorrentDetailSnapshot { + TransmissionTorrentDetailSnapshot( + files: [ + TorrentFile(bytesCompleted: 50, length: 100, name: "Ubuntu.iso"), + TorrentFile(bytesCompleted: 0, length: 20, name: "Extras/Readme.txt") + ], + fileStats: [ + TorrentFileStats( + bytesCompleted: 50, + wanted: true, + priority: FilePriority.normal.rawValue + ), + TorrentFileStats( + bytesCompleted: 0, + wanted: false, + priority: FilePriority.low.rawValue + ) + ], + peers: [], + peersFrom: nil, + pieceCount: 0, + pieceSize: 0, + piecesBitfieldBase64: "" + ) + } +} From a268c0c1871a934a87ea7a188ebc4a1d364be9d9 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sun, 8 Mar 2026 16:22:18 -0700 Subject: [PATCH 6/8] stop torrent detail supplemental auto-retry loops on failure --- .swiftlint.yml | 1 - BitDream/Views/Shared/TorrentDetail.swift | 22 ++- BitDream/Views/iOS/iOSTorrentDetail.swift | 30 ++-- BitDream/Views/macOS/macOSTorrentDetail.swift | 30 ++-- .../TorrentDetailSupplementalStoreTests.swift | 167 ++++++++++++++++++ 5 files changed, 223 insertions(+), 27 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index b483e3b..21fcaaa 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -6,7 +6,6 @@ excluded: - build - .build # TODO: Refactor these files and remove the temporary lint exclusion. - - BitDream/Transmission/TransmissionFunctions.swift - BitDream/TransmissionStore.swift type_name: diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index d4aa0c8..bd8bb5d 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -261,7 +261,7 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { } } - func loadIfNeeded( + func loadIfIdle( for torrentID: Int, using store: TransmissionStore, onError: @escaping @MainActor @Sendable (String) -> Void @@ -270,7 +270,7 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { return } - guard state.status != .loading else { + guard state.status == .idle else { return } @@ -335,12 +335,30 @@ internal struct TorrentDetailLoadingPlaceholderView: View { internal struct TorrentDetailUnavailablePlaceholderView: View { let title: String let message: String + let actionTitle: String? + let action: (() -> Void)? + + init( + title: String, + message: String, + actionTitle: String? = nil, + action: (() -> Void)? = nil + ) { + self.title = title + self.message = message + self.actionTitle = actionTitle + self.action = action + } var body: some View { ContentUnavailableView { Label(title, systemImage: "exclamationmark.triangle") } description: { Text(message) + } actions: { + if let actionTitle, let action { + Button(actionTitle, action: action) + } } } } diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index 4d5a286..f557a90 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -81,8 +81,8 @@ struct iOSTorrentDetail: View { } @MainActor - private func loadSupplementalDataIfNeeded(for torrentID: Int) async { - await supplementalStore.loadIfNeeded(for: torrentID, using: store) { message in + private func loadSupplementalDataIfIdle(for torrentID: Int) async { + await supplementalStore.loadIfIdle(for: torrentID, using: store) { message in errorMessage = message showingError = true } @@ -126,7 +126,7 @@ struct iOSTorrentDetail: View { .navigationTitle("Files") .navigationBarTitleDisplayMode(.inline) .task { - await loadSupplementalDataIfNeeded(for: torrent.id) + await loadSupplementalDataIfIdle(for: torrent.id) } case .loading: TorrentDetailLoadingPlaceholderView( @@ -138,13 +138,16 @@ struct iOSTorrentDetail: View { case .failed: TorrentDetailUnavailablePlaceholderView( title: "Files Unavailable", - message: "The latest file details could not be loaded." + message: "The latest file details could not be loaded.", + actionTitle: "Retry", + action: { + Task { + await loadSupplementalData(for: torrent.id) + } + } ) .navigationTitle("Files") .navigationBarTitleDisplayMode(.inline) - .task { - await loadSupplementalDataIfNeeded(for: torrent.id) - } case .loaded: EmptyView() } @@ -174,7 +177,7 @@ struct iOSTorrentDetail: View { .navigationTitle("Peers") .navigationBarTitleDisplayMode(.inline) .task { - await loadSupplementalDataIfNeeded(for: torrent.id) + await loadSupplementalDataIfIdle(for: torrent.id) } case .loading: TorrentDetailLoadingPlaceholderView( @@ -186,13 +189,16 @@ struct iOSTorrentDetail: View { case .failed: TorrentDetailUnavailablePlaceholderView( title: "Peers Unavailable", - message: "The latest peer details could not be loaded." + message: "The latest peer details could not be loaded.", + actionTitle: "Retry", + action: { + Task { + await loadSupplementalData(for: torrent.id) + } + } ) .navigationTitle("Peers") .navigationBarTitleDisplayMode(.inline) - .task { - await loadSupplementalDataIfNeeded(for: torrent.id) - } case .loaded: EmptyView() } diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index 6e232e9..dbc25e0 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -105,8 +105,8 @@ struct macOSTorrentDetail: View { } @MainActor - private func loadSupplementalDataIfNeeded(for torrentID: Int) async { - await supplementalStore.loadIfNeeded(for: torrentID, using: store) { message in + private func loadSupplementalDataIfIdle(for torrentID: Int) async { + await supplementalStore.loadIfIdle(for: torrentID, using: store) { message in errorMessage = message showingError = true } @@ -166,7 +166,7 @@ struct macOSTorrentDetail: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) .task { - await loadSupplementalDataIfNeeded(for: torrent.id) + await loadSupplementalDataIfIdle(for: torrent.id) } case .loading: TorrentDetailLoadingPlaceholderView( @@ -177,12 +177,15 @@ struct macOSTorrentDetail: View { case .failed: TorrentDetailUnavailablePlaceholderView( title: "Files Unavailable", - message: "The latest file details could not be loaded." + message: "The latest file details could not be loaded.", + actionTitle: "Retry", + action: { + Task { + await loadSupplementalData(for: torrent.id) + } + } ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .task { - await loadSupplementalDataIfNeeded(for: torrent.id) - } case .loaded: EmptyView() } @@ -210,7 +213,7 @@ struct macOSTorrentDetail: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) .task { - await loadSupplementalDataIfNeeded(for: torrent.id) + await loadSupplementalDataIfIdle(for: torrent.id) } case .loading: TorrentDetailLoadingPlaceholderView( @@ -221,12 +224,15 @@ struct macOSTorrentDetail: View { case .failed: TorrentDetailUnavailablePlaceholderView( title: "Peers Unavailable", - message: "The latest peer details could not be loaded." + message: "The latest peer details could not be loaded.", + actionTitle: "Retry", + action: { + Task { + await loadSupplementalData(for: torrent.id) + } + } ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .task { - await loadSupplementalDataIfNeeded(for: torrent.id) - } case .loaded: EmptyView() } diff --git a/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift b/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift index f282ff6..b30c8e0 100644 --- a/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift +++ b/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift @@ -3,6 +3,112 @@ import XCTest @MainActor final class TorrentDetailSupplementalStoreTests: XCTestCase { + func testLoadIfIdleLoadsOnlyOnceFromIdle() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ] + ]) + let transmissionStore = makeStore(sender: sender) + let supplementalStore = TorrentDetailSupplementalStore() + var errors: [String] = [] + + transmissionStore.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { transmissionStore.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + await supplementalStore.loadIfIdle(for: 42, using: transmissionStore) { errors.append($0) } + await supplementalStore.loadIfIdle(for: 42, using: transmissionStore) { errors.append($0) } + + let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } + XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 4) + XCTAssertEqual(errors, []) + XCTAssertEqual(supplementalStore.status, .loaded) + XCTAssertTrue(supplementalStore.shouldDisplayPayload(for: 42)) + } + + func testLoadIfIdleDoesNotRetryFromFailedStateWithoutPayload() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: rpcFailureBody(result: "detail load failed")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ] + ]) + let transmissionStore = makeStore(sender: sender) + let supplementalStore = TorrentDetailSupplementalStore() + var errors: [String] = [] + + transmissionStore.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { transmissionStore.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + await supplementalStore.loadIfIdle(for: 42, using: transmissionStore) { errors.append($0) } + await supplementalStore.loadIfIdle(for: 42, using: transmissionStore) { errors.append($0) } + + let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } + XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 4) + XCTAssertEqual(errors, ["detail load failed"]) + XCTAssertEqual(supplementalStore.status, .failed) + XCTAssertEqual(supplementalStore.payload(for: 42), .empty) + XCTAssertFalse(supplementalStore.shouldDisplayPayload(for: 42)) + } + + func testExplicitLoadRetriesAfterFailedIdleLoadAndRecovers() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: rpcFailureBody(result: "detail load failed")), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()), + .http(statusCode: 200, body: makeTorrentDetailSuccessBody()) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/initial", version: "4.0.0")) + ] + ]) + let transmissionStore = makeStore(sender: sender) + let supplementalStore = TorrentDetailSupplementalStore() + var errors: [String] = [] + + transmissionStore.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { transmissionStore.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + await supplementalStore.loadIfIdle(for: 42, using: transmissionStore) { errors.append($0) } + XCTAssertEqual(supplementalStore.status, .failed) + + await supplementalStore.load(for: 42, using: transmissionStore) { errors.append($0) } + + let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } + XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 7) + XCTAssertEqual(errors, ["detail load failed"]) + XCTAssertEqual(supplementalStore.status, .loaded) + XCTAssertTrue(supplementalStore.shouldDisplayPayload(for: 42)) + XCTAssertEqual(supplementalStore.payload(for: 42).files.map(\.name), ["Ubuntu.iso"]) + } + func testPayloadForActiveTorrentReflectsCommittedWantedMutationWithoutReload() { let store = makeLoadedStore() @@ -69,6 +175,67 @@ final class TorrentDetailSupplementalStoreTests: XCTestCase { } private extension TorrentDetailSupplementalStoreTests { + func makeTorrentDetailSuccessBody() -> String { + """ + { + "arguments": { + "torrents": [ + { + "files": [ + { "bytesCompleted": 1, "length": 2, "name": "Ubuntu.iso" } + ], + "fileStats": [ + { "bytesCompleted": 1, "wanted": true, "priority": 0 } + ], + "peers": [ + { + "address": "127.0.0.1", + "clientName": "Transmission", + "clientIsChoked": false, + "clientIsInterested": true, + "flagStr": "D", + "isDownloadingFrom": true, + "isEncrypted": false, + "isIncoming": false, + "isUploadingTo": false, + "isUTP": false, + "peerIsChoked": false, + "peerIsInterested": true, + "port": 51413, + "progress": 0.5, + "rateToClient": 100, + "rateToPeer": 200 + } + ], + "peersFrom": { + "fromCache": 0, + "fromDht": 1, + "fromIncoming": 0, + "fromLpd": 0, + "fromLtep": 0, + "fromPex": 0, + "fromTracker": 1 + }, + "pieceCount": 2, + "pieceSize": 16384, + "pieces": "Zm9v" + } + ] + }, + "result": "success" + } + """ + } + + func rpcFailureBody(result: String) -> String { + """ + { + "arguments": {}, + "result": "\(result)" + } + """ + } + func makeLoadedStore(torrentID: Int = 42) -> TorrentDetailSupplementalStore { var state = TorrentDetailSupplementalState() let generation = state.beginLoading(for: torrentID) From c794608790fdd111da1ccf69fda57e98c092aabf Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sun, 8 Mar 2026 16:50:18 -0700 Subject: [PATCH 7/8] refactor torrent detail helpers and fix label cache refresh --- BitDream/BitDreamApp.swift | 13 +-- BitDream/Delegates/AppFileOpenDelegate.swift | 13 +-- BitDream/TransmissionStore.swift | 8 +- BitDream/Views/Shared/AddTorrent.swift | 16 +++ BitDream/Views/Shared/PiecesGridView.swift | 5 +- BitDream/Views/Shared/TorrentDetail.swift | 41 ++++++- BitDream/Views/Shared/TorrentFileDetail.swift | 60 ++++++++++ BitDream/Views/iOS/iOSTorrentDetail.swift | 95 ++++------------ BitDream/Views/iOS/iOSTorrentFileDetail.swift | 103 ++---------------- BitDream/Views/macOS/macOSContentDetail.swift | 13 +-- BitDream/Views/macOS/macOSTorrentDetail.swift | 87 ++++----------- .../Views/macOS/macOSTorrentFileDetail.swift | 71 ++---------- 12 files changed, 186 insertions(+), 339 deletions(-) diff --git a/BitDream/BitDreamApp.swift b/BitDream/BitDreamApp.swift index 2052f64..c2a77ef 100644 --- a/BitDream/BitDreamApp.swift +++ b/BitDream/BitDreamApp.swift @@ -155,18 +155,7 @@ private extension BitDreamApp { for url in urls { do { let data = try Data(contentsOf: url) - performTransmissionAction( - operation: { - try await store.addTorrent( - fileData: data, - saveLocation: store.defaultDownloadDir - ) - }, - onSuccess: { (_: TransmissionTorrentAddOutcome) in }, - onError: { message in - presentAddTorrentStoreError(detail: message, store: store) - } - ) + addTorrentFromFileData(data, store: store) } catch { failures.append((url.lastPathComponent, error.localizedDescription)) } diff --git a/BitDream/Delegates/AppFileOpenDelegate.swift b/BitDream/Delegates/AppFileOpenDelegate.swift index d725fcd..be683d3 100644 --- a/BitDream/Delegates/AppFileOpenDelegate.swift +++ b/BitDream/Delegates/AppFileOpenDelegate.swift @@ -104,18 +104,7 @@ final class AppFileOpenDelegate: NSObject, NSApplicationDelegate, ObservableObje case .magnet(let magnetString): store.enqueueMagnet(magnetString) case .torrentData(let data): - performTransmissionAction( - operation: { - try await store.addTorrent( - fileData: data, - saveLocation: store.defaultDownloadDir - ) - }, - onSuccess: { (_: TransmissionTorrentAddOutcome) in }, - onError: { message in - presentAddTorrentStoreError(detail: message, store: store) - } - ) + addTorrentFromFileData(data, store: store) } } diff --git a/BitDream/TransmissionStore.swift b/BitDream/TransmissionStore.swift index ef16ff0..94ba3b7 100644 --- a/BitDream/TransmissionStore.swift +++ b/BitDream/TransmissionStore.swift @@ -634,10 +634,14 @@ extension TransmissionStore { } } - labelCounts = counts - availableLabels = normalizedToDisplay.values.sorted { + let nextAvailableLabels = normalizedToDisplay.values.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + + guard counts != labelCounts || nextAvailableLabels != availableLabels else { return } + + labelCounts = counts + availableLabels = nextAvailableLabels } func requestRefresh() { diff --git a/BitDream/Views/Shared/AddTorrent.swift b/BitDream/Views/Shared/AddTorrent.swift index 8187df7..de8be3b 100644 --- a/BitDream/Views/Shared/AddTorrent.swift +++ b/BitDream/Views/Shared/AddTorrent.swift @@ -57,6 +57,22 @@ func presentAddTorrentStoreError( #endif } +@MainActor +func addTorrentFromFileData(_ data: Data, store: TransmissionStore) { + performTransmissionAction( + operation: { + try await store.addTorrent( + fileData: data, + saveLocation: store.defaultDownloadDir + ) + }, + onSuccess: { (_: TransmissionTorrentAddOutcome) in }, + onError: { message in + presentAddTorrentStoreError(detail: message, store: store) + } + ) +} + // MARK: - Extensions extension UTType { /// Convenience UTType for .torrent used by file importer; prefers extension, then MIME type, then .data diff --git a/BitDream/Views/Shared/PiecesGridView.swift b/BitDream/Views/Shared/PiecesGridView.swift index e437afc..cf1dda7 100644 --- a/BitDream/Views/Shared/PiecesGridView.swift +++ b/BitDream/Views/Shared/PiecesGridView.swift @@ -1,8 +1,7 @@ import SwiftUI struct PiecesGridView: View { - let pieceCount: Int - let piecesBitfieldBase64: String + let piecesHaveSet: [Bool] var rows: Int = 10 // Visual tuning for macOS @@ -10,7 +9,7 @@ struct PiecesGridView: View { private let cellSpacing: CGFloat = 2 var body: some View { - let bitset = decodePiecesBitfield(base64String: piecesBitfieldBase64, pieceCount: pieceCount) + let bitset = piecesHaveSet GeometryReader { geometry in let columnsCount = computeColumns(availableWidth: geometry.size.width, cellSize: cellSize, cellSpacing: cellSpacing) diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index bd8bb5d..9eed2b3 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -35,7 +35,7 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { let peersFrom: PeersFrom? let pieceCount: Int let pieceSize: Int64 - let piecesBitfieldBase64: String + let piecesHaveSet: [Bool] let piecesHaveCount: Int static let empty = TorrentDetailSupplementalPayload( @@ -45,7 +45,7 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { peersFrom: nil, pieceCount: 0, pieceSize: 0, - piecesBitfieldBase64: "", + piecesHaveSet: [], piecesHaveCount: 0 ) @@ -56,7 +56,7 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { peersFrom: PeersFrom?, pieceCount: Int, pieceSize: Int64, - piecesBitfieldBase64: String, + piecesHaveSet: [Bool], piecesHaveCount: Int ) { self.files = files @@ -65,7 +65,7 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { self.peersFrom = peersFrom self.pieceCount = pieceCount self.pieceSize = pieceSize - self.piecesBitfieldBase64 = piecesBitfieldBase64 + self.piecesHaveSet = piecesHaveSet self.piecesHaveCount = piecesHaveCount } @@ -82,7 +82,7 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { peersFrom: snapshot.peersFrom, pieceCount: snapshot.pieceCount, pieceSize: snapshot.pieceSize, - piecesBitfieldBase64: snapshot.piecesBitfieldBase64, + piecesHaveSet: haveSet, piecesHaveCount: haveSet.reduce(0) { $0 + ($1 ? 1 : 0) } ) } @@ -95,7 +95,7 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { peersFrom: peersFrom, pieceCount: pieceCount, pieceSize: pieceSize, - piecesBitfieldBase64: piecesBitfieldBase64, + piecesHaveSet: piecesHaveSet, piecesHaveCount: piecesHaveCount ) } @@ -317,6 +317,35 @@ private extension TorrentFileStats { } } +internal struct TorrentDetailSupplementalPlaceholder: View { + let status: TorrentDetailSupplementalLoadStatus + let loadingTitle: String + let loadingMessage: String + let unavailableTitle: String + let unavailableMessage: String + let onLoadIfIdle: @Sendable () async -> Void + let onRetry: () -> Void + + var body: some View { + switch status { + case .idle: + TorrentDetailLoadingPlaceholderView(title: loadingTitle, message: loadingMessage) + .task { await onLoadIfIdle() } + case .loading: + TorrentDetailLoadingPlaceholderView(title: loadingTitle, message: loadingMessage) + case .failed: + TorrentDetailUnavailablePlaceholderView( + title: unavailableTitle, + message: unavailableMessage, + actionTitle: "Retry", + action: onRetry + ) + case .loaded: + EmptyView() + } + } +} + internal struct TorrentDetailLoadingPlaceholderView: View { let title: String let message: String diff --git a/BitDream/Views/Shared/TorrentFileDetail.swift b/BitDream/Views/Shared/TorrentFileDetail.swift index 8b6c1d2..3a7bda0 100644 --- a/BitDream/Views/Shared/TorrentFileDetail.swift +++ b/BitDream/Views/Shared/TorrentFileDetail.swift @@ -169,6 +169,66 @@ struct TorrentFileDetail: View { } } +// MARK: - Shared File Stats Mutation Helpers + +func snapshotFileStats( + for fileIndices: [Int], + mutableStats: [TorrentFileStats], + fallbackStats: [TorrentFileStats] +) -> [(index: Int, stats: TorrentFileStats)] { + let source = mutableStats.isEmpty ? fallbackStats : mutableStats + return fileIndices.compactMap { idx in + guard idx < source.count else { return nil } + return (idx, source[idx]) + } +} + +func applyFileStatsRevert( + _ previousStats: [(index: Int, stats: TorrentFileStats)], + into mutableStats: [TorrentFileStats], + fallback fallbackStats: [TorrentFileStats] +) -> [TorrentFileStats] { + var result = mutableStats.isEmpty ? fallbackStats : mutableStats + for (idx, old) in previousStats where idx < result.count { + result[idx] = old + } + return result +} + +func applyLocalFileWanted( + fileIndices: [Int], + wanted: Bool, + mutableStats: [TorrentFileStats], + fallbackStats: [TorrentFileStats] +) -> [TorrentFileStats] { + var result = mutableStats.isEmpty ? fallbackStats : mutableStats + for idx in fileIndices where idx < result.count { + result[idx] = TorrentFileStats( + bytesCompleted: result[idx].bytesCompleted, + wanted: wanted, + priority: result[idx].priority + ) + } + return result +} + +func applyLocalFilePriority( + fileIndices: [Int], + priority: FilePriority, + mutableStats: [TorrentFileStats], + fallbackStats: [TorrentFileStats] +) -> [TorrentFileStats] { + var result = mutableStats.isEmpty ? fallbackStats : mutableStats + for idx in fileIndices where idx < result.count { + result[idx] = TorrentFileStats( + bytesCompleted: result[idx].bytesCompleted, + wanted: result[idx].wanted, + priority: priority.rawValue + ) + } + return result +} + // MARK: - Preview Data /// Shared test data for previews diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index f557a90..d44904a 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -117,40 +117,17 @@ struct iOSTorrentDetail: View { ) .navigationBarTitleDisplayMode(.inline) } else { - switch supplementalStore.status { - case .idle: - TorrentDetailLoadingPlaceholderView( - title: "Loading Files", - message: "Fetching the latest files for this torrent." - ) - .navigationTitle("Files") - .navigationBarTitleDisplayMode(.inline) - .task { - await loadSupplementalDataIfIdle(for: torrent.id) - } - case .loading: - TorrentDetailLoadingPlaceholderView( - title: "Loading Files", - message: "Fetching the latest files for this torrent." - ) - .navigationTitle("Files") - .navigationBarTitleDisplayMode(.inline) - case .failed: - TorrentDetailUnavailablePlaceholderView( - title: "Files Unavailable", - message: "The latest file details could not be loaded.", - actionTitle: "Retry", - action: { - Task { - await loadSupplementalData(for: torrent.id) - } - } - ) - .navigationTitle("Files") - .navigationBarTitleDisplayMode(.inline) - case .loaded: - EmptyView() - } + TorrentDetailSupplementalPlaceholder( + status: supplementalStore.status, + loadingTitle: "Loading Files", + loadingMessage: "Fetching the latest files for this torrent.", + unavailableTitle: "Files Unavailable", + unavailableMessage: "The latest file details could not be loaded.", + onLoadIfIdle: { await loadSupplementalDataIfIdle(for: torrent.id) }, + onRetry: { Task { await loadSupplementalData(for: torrent.id) } } + ) + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) } } @@ -168,40 +145,17 @@ struct iOSTorrentDetail: View { ) .navigationBarTitleDisplayMode(.inline) } else { - switch supplementalStore.status { - case .idle: - TorrentDetailLoadingPlaceholderView( - title: "Loading Peers", - message: "Fetching the latest peers for this torrent." - ) - .navigationTitle("Peers") - .navigationBarTitleDisplayMode(.inline) - .task { - await loadSupplementalDataIfIdle(for: torrent.id) - } - case .loading: - TorrentDetailLoadingPlaceholderView( - title: "Loading Peers", - message: "Fetching the latest peers for this torrent." - ) - .navigationTitle("Peers") - .navigationBarTitleDisplayMode(.inline) - case .failed: - TorrentDetailUnavailablePlaceholderView( - title: "Peers Unavailable", - message: "The latest peer details could not be loaded.", - actionTitle: "Retry", - action: { - Task { - await loadSupplementalData(for: torrent.id) - } - } - ) - .navigationTitle("Peers") - .navigationBarTitleDisplayMode(.inline) - case .loaded: - EmptyView() - } + TorrentDetailSupplementalPlaceholder( + status: supplementalStore.status, + loadingTitle: "Loading Peers", + loadingMessage: "Fetching the latest peers for this torrent.", + unavailableTitle: "Peers Unavailable", + unavailableMessage: "The latest peer details could not be loaded.", + onLoadIfIdle: { await loadSupplementalDataIfIdle(for: torrent.id) }, + onRetry: { Task { await loadSupplementalData(for: torrent.id) } } + ) + .navigationTitle("Peers") + .navigationBarTitleDisplayMode(.inline) } } } @@ -293,12 +247,11 @@ private struct IOSTorrentDetailContent 0 && !supplementalPayload.piecesBitfieldBase64.isEmpty { + if supplementalPayload.pieceCount > 0 && !supplementalPayload.piecesHaveSet.isEmpty { Section(header: Text("Pieces")) { VStack(alignment: .leading, spacing: 6) { PiecesGridView( - pieceCount: supplementalPayload.pieceCount, - piecesBitfieldBase64: supplementalPayload.piecesBitfieldBase64 + piecesHaveSet: supplementalPayload.piecesHaveSet ) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/BitDream/Views/iOS/iOSTorrentFileDetail.swift b/BitDream/Views/iOS/iOSTorrentFileDetail.swift index c43f34e..cfbbb31 100644 --- a/BitDream/Views/iOS/iOSTorrentFileDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentFileDetail.swift @@ -212,54 +212,16 @@ struct iOSTorrentFileDetail: View { private extension iOSTorrentFileDetail { func setFileWanted(_ row: TorrentFileRow, wanted: Bool) { - let previousStats = snapshotStats(for: [row.fileIndex]) - updateLocalFileStatus(fileIndices: [row.fileIndex], wanted: wanted) - - performTransmissionAction( - operation: { - try await store.setFileWantedStatus( - torrentId: torrentId, - fileIndices: [row.fileIndex], - wanted: wanted - ) - }, - onSuccess: { - onCommittedFileStatsMutation([row.fileIndex], .wanted(wanted)) - }, - onError: { message in - revertStats(previousStats) - errorMessage = message - showingError = true - } - ) + setBulkWanted(fileIndices: [row.fileIndex], wanted: wanted) } func setFilePriority(_ row: TorrentFileRow, priority: FilePriority) { - let previousStats = snapshotStats(for: [row.fileIndex]) - updateLocalFilePriority(fileIndices: [row.fileIndex], priority: priority) - - performTransmissionAction( - operation: { - try await store.setFilePriority( - torrentId: torrentId, - fileIndices: [row.fileIndex], - priority: priority - ) - }, - onSuccess: { - onCommittedFileStatsMutation([row.fileIndex], .priority(priority)) - }, - onError: { message in - revertStats(previousStats) - errorMessage = message - showingError = true - } - ) + setBulkPriority(fileIndices: [row.fileIndex], priority: priority) } func setBulkWanted(fileIndices: [Int], wanted: Bool) { - let previousStats = snapshotStats(for: fileIndices) - updateLocalFileStatus(fileIndices: fileIndices, wanted: wanted) + let previousStats = snapshotFileStats(for: fileIndices, mutableStats: mutableFileStats, fallbackStats: fileStats) + mutableFileStats = applyLocalFileWanted(fileIndices: fileIndices, wanted: wanted, mutableStats: mutableFileStats, fallbackStats: fileStats) performTransmissionAction( operation: { @@ -273,7 +235,7 @@ private extension iOSTorrentFileDetail { onCommittedFileStatsMutation(fileIndices, .wanted(wanted)) }, onError: { message in - revertStats(previousStats) + mutableFileStats = applyFileStatsRevert(previousStats, into: mutableFileStats, fallback: fileStats) errorMessage = message showingError = true } @@ -281,8 +243,8 @@ private extension iOSTorrentFileDetail { } func setBulkPriority(fileIndices: [Int], priority: FilePriority) { - let previousStats = snapshotStats(for: fileIndices) - updateLocalFilePriority(fileIndices: fileIndices, priority: priority) + let previousStats = snapshotFileStats(for: fileIndices, mutableStats: mutableFileStats, fallbackStats: fileStats) + mutableFileStats = applyLocalFilePriority(fileIndices: fileIndices, priority: priority, mutableStats: mutableFileStats, fallbackStats: fileStats) performTransmissionAction( operation: { @@ -296,61 +258,12 @@ private extension iOSTorrentFileDetail { onCommittedFileStatsMutation(fileIndices, .priority(priority)) }, onError: { message in - revertStats(previousStats) + mutableFileStats = applyFileStatsRevert(previousStats, into: mutableFileStats, fallback: fileStats) errorMessage = message showingError = true } ) } - - func snapshotStats(for fileIndices: [Int]) -> [(index: Int, stats: TorrentFileStats)] { - fileIndices.compactMap { fileIndex in - guard fileIndex < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { - return nil - } - - let currentStats = mutableFileStats.isEmpty ? fileStats[fileIndex] : mutableFileStats[fileIndex] - return (fileIndex, currentStats) - } - } - - func revertStats(_ previousStats: [(index: Int, stats: TorrentFileStats)]) { - if mutableFileStats.isEmpty { - mutableFileStats = fileStats - } - - for (fileIndex, previousStats) in previousStats where fileIndex < mutableFileStats.count { - mutableFileStats[fileIndex] = previousStats - } - } - - func updateLocalFileStatus(fileIndices: [Int], wanted: Bool) { - if mutableFileStats.isEmpty { - mutableFileStats = fileStats - } - - for fileIndex in fileIndices where fileIndex < mutableFileStats.count { - mutableFileStats[fileIndex] = TorrentFileStats( - bytesCompleted: mutableFileStats[fileIndex].bytesCompleted, - wanted: wanted, - priority: mutableFileStats[fileIndex].priority - ) - } - } - - func updateLocalFilePriority(fileIndices: [Int], priority: FilePriority) { - if mutableFileStats.isEmpty { - mutableFileStats = fileStats - } - - for fileIndex in fileIndices where fileIndex < mutableFileStats.count { - mutableFileStats[fileIndex] = TorrentFileStats( - bytesCompleted: mutableFileStats[fileIndex].bytesCompleted, - wanted: mutableFileStats[fileIndex].wanted, - priority: priority.rawValue - ) - } - } } #endif diff --git a/BitDream/Views/macOS/macOSContentDetail.swift b/BitDream/Views/macOS/macOSContentDetail.swift index 01bb548..828360f 100644 --- a/BitDream/Views/macOS/macOSContentDetail.swift +++ b/BitDream/Views/macOS/macOSContentDetail.swift @@ -350,18 +350,7 @@ struct TorrentDropDelegate: DropDelegate { ) return } - performTransmissionAction( - operation: { - try await store.addTorrent( - fileData: data, - saveLocation: store.defaultDownloadDir - ) - }, - onSuccess: { (_: TransmissionTorrentAddOutcome) in }, - onError: { message in - presentAddTorrentStoreError(detail: message, store: store) - } - ) + addTorrentFromFileData(data, store: store) } catch { Self.logger.error("Failed to read dropped torrent file \(url.lastPathComponent): \(error.localizedDescription)") } diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index dbc25e0..3a8d03b 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -158,37 +158,16 @@ struct macOSTorrentDetail: View { } ) } else { - switch supplementalStore.status { - case .idle: - TorrentDetailLoadingPlaceholderView( - title: "Loading Files", - message: "Fetching the latest files for this torrent." - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .task { - await loadSupplementalDataIfIdle(for: torrent.id) - } - case .loading: - TorrentDetailLoadingPlaceholderView( - title: "Loading Files", - message: "Fetching the latest files for this torrent." - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .failed: - TorrentDetailUnavailablePlaceholderView( - title: "Files Unavailable", - message: "The latest file details could not be loaded.", - actionTitle: "Retry", - action: { - Task { - await loadSupplementalData(for: torrent.id) - } - } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .loaded: - EmptyView() - } + TorrentDetailSupplementalPlaceholder( + status: supplementalStore.status, + loadingTitle: "Loading Files", + loadingMessage: "Fetching the latest files for this torrent.", + unavailableTitle: "Files Unavailable", + unavailableMessage: "The latest file details could not be loaded.", + onLoadIfIdle: { await loadSupplementalDataIfIdle(for: torrent.id) }, + onRetry: { Task { await loadSupplementalData(for: torrent.id) } } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -205,37 +184,16 @@ struct macOSTorrentDetail: View { onDone: { isShowingPeersSheet = false } ) } else { - switch supplementalStore.status { - case .idle: - TorrentDetailLoadingPlaceholderView( - title: "Loading Peers", - message: "Fetching the latest peers for this torrent." - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .task { - await loadSupplementalDataIfIdle(for: torrent.id) - } - case .loading: - TorrentDetailLoadingPlaceholderView( - title: "Loading Peers", - message: "Fetching the latest peers for this torrent." - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .failed: - TorrentDetailUnavailablePlaceholderView( - title: "Peers Unavailable", - message: "The latest peer details could not be loaded.", - actionTitle: "Retry", - action: { - Task { - await loadSupplementalData(for: torrent.id) - } - } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .loaded: - EmptyView() - } + TorrentDetailSupplementalPlaceholder( + status: supplementalStore.status, + loadingTitle: "Loading Peers", + loadingMessage: "Fetching the latest peers for this torrent.", + unavailableTitle: "Peers Unavailable", + unavailableMessage: "The latest peer details could not be loaded.", + onLoadIfIdle: { await loadSupplementalDataIfIdle(for: torrent.id) }, + onRetry: { Task { await loadSupplementalData(for: torrent.id) } } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } @@ -308,15 +266,14 @@ private struct MacOSTorrentDetailContent: View { } .padding(.bottom, 8) - if supplementalPayload.pieceCount > 0 && !supplementalPayload.piecesBitfieldBase64.isEmpty { + if supplementalPayload.pieceCount > 0 && !supplementalPayload.piecesHaveSet.isEmpty { GroupBox { VStack(alignment: .leading, spacing: 10) { macOSSectionHeader("Pieces", icon: "square.grid.2x2") VStack(alignment: .leading, spacing: 8) { PiecesGridView( - pieceCount: supplementalPayload.pieceCount, - piecesBitfieldBase64: supplementalPayload.piecesBitfieldBase64 + piecesHaveSet: supplementalPayload.piecesHaveSet ) .frame(maxWidth: .infinity) diff --git a/BitDream/Views/macOS/macOSTorrentFileDetail.swift b/BitDream/Views/macOS/macOSTorrentFileDetail.swift index a3d4b85..1133b0e 100644 --- a/BitDream/Views/macOS/macOSTorrentFileDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentFileDetail.swift @@ -251,9 +251,9 @@ private extension macOSTorrentFileDetail { func setFilesWanted(_ selectedRows: [TorrentFileRow], wanted: Bool) { let fileIndices = selectedRows.map(\.fileIndex) - let previousStats = snapshotStats(for: fileIndices) - - updateLocalFileStatus(selectedRows, wanted: wanted) + let previousStats = snapshotFileStats(for: fileIndices, mutableStats: mutableFileStats, fallbackStats: fileStats) + mutableFileStats = applyLocalFileWanted(fileIndices: fileIndices, wanted: wanted, mutableStats: mutableFileStats, fallbackStats: fileStats) + recomputeRows() performTransmissionAction( operation: { @@ -267,7 +267,8 @@ private extension macOSTorrentFileDetail { onCommittedFileStatsMutation(fileIndices, .wanted(wanted)) }, onError: { message in - revertStats(previousStats) + mutableFileStats = applyFileStatsRevert(previousStats, into: mutableFileStats, fallback: fileStats) + recomputeRows() errorMessage = message showingError = true } @@ -276,9 +277,9 @@ private extension macOSTorrentFileDetail { func setFilesPriority(_ selectedRows: [TorrentFileRow], priority: FilePriority) { let fileIndices = selectedRows.map(\.fileIndex) - let previousStats = snapshotStats(for: fileIndices) - - updateLocalFilePriority(selectedRows, priority: priority) + let previousStats = snapshotFileStats(for: fileIndices, mutableStats: mutableFileStats, fallbackStats: fileStats) + mutableFileStats = applyLocalFilePriority(fileIndices: fileIndices, priority: priority, mutableStats: mutableFileStats, fallbackStats: fileStats) + recomputeRows() performTransmissionAction( operation: { @@ -292,65 +293,13 @@ private extension macOSTorrentFileDetail { onCommittedFileStatsMutation(fileIndices, .priority(priority)) }, onError: { message in - revertStats(previousStats) + mutableFileStats = applyFileStatsRevert(previousStats, into: mutableFileStats, fallback: fileStats) + recomputeRows() errorMessage = message showingError = true } ) } - - func snapshotStats(for fileIndices: [Int]) -> [(index: Int, stats: TorrentFileStats)] { - fileIndices.compactMap { idx in - guard idx < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { - return nil - } - let current = mutableFileStats.isEmpty ? fileStats[idx] : mutableFileStats[idx] - return (idx, current) - } - } - - func revertStats(_ previousStats: [(index: Int, stats: TorrentFileStats)]) { - for (idx, old) in previousStats where idx < mutableFileStats.count { - mutableFileStats[idx] = old - } - recomputeRows() - } - - func updateLocalFileStatus(_ selectedRows: [TorrentFileRow], wanted: Bool) { - for row in selectedRows { - let idx = row.fileIndex - guard idx < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { continue } - let current = mutableFileStats.isEmpty ? fileStats[idx] : mutableFileStats[idx] - let updated = TorrentFileStats( - bytesCompleted: current.bytesCompleted, - wanted: wanted, - priority: current.priority - ) - if mutableFileStats.isEmpty { - mutableFileStats = fileStats - } - mutableFileStats[idx] = updated - } - recomputeRows() - } - - func updateLocalFilePriority(_ selectedRows: [TorrentFileRow], priority: FilePriority) { - for row in selectedRows { - let idx = row.fileIndex - guard idx < (mutableFileStats.isEmpty ? fileStats.count : mutableFileStats.count) else { continue } - let current = mutableFileStats.isEmpty ? fileStats[idx] : mutableFileStats[idx] - let updated = TorrentFileStats( - bytesCompleted: current.bytesCompleted, - wanted: current.wanted, - priority: priority.rawValue - ) - if mutableFileStats.isEmpty { - mutableFileStats = fileStats - } - mutableFileStats[idx] = updated - } - recomputeRows() - } } // MARK: - Header View From 678165b2e204cecc4e38aa186acbd495635a3004 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sun, 8 Mar 2026 17:01:32 -0700 Subject: [PATCH 8/8] add binding-based load API and DetailViewLabelTag Add new load(...) and loadIfIdle(...) overloads to TorrentDetailSupplementalStore that accept Binding and Binding for presenting transmission errors. Update iOS and macOS TorrentDetail views to use the new binding-based APIs and remove duplicated per-platform helper methods. Extract DetailViewLabelTag into the shared TorrentDetail file to avoid repeating the same UI component. Bump SwiftLint file_length warning threshold from 600 to 800. --- .swiftlint.yml | 2 +- BitDream/Views/Shared/TorrentDetail.swift | 55 +++++++++++++++++++ BitDream/Views/iOS/iOSTorrentDetail.swift | 50 ++--------------- BitDream/Views/macOS/macOSTorrentDetail.swift | 51 ++--------------- 4 files changed, 68 insertions(+), 90 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 21fcaaa..1af5cab 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -19,5 +19,5 @@ identifier_name: - "^id$" file_length: - warning: 600 + warning: 800 error: 1000 diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index 9eed2b3..accf7b3 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -277,6 +277,38 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { await load(for: torrentID, using: store, onError: onError) } + func load( + for torrentID: Int, + using store: TransmissionStore, + showingError: Binding, + errorMessage: Binding + ) async { + await load( + for: torrentID, + using: store, + onError: makeTransmissionBindingErrorHandler( + isPresented: showingError, + message: errorMessage + ) + ) + } + + func loadIfIdle( + for torrentID: Int, + using store: TransmissionStore, + showingError: Binding, + errorMessage: Binding + ) async { + await loadIfIdle( + for: torrentID, + using: store, + onError: makeTransmissionBindingErrorHandler( + isPresented: showingError, + message: errorMessage + ) + ) + } + @discardableResult private func markFailure(for torrentID: Int, generation: Int) -> Bool { mutateState { $0.markFailed(for: torrentID, generation: generation) } @@ -537,6 +569,29 @@ struct TorrentStatusBadge: View { } } +// Shared label tag component for detail views +struct DetailViewLabelTag: View { + let label: String + var isLarge: Bool = false + + var body: some View { + Text(label) + .font(isLarge ? .subheadline : .caption) + .fontWeight(.medium) + .padding(.horizontal, isLarge ? 8 : 6) + .padding(.vertical, isLarge ? 4 : 3) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.accentColor.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor.opacity(0.3), lineWidth: 1) + ) + .foregroundColor(.primary) + } +} + private func formatTorrentDetailDate(_ timestamp: Int) -> String { let date = Date(timeIntervalSince1970: Double(timestamp)) return date.formatted( diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index d44904a..a0ded51 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -33,7 +33,7 @@ struct iOSTorrentDetail: View { onDelete: { showingDeleteConfirmation = true } ) .task(id: torrent.id) { - await loadSupplementalData(for: torrent.id) + await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } .toolbar { TorrentDetailToolbar(torrent: torrent, store: store) @@ -72,22 +72,6 @@ struct iOSTorrentDetail: View { ) } - @MainActor - private func loadSupplementalData(for torrentID: Int) async { - await supplementalStore.load(for: torrentID, using: store) { message in - errorMessage = message - showingError = true - } - } - - @MainActor - private func loadSupplementalDataIfIdle(for torrentID: Int) async { - await supplementalStore.loadIfIdle(for: torrentID, using: store) { message in - errorMessage = message - showingError = true - } - } - @MainActor private func applyCommittedFileStatsMutation( fileIndices: [Int], @@ -123,8 +107,8 @@ struct iOSTorrentDetail: View { loadingMessage: "Fetching the latest files for this torrent.", unavailableTitle: "Files Unavailable", unavailableMessage: "The latest file details could not be loaded.", - onLoadIfIdle: { await loadSupplementalDataIfIdle(for: torrent.id) }, - onRetry: { Task { await loadSupplementalData(for: torrent.id) } } + onLoadIfIdle: { await supplementalStore.loadIfIdle(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, + onRetry: { Task { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } } ) .navigationTitle("Files") .navigationBarTitleDisplayMode(.inline) @@ -140,7 +124,7 @@ struct iOSTorrentDetail: View { store: store, peers: supplementalPayload.peers, peersFrom: supplementalPayload.peersFrom, - onRefresh: { await loadSupplementalData(for: torrent.id) }, + onRefresh: { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, onDone: { /* no-op in push */ } ) .navigationBarTitleDisplayMode(.inline) @@ -151,8 +135,8 @@ struct iOSTorrentDetail: View { loadingMessage: "Fetching the latest peers for this torrent.", unavailableTitle: "Peers Unavailable", unavailableMessage: "The latest peer details could not be loaded.", - onLoadIfIdle: { await loadSupplementalDataIfIdle(for: torrent.id) }, - onRetry: { Task { await loadSupplementalData(for: torrent.id) } } + onLoadIfIdle: { await supplementalStore.loadIfIdle(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, + onRetry: { Task { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } } ) .navigationTitle("Peers") .navigationBarTitleDisplayMode(.inline) @@ -307,26 +291,4 @@ private struct IOSTorrentDetailContent: View { var label: String