diff --git a/.swiftlint.yml b/.swiftlint.yml index 0b2f06b..1af5cab 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,8 +4,8 @@ disabled_rules: excluded: - build + - .build # TODO: Refactor these files and remove the temporary lint exclusion. - - BitDream/Transmission/TransmissionFunctions.swift - BitDream/TransmissionStore.swift type_name: @@ -19,5 +19,5 @@ identifier_name: - "^id$" file_length: - warning: 600 + warning: 800 error: 1000 diff --git a/BitDream.xcodeproj/project.pbxproj b/BitDream.xcodeproj/project.pbxproj index 68aa7b2..b317fe6 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 */; }; @@ -97,10 +96,10 @@ 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 */; }; - 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 +110,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 +151,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 = ""; }; @@ -236,10 +233,10 @@ 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 = ""; }; - 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 +248,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 +341,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 +384,6 @@ 4B1DADFC295E6F0F0037E9FB /* Transmission */ = { isa = PBXGroup; children = ( - 4B1DADFF295E6F600037E9FB /* TransmissionFunctions.swift */, - 7B1000084300000000A1B2C3 /* TransmissionLegacyConnectionInfo.swift */, 5A1000064100000000A1B2C3 /* TransmissionModels.swift */, 5A1000224100000000A1B2C3 /* TransmissionTransport.swift */, 5A1000244100000000A1B2C3 /* TransmissionConnection.swift */, @@ -425,6 +419,7 @@ 4B642D7A2D7E1EF6003CEADC /* iOSServerList.swift */, 4BC5C2B12D7D415100D80AB4 /* iOSTorrentDetail.swift */, 4B6F1EBD2D86C926003D8F6E /* iOSTorrentFileDetail.swift */, + 4F7000034000000000A1B2C3 /* iOSTorrentFileDetailControls.swift */, 4F7000014000000000A1B2C3 /* iOSTorrentFileRow.swift */, 4B6F1EB32D82DB2D003D8F6E /* iOSTorrentListRow.swift */, 4BF593BA2E89C8C400A8C1FC /* iOSTorrentPeerDetail.swift */, @@ -474,8 +469,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 +745,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,18 +767,18 @@ 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 */, 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 */, 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..c2a77ef 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,6 +144,13 @@ 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 { @@ -155,7 +179,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 +199,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 +237,9 @@ struct BitDreamApp: App { ) } .modelContainer(persistenceController.container) + } + + var connectionInfoScene: some Scene { WindowGroup("Connection Info", id: "connection-info") { macOSConnectionInfoView() .environmentObject(store) @@ -223,7 +250,9 @@ struct BitDreamApp: App { } .windowResizability(.contentSize) .modelContainer(persistenceController.container) + } + var statisticsScene: some Scene { WindowGroup("Statistics", id: "statistics") { macOSStatisticsView() .environmentObject(store) @@ -234,7 +263,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 +279,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 +310,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/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..d4a6398 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] @@ -18,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", @@ -26,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) @@ -52,6 +52,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 +125,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..94ba3b7 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 { @@ -19,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 @@ -59,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? @@ -121,12 +133,17 @@ final class TransmissionStore: NSObject, ObservableObject { // Confirmation dialog state for menu remove command @Published var showingMenuRemoveConfirmation = false - private let connectionFactory: TransmissionConnectionFactory + // 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 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? @@ -145,13 +162,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 @@ -255,34 +275,230 @@ 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) 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 await awaitActiveConnection() + 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() @@ -399,19 +615,33 @@ 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 + } + + let nextAvailableLabels = normalizedToDisplay.values.sorted { + $0.localizedCaseInsensitiveCompare($1) == .orderedAscending + } + + guard counts != labelCounts || nextAvailableLabels != availableLabels else { return } + + labelCounts = counts + availableLabels = nextAvailableLabels } func requestRefresh() { @@ -461,6 +691,7 @@ extension TransmissionStore { resetReconnectBackoff() } + activationFailure = nil torrents = [] sessionStats = nil sessionConfiguration = nil @@ -475,7 +706,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 } @@ -487,9 +718,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) } } @@ -650,12 +894,59 @@ extension TransmissionStore { currentConnectionGeneration == generation && host?.serverID == hostID } - private func requireActiveConnection() throws -> ActiveConnection { - guard let activeConnection else { - throw CancellationError() + private func awaitActiveConnection() async throws -> ActiveConnection { + if let activeConnection { + try ensureCurrent(activeConnection) + return activeConnection + } + + guard let host else { + throw TransmissionError.invalidEndpointConfiguration + } + + let waitingGeneration = currentConnectionGeneration + let waitingHostID = host.serverID + + if let activationTask { + await activationTask.value + + guard isCurrentGeneration(waitingGeneration, hostID: waitingHostID) else { + throw CancellationError() + } + + if let activationFailure, + activationFailure.generation == waitingGeneration { + throw activationFailure.error + } + + guard let activeConnection else { + throw CancellationError() + } + + try ensureCurrent(activeConnection) + return activeConnection + } + + if let activationFailure, + activationFailure.generation == waitingGeneration, + isCurrentGeneration(waitingGeneration, hostID: waitingHostID) { + throw activationFailure.error } - return activeConnection + throw CancellationError() + } + + private func performConnectionOperation( + refreshOnSuccess: Bool = false, + operation: (ActiveConnection) async throws -> Result + ) async throws -> Result { + let connectionState = try await awaitActiveConnection() + let result = try await operation(connectionState) + try ensureCurrent(connectionState) + if refreshOnSuccess { + requestRefresh() + } + return result } private func ensureCurrent(_ connectionState: ActiveConnection) throws { diff --git a/BitDream/Views/Shared/AddTorrent.swift b/BitDream/Views/Shared/AddTorrent.swift index 0e9767a..de8be3b 100644 --- a/BitDream/Views/Shared/AddTorrent.swift +++ b/BitDream/Views/Shared/AddTorrent.swift @@ -25,37 +25,50 @@ 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 } + handleAddTorrentError( + TransmissionActionFailureContext.addTorrent.inlineMessage(detail: detail), + errorMessage: errorMessage, + showingError: showingError + ) +} - // 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?() - } +@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 +} + +@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) } ) } @@ -69,51 +82,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/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/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..accf7b3 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -16,6 +16,414 @@ struct TorrentDetail: View { // MARK: - Shared Helpers +internal enum TorrentDetailSupplementalLoadStatus: Sendable, Equatable { + case idle + case loading + case loaded + case failed +} + +internal enum TorrentDetailFileStatsMutation: Sendable, Equatable { + case wanted(Bool) + case priority(FilePriority) +} + +internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { + let files: [TorrentFile] + let fileStats: [TorrentFileStats] + let peers: [Peer] + let peersFrom: PeersFrom? + let pieceCount: Int + let pieceSize: Int64 + let piecesHaveSet: [Bool] + let piecesHaveCount: Int + + static let empty = TorrentDetailSupplementalPayload( + files: [], + fileStats: [], + peers: [], + peersFrom: nil, + pieceCount: 0, + pieceSize: 0, + piecesHaveSet: [], + piecesHaveCount: 0 + ) + + init( + files: [TorrentFile], + fileStats: [TorrentFileStats], + peers: [Peer], + peersFrom: PeersFrom?, + pieceCount: Int, + pieceSize: Int64, + piecesHaveSet: [Bool], + piecesHaveCount: Int + ) { + self.files = files + self.fileStats = fileStats + self.peers = peers + self.peersFrom = peersFrom + self.pieceCount = pieceCount + self.pieceSize = pieceSize + self.piecesHaveSet = piecesHaveSet + 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, + piecesHaveSet: haveSet, + 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, + piecesHaveSet: piecesHaveSet, + piecesHaveCount: piecesHaveCount + ) + } +} + +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 + + func shouldDisplayPayload(for torrentID: Int) -> Bool { + activeTorrentID == torrentID && hasLoadedPayload + } + + func visiblePayload(for torrentID: Int) -> TorrentDetailSupplementalPayload { + shouldDisplayPayload(for: torrentID) ? payload : .empty + } + + @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 + } + + @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 + } + + @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 + } + + var status: TorrentDetailSupplementalLoadStatus { + state.status + } + + func payload(for torrentID: Int) -> TorrentDetailSupplementalPayload { + state.visiblePayload(for: torrentID) + } + + func shouldDisplayPayload(for torrentID: Int) -> Bool { + 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, + 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 { + _ = markCancellation(for: torrentID, generation: requestGeneration) + return + } + + mutateState { state in + _ = state.apply( + snapshot: snapshot, + for: torrentID, + generation: requestGeneration + ) + } + } + + func loadIfIdle( + for torrentID: Int, + using store: TransmissionStore, + onError: @escaping @MainActor @Sendable (String) -> Void + ) async { + guard !state.shouldDisplayPayload(for: torrentID) else { + return + } + + guard state.status == .idle else { + return + } + + 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) } + } + + @discardableResult + private func markCancellation(for torrentID: Int, generation: Int) -> Bool { + mutateState { $0.markCancelled(for: torrentID, generation: generation) } + } + + @discardableResult + private func mutateState( + _ mutate: (inout TorrentDetailSupplementalState) -> Result + ) -> Result { + var nextState = state + let result = mutate(&nextState) + state = nextState + return result + } +} + +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 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 + + 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 + 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) + } + } + } +} + // Shared function to determine torrent status color func statusColor(for torrent: Torrent) -> Color { if torrent.statusCalc == TorrentStatusCalc.complete || torrent.statusCalc == TorrentStatusCalc.seeding { @@ -31,44 +439,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 +527,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") @@ -191,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/Shared/TorrentFileDetail.swift b/BitDream/Views/Shared/TorrentFileDetail.swift index b7e2029..3a7bda0 100644 --- a/BitDream/Views/Shared/TorrentFileDetail.swift +++ b/BitDream/Views/Shared/TorrentFileDetail.swift @@ -132,16 +132,103 @@ 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 } } +// 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 @@ -282,51 +369,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..a0ded51 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -8,27 +8,153 @@ 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(for: torrent.id) + } + + private var shouldDisplaySupplementalPayload: Bool { + supplementalStore.shouldDisplayPayload(for: torrent.id) + } 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 supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) + } + .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 applyCommittedFileStatsMutation( + fileIndices: [Int], + mutation: TorrentDetailFileStatsMutation + ) { + supplementalStore.applyCommittedFileStatsMutation( + mutation, + for: torrent.id, + fileIndices: fileIndices + ) + } + + @ViewBuilder + private var filesDestination: some View { + if shouldDisplaySupplementalPayload { + iOSTorrentFileDetail( + files: supplementalPayload.files, + fileStats: supplementalPayload.fileStats, + torrentId: torrent.id, + store: store, + onCommittedFileStatsMutation: { fileIndices, mutation in + applyCommittedFileStatsMutation( + fileIndices: fileIndices, + mutation: mutation + ) + } + ) + .navigationBarTitleDisplayMode(.inline) + } else { + 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 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) + } + } + + @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 supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, + onDone: { /* no-op in push */ } + ) + .navigationBarTitleDisplayMode(.inline) + } else { + 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 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) + } + } +} + +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,30 +180,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: { - fetchTorrentPeers(transferId: torrent.id, store: store) { fetchedPeers, fetchedFrom in - peers = fetchedPeers - peersFrom = fetchedFrom - } - }, - onDone: { /* no-op in push */ } - ) - .navigationBarTitleDisplayMode(.inline) + peersDestination } label: { - LabeledContent("Peers", value: "\(peers.count)") + LabeledContent("Peers", value: "\(supplementalPayload.peers.count)") } } @@ -114,15 +231,19 @@ struct iOSTorrentDetail: View { } } - // Pieces section - if pieceCount > 0 && !piecesBitfield.isEmpty { + if supplementalPayload.pieceCount > 0 && !supplementalPayload.piecesHaveSet.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( + piecesHaveSet: supplementalPayload.piecesHaveSet + ) + .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)) } @@ -143,7 +264,6 @@ struct iOSTorrentDetail: View { } } - // Beautiful Dedicated Labels Section (Display Only) if !torrent.labels.isEmpty { Section(header: Text("Labels")) { FlowLayout(spacing: 6) { @@ -156,9 +276,7 @@ struct iOSTorrentDetail: View { } } - Button(role: .destructive, action: { - showingDeleteConfirmation = true - }, label: { + Button(role: .destructive, action: onDelete) { HStack { HStack { Image(systemName: "trash") @@ -166,95 +284,11 @@ struct iOSTorrentDetail: View { Spacer() } } - }) - } - } - .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) } - } - } - .toolbar { - // Use shared toolbar - TorrentDetailToolbar(torrent: torrent, store: store) - } - .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 - } - ) - }) - } 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 - } - ) - }) + } } - Button("Cancel", role: .cancel) { } - } message: { - Text("Do you want to delete the file(s) from the disk?") } - .transmissionErrorAlert(isPresented: $showingDeleteError, message: deleteErrorMessage) } - } } -// Enhanced LabelTag 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) - } -} #endif diff --git a/BitDream/Views/iOS/iOSTorrentFileDetail.swift b/BitDream/Views/iOS/iOSTorrentFileDetail.swift index b2be7f6..cfbbb31 100644 --- a/BitDream/Views/iOS/iOSTorrentFileDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentFileDetail.swift @@ -44,13 +44,27 @@ 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 = "" @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,9 +77,10 @@ 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 + @State private var errorMessage = "" private var fileRows: [TorrentFileRow] { let processedFiles = processFilesForDisplay(files, stats: mutableFileStats.isEmpty ? fileStats : mutableFileStats) @@ -91,7 +106,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) { @@ -99,16 +113,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 } @@ -135,7 +146,6 @@ struct iOSTorrentFileDetail: View { ) } - // File count footer as a List section Section { EmptyView() } footer: { @@ -160,11 +170,8 @@ struct iOSTorrentFileDetail: View { selectedCount: selectedFileIds.count, selectedFileIds: $selectedFileIds, allFileRows: filteredAndSortedFileRows, - torrentId: torrentId, - store: store, - updateFileStatus: updateLocalFileStatus, - updateFilePriority: updateLocalFilePriority, - revertData: revertToOriginalData + setBulkWanted: setBulkWanted, + setBulkPriority: setBulkPriority ) } } @@ -196,374 +203,65 @@ struct iOSTorrentFileDetail: View { .onAppear { mutableFileStats = fileStats } - } - - // MARK: - File Operations - - 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 { - revertToOriginalData() - } - } - } - - 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 { - revertToOriginalData() - } + .onChange(of: fileStats) { _, newValue in + mutableFileStats = newValue } - } - - // MARK: - Optimistic Updates - - 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 - ) - } - - 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 - ) - } - - private func revertToOriginalData() { - mutableFileStats = fileStats + .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) } } -// MARK: - Bulk Action Toolbar - -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 - - 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() - } else { - selectedFileIds = Set(allFileRows.map { $0.id }) - } - } - .font(.subheadline) - - // Actions menu - EXACTLY same as context menu - 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 extension iOSTorrentFileDetail { + func setFileWanted(_ row: TorrentFileRow, wanted: Bool) { + setBulkWanted(fileIndices: [row.fileIndex], wanted: wanted) } - 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) - } - - let info = makeConfig(store: store) - setFilePriority( - torrentId: torrentId, - fileIndices: fileIndices, - priority: priority, - info: info - ) { response in - if response != .success { - // Revert on failure - revertData() - } - } - } - - 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) - } - - let info = makeConfig(store: store) - setFileWantedStatus( - torrentId: torrentId, - fileIndices: fileIndices, - wanted: wanted, - info: info - ) { response in - if response != .success { - // Revert on failure - revertData() - } - } + func setFilePriority(_ row: TorrentFileRow, priority: FilePriority) { + setBulkPriority(fileIndices: [row.fileIndex], priority: priority) } -} - -// 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) { - // Filter button - 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) - } - - // Sort menu - Menu { - // Sort properties - ForEach(FileSortProperty.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") - } - } - } + func setBulkWanted(fileIndices: [Int], wanted: Bool) { + let previousStats = snapshotFileStats(for: fileIndices, mutableStats: mutableFileStats, fallbackStats: fileStats) + mutableFileStats = applyLocalFileWanted(fileIndices: fileIndices, wanted: wanted, mutableStats: mutableFileStats, fallbackStats: fileStats) - 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() - - // Edit button - separated on the right - 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) + performTransmissionAction( + operation: { + try await store.setFileWantedStatus( + torrentId: torrentId, + fileIndices: fileIndices, + wanted: wanted + ) + }, + onSuccess: { + onCommittedFileStatsMutation(fileIndices, .wanted(wanted)) + }, + onError: { message in + mutableFileStats = applyFileStatsRevert(previousStats, into: mutableFileStats, fallback: fileStats) + errorMessage = message + showingError = true } - } - .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) - } + func setBulkPriority(fileIndices: [Int], priority: FilePriority) { + let previousStats = snapshotFileStats(for: fileIndices, mutableStats: mutableFileStats, fallbackStats: fileStats) + mutableFileStats = applyLocalFilePriority(fileIndices: fileIndices, priority: priority, mutableStats: mutableFileStats, fallbackStats: fileStats) - 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() - } - } + performTransmissionAction( + operation: { + try await store.setFilePriority( + torrentId: torrentId, + fileIndices: fileIndices, + priority: priority + ) + }, + onSuccess: { + onCommittedFileStatsMutation(fileIndices, .priority(priority)) + }, + onError: { message in + mutableFileStats = applyFileStatsRevert(previousStats, into: mutableFileStats, fallback: fileStats) + errorMessage = message + showingError = true } - } - } -} - -// MARK: - Preview - -#Preview("iOS Torrent Files") { - NavigationView { - iOSTorrentFileDetail( - files: TorrentFilePreviewData.sampleFiles, - fileStats: TorrentFilePreviewData.sampleFileStats, - torrentId: 1, - store: TransmissionStore() ) } } 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/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..828360f 100644 --- a/BitDream/Views/macOS/macOSContentDetail.swift +++ b/BitDream/Views/macOS/macOSContentDetail.swift @@ -343,6 +343,13 @@ struct TorrentDropDelegate: DropDelegate { Task { @MainActor in do { let data = try await Self.readTorrentData(from: url) + guard store.host != nil else { + presentAddTorrentStoreError( + detail: addTorrentNoServerConfiguredMessage, + store: store + ) + return + } addTorrentFromFileData(data, 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..52d7607 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 + ) } } @@ -287,73 +294,40 @@ 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 - ) - - 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( "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: { @@ -361,40 +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 - } - renameTorrentRoot(torrent: targetTorrent, to: newName, store: store) { err in - if let err = err { - errorMessage = err - showingError = true - } else { - renameDialog = false - } - } - } - ) - .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( @@ -420,6 +371,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) { @@ -428,10 +381,16 @@ 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 + shouldSave: $shouldSave, + onError: { message in + errorMessage = message + showingError = true + } ) HStack { @@ -482,14 +441,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 +473,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 +500,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..2059ff2 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -8,68 +8,222 @@ 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(for: torrent.id) + } + + private var shouldDisplaySupplementalPayload: Bool { + supplementalStore.shouldDisplayPayload(for: torrent.id) + } 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 supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) + } + .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 applyCommittedFileStatsMutation( + fileIndices: [Int], + mutation: TorrentDetailFileStatsMutation + ) { + supplementalStore.applyCommittedFileStatsMutation( + mutation, + for: torrent.id, + fileIndices: fileIndices + ) + } + + 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, + onCommittedFileStatsMutation: { fileIndices, mutation in + applyCommittedFileStatsMutation( + fileIndices: fileIndices, + mutation: mutation + ) + } + ) + } else { + 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 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) } } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @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 supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, + onDone: { isShowingPeersSheet = false } + ) + } else { + 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 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) } } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +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 +236,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 +250,22 @@ struct macOSTorrentDetail: View { } .padding(.bottom, 8) - // Pieces section - if pieceCount > 0 && !piecesBitfield.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: pieceCount, piecesBitfieldBase64: piecesBitfield) - .frame(maxWidth: .infinity) - Text("\(piecesHaveCount) of \(pieceCount) pieces • \(formatByteCount(pieceSize)) each") - .font(.caption) - .foregroundColor(.secondary) + PiecesGridView( + piecesHaveSet: supplementalPayload.piecesHaveSet + ) + .frame(maxWidth: .infinity) + + Text( + "\(supplementalPayload.piecesHaveCount) of \(supplementalPayload.pieceCount) pieces • \(formatByteCount(supplementalPayload.pieceSize)) each" + ) + .font(.caption) + .foregroundColor(.secondary) } } .padding(.vertical, 16) @@ -119,12 +274,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 +285,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,168 +303,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: { - fetchTorrentPeers(transferId: torrent.id, store: store) { fetchedPeers, fetchedFrom in - peers = fetchedPeers - peersFrom = fetchedFrom - } - }, - onDone: { isShowingPeersSheet = false } - ) - .frame(minWidth: 1000, minHeight: 700) - } - .task(id: torrent.id) { - loadSupplementalData() - } - .toolbar { - // Use shared toolbar - TorrentDetailToolbar(torrent: torrent, store: store) - } - .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 - } - ) - }) - } 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 - } - ) - }) - } - Button("Cancel", role: .cancel) { } - } message: { - Text("Do you want to delete the file(s) from the disk?") - } - .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 - } - - fetchTorrentPeers(transferId: torrent.id, store: store) { fetchedPeers, fetchedFrom in - peers = fetchedPeers - peersFrom = fetchedFrom - } - - 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) } - } - } -} - -// Enhanced LabelTag 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) } } diff --git a/BitDream/Views/macOS/macOSTorrentEdit.swift b/BitDream/Views/macOS/macOSTorrentEdit.swift index e3bb067..59f13be 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,37 @@ 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 = bulkLabelUpdates( + for: selectedTorrents, + existingLabels: existingLabels, + workingLabels: workingLabels + ) + + guard !updates.isEmpty else { + dismiss() + return } - store.requestRefresh() - dismiss() + + performTransmissionAction( + operation: { try await store.updateTorrentLabels(updates) }, + onSuccess: { + dismiss() + }, + onError: onError + ) } } @@ -209,22 +227,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) @@ -235,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 6489f4f..1133b0e 100644 --- a/BitDream/Views/macOS/macOSTorrentFileDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentFileDetail.swift @@ -35,13 +35,64 @@ 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"]) @State private var mutableFileStats: [TorrentFileStats] = [] @State private var cachedRows: [TorrentFileRow] = [] + @State private var showingError = false + @State private var errorMessage = "" + + 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 func recomputeRows() { +private extension macOSTorrentFileDetail { + func recomputeRows() { let statsToUse = mutableFileStats.isEmpty ? fileStats : mutableFileStats let processed = processFilesForDisplay(files, stats: statsToUse) cachedRows = processed.map { processed in @@ -57,15 +108,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 +125,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 +148,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 +212,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,116 +249,56 @@ struct macOSTorrentFileDetail: View { } } - 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() - } - } - - // 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) - } - - updateLocalFileStatus(selectedRows, wanted: wanted) - - 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() - } - } - } - - 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]) - return (idx, current) - } - - updateLocalFilePriority(selectedRows, priority: priority) + func setFilesWanted(_ selectedRows: [TorrentFileRow], wanted: Bool) { + let fileIndices = selectedRows.map(\.fileIndex) + let previousStats = snapshotFileStats(for: fileIndices, mutableStats: mutableFileStats, fallbackStats: fileStats) + mutableFileStats = applyLocalFileWanted(fileIndices: fileIndices, wanted: wanted, mutableStats: mutableFileStats, fallbackStats: fileStats) + recomputeRows() - 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 - } + performTransmissionAction( + operation: { + try await store.setFileWantedStatus( + torrentId: torrentId, + fileIndices: fileIndices, + wanted: wanted + ) + }, + onSuccess: { + onCommittedFileStatsMutation(fileIndices, .wanted(wanted)) + }, + onError: { message in + mutableFileStats = applyFileStatsRevert(previousStats, into: mutableFileStats, fallback: fileStats) recomputeRows() + errorMessage = message + showingError = true } - } + ) } - private func updateLocalFileStatus(_ selectedRows: [TorrentFileRow], wanted: Bool) { - // Update local data optimistically by mutating stats - 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 - } + func setFilesPriority(_ selectedRows: [TorrentFileRow], priority: FilePriority) { + let fileIndices = selectedRows.map(\.fileIndex) + let previousStats = snapshotFileStats(for: fileIndices, mutableStats: mutableFileStats, fallbackStats: fileStats) + mutableFileStats = applyLocalFilePriority(fileIndices: fileIndices, priority: priority, mutableStats: mutableFileStats, fallbackStats: fileStats) recomputeRows() - } - private func updateLocalFilePriority(_ selectedRows: [TorrentFileRow], priority: FilePriority) { - // Update local data optimistically by mutating stats - 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 + performTransmissionAction( + operation: { + try await store.setFilePriority( + torrentId: torrentId, + fileIndices: fileIndices, + priority: priority + ) + }, + onSuccess: { + onCommittedFileStatsMutation(fileIndices, .priority(priority)) + }, + onError: { message in + mutableFileStats = applyFileStatsRevert(previousStats, into: mutableFileStats, fallback: fileStats) + recomputeRows() + errorMessage = message + showingError = true } - mutableFileStats[idx] = updated - } - recomputeRows() + ) } } 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/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 d0bfea8..b0ec605 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,17 +366,54 @@ private func makeTorrentPeersSuccessBody() -> 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 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 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 b6b2ba9..f738256 100644 --- a/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift +++ b/BitDreamTests/Transmission/Connection/TransmissionConnectionTests.swift @@ -131,4 +131,99 @@ 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]) + } + } } 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..357de7c 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" @@ -359,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/TransmissionStoreSessionOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreSessionOperationTests.swift index 71f2253..6871552 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,94 @@ 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)) + } + } + + 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 { @@ -156,7 +273,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 +287,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 +340,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 new file mode 100644 index 0000000..0912b76 --- /dev/null +++ b/BitDreamTests/TransmissionStore/TransmissionStoreTorrentOperationTests.swift @@ -0,0 +1,525 @@ +import Foundation +import XCTest +@testable import BitDream + +@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": [ + .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 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": [ + .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 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 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")) + ] + ] + ]) + } + +} + +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 makeTorrentAddSuccessBody() -> String { + """ + { + "arguments": { + "torrent-added": { + "hashString": "abcdef1234567890", + "id": 99, + "name": "Ubuntu.iso" + } + }, + "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/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/TorrentDetailSupplementalStateTests.swift b/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift new file mode 100644 index 0000000..f7685e0 --- /dev/null +++ b/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift @@ -0,0 +1,314 @@ +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(for: 42)) + } + + 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(for: 7)) + } + + 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(for: 2)) + } + + 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(for: 11)) + } + + 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(for: 12)) + } + + 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(for: 11)) + } + + 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(for: 4)) + } + + 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(for: 7)) + } + + 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(for: 9)) + } + + 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(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) + } +} + +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 + ) +} diff --git a/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift b/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift new file mode 100644 index 0000000..b30c8e0 --- /dev/null +++ b/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift @@ -0,0 +1,277 @@ +import XCTest +@testable import BitDream + +@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() + + 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 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) + 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: "" + ) + } +} 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 + } + } +}