From b5d619ed5bab02ce2deb0cfdf8a7b1261922f814 Mon Sep 17 00:00:00 2001 From: Daniil Vinogradov Date: Tue, 2 Jul 2024 00:51:31 +0200 Subject: [PATCH] Storage containers implemented WIP: Select download path Storage containers implemented Rework torrents Array to Dictionary --- ProgressWidget/ProgressWidgetAttributes.swift | 3 + .../ProgressWidgetLiveActivity.swift | 6 + Submodules/LibTorrent-Swift | 2 +- Submodules/MVVMFoundation | 2 +- iTorrent.xcodeproj/project.pbxproj | 60 +++- .../xcshareddata/swiftpm/Package.resolved | 168 +++++++++++ iTorrent/Base/BaseViewController.swift | 56 ++++ .../Cells/DetailCell/DetailCellView.swift | 4 +- .../DetailCell/DetailCellViewModel.swift | 6 +- .../AppDelegate+Notifications.swift | 2 +- iTorrent/Core/Assets/Localizable.xcstrings | 273 ++++++++++++++++++ .../SceneDelegate+URLProcessing.swift | 2 +- .../Core/SceneDelegate/SceneDelegate.swift | 2 + .../Root/Cells/PRButton/PRButtonView.swift | 11 + .../Cells/PRButton/PRButtonViewModel.swift | 20 +- .../Root/PreferencesViewModel.swift | 8 +- .../Storage/StoragePreferencesView.swift | 187 ++++++++++++ .../TorrentAdd/TorrentAddViewController.swift | 50 +++- .../TorrentAdd/TorrentAddViewModel.swift | 16 +- .../TorrentDetailsViewController.swift | 9 +- .../TorrentDetailsViewModel.swift | 69 ++++- .../TorrentFilesFileItemViewModel.swift | 2 +- .../TorrentFilesFileListCell.swift | 10 +- .../TorrentFilesViewController.swift | 7 +- .../TorrentFiles/TorrentFilesViewModel.swift | 4 + .../TorrentListViewController.swift | 13 +- .../TorrentList/TorrentListViewModel.swift | 7 +- .../BackgroundService/BackgroundService.swift | 2 +- .../IntentsService/IntentsService.swift | 2 +- .../LiveActivityService.swift | 4 +- .../Preferences/PreferencesStorage.swift | 21 +- .../Services/TorrentMonitoringService.swift | 10 + .../Extensions/StorageModel+Extensions.swift | 23 ++ .../TorrentHandle+Extension.swift | 22 ++ .../TorrentService/TorrentService.swift | 84 +++--- .../Extensions/Combine/Publisher+UI.swift | 16 +- .../UIKit/UICellAccessory+Image.swift | 25 ++ .../Extensions/UIMenu/UIMenu+Priority.swift | 2 +- .../Utils/Extensions/URL+Extensions.swift | 14 + .../Utils/SANavigation/SAViewController.swift | 11 + iTorrent/Utils/UserDefaultItem.swift | 15 +- 41 files changed, 1138 insertions(+), 112 deletions(-) create mode 100644 iTorrent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 iTorrent/Screens/Preferences/Storage/StoragePreferencesView.swift create mode 100644 iTorrent/Services/TorrentService/Extensions/StorageModel+Extensions.swift rename iTorrent/Services/TorrentService/{ => Extensions}/TorrentHandle+Extension.swift (92%) create mode 100644 iTorrent/Utils/Extensions/UIKit/UICellAccessory+Image.swift create mode 100644 iTorrent/Utils/Extensions/URL+Extensions.swift diff --git a/ProgressWidget/ProgressWidgetAttributes.swift b/ProgressWidget/ProgressWidgetAttributes.swift index 33f5cb39..0a26434a 100644 --- a/ProgressWidget/ProgressWidgetAttributes.swift +++ b/ProgressWidget/ProgressWidgetAttributes.swift @@ -18,6 +18,7 @@ extension ProgressWidgetAttributes { case seeding case checkingResumeData case paused + case storageError } } @@ -38,6 +39,8 @@ extension ProgressWidgetAttributes.State { return %"torrent.state.resuming" case .paused: return %"torrent.state.paused" + case .storageError: + return %"torrent.state.storageError" } } } diff --git a/ProgressWidget/ProgressWidgetLiveActivity.swift b/ProgressWidget/ProgressWidgetLiveActivity.swift index fa42630d..b808bc9b 100644 --- a/ProgressWidget/ProgressWidgetLiveActivity.swift +++ b/ProgressWidget/ProgressWidgetLiveActivity.swift @@ -149,6 +149,8 @@ struct ProgressWidgetLiveActivityWatchSupportContent: View { case .paused: Text(context.state.state.name) Spacer() + case .storageError: + EmptyView() } } .font(.caption2) @@ -208,6 +210,8 @@ struct ProgressWidgetLiveActivityContent: View { Text(context.state.state.name) case .paused: Text(context.state.state.name) + case .storageError: + EmptyView() } Spacer() @@ -253,6 +257,8 @@ struct LeadingView: View { Image(systemName: "arrow.triangle.2.circlepath") case .paused: Image(systemName: "pause.fill") + case .storageError: + EmptyView() } } } diff --git a/Submodules/LibTorrent-Swift b/Submodules/LibTorrent-Swift index e7121a7c..a4aab2d2 160000 --- a/Submodules/LibTorrent-Swift +++ b/Submodules/LibTorrent-Swift @@ -1 +1 @@ -Subproject commit e7121a7c0a906e7d4a9d739686e36612979cce43 +Subproject commit a4aab2d266589124ec347b1e9dd6635ae667da81 diff --git a/Submodules/MVVMFoundation b/Submodules/MVVMFoundation index 9612851b..74194bf1 160000 --- a/Submodules/MVVMFoundation +++ b/Submodules/MVVMFoundation @@ -1 +1 @@ -Subproject commit 9612851bdc7810f5666702f585d81af39bd7553f +Subproject commit 74194bf191ee89482dd086fbd3a78167fc82c22d diff --git a/iTorrent.xcodeproj/project.pbxproj b/iTorrent.xcodeproj/project.pbxproj index 506b93ed..4dae71ad 100644 --- a/iTorrent.xcodeproj/project.pbxproj +++ b/iTorrent.xcodeproj/project.pbxproj @@ -51,6 +51,8 @@ 7C5FBE702BC2EF8B0069E5A0 /* EditTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5FBE6D2BC2EF8B0069E5A0 /* EditTextViewController.swift */; }; 7C5FBE742BC2F0A30069E5A0 /* MvvmViewModelProtocol+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5FBE732BC2F0A30069E5A0 /* MvvmViewModelProtocol+Alert.swift */; }; 7C609A5B2C1F5D9E00586635 /* SceneDelegate+AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C609A5A2C1F5D9E00586635 /* SceneDelegate+AVPlayer.swift */; }; + 7C95B7B42C35DE5C000EC50F /* StoragePreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95B7B32C35DE49000EC50F /* StoragePreferencesView.swift */; }; + 7C95B7B72C385B99000EC50F /* UICellAccessory+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95B7B62C385B97000EC50F /* UICellAccessory+Image.swift */; }; 7CAD30192BC3455800592990 /* SWXMLHash in Frameworks */ = {isa = PBXBuildFile; productRef = 7CAD30182BC3455800592990 /* SWXMLHash */; }; 7CAD301C2BC3457900592990 /* RssModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAD301B2BC3457900592990 /* RssModel.swift */; }; 7CAD301E2BC347D900592990 /* Published+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAD301D2BC347D900592990 /* Published+Codable.swift */; }; @@ -205,6 +207,8 @@ D1EFCD172AF6AE1600D33A7A /* TorrentFilesDictionaryItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EFCD162AF6AE1600D33A7A /* TorrentFilesDictionaryItemView.swift */; }; D1EFCD192AF6AEC700D33A7A /* TorrentFilesDictionaryItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EFCD182AF6AEC700D33A7A /* TorrentFilesDictionaryItemViewModel.swift */; }; D1F8BC852AFC405A00A6258C /* MvvmViewModel+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F8BC842AFC405A00A6258C /* MvvmViewModel+Alert.swift */; }; + D1FFC9652C38136600233C2F /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC9642C38135F00233C2F /* URL+Extensions.swift */; }; + D1FFC96C2C382FC900233C2F /* StorageModel+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC96B2C382FBE00233C2F /* StorageModel+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -287,6 +291,8 @@ 7C609A572C1F1D6400586635 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7C609A592C1F2A2700586635 /* iTorrent.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = iTorrent.xcconfig; sourceTree = ""; }; 7C609A5A2C1F5D9E00586635 /* SceneDelegate+AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SceneDelegate+AVPlayer.swift"; sourceTree = ""; }; + 7C95B7B32C35DE49000EC50F /* StoragePreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoragePreferencesView.swift; sourceTree = ""; }; + 7C95B7B62C385B97000EC50F /* UICellAccessory+Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICellAccessory+Image.swift"; sourceTree = ""; }; 7CAD301B2BC3457900592990 /* RssModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RssModel.swift; sourceTree = ""; }; 7CAD301D2BC347D900592990 /* Published+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Published+Codable.swift"; sourceTree = ""; }; 7CAD301F2BC34BCE00592990 /* RssFeedProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RssFeedProvider.swift; sourceTree = ""; }; @@ -437,6 +443,8 @@ D1EFCD162AF6AE1600D33A7A /* TorrentFilesDictionaryItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentFilesDictionaryItemView.swift; sourceTree = ""; }; D1EFCD182AF6AEC700D33A7A /* TorrentFilesDictionaryItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentFilesDictionaryItemViewModel.swift; sourceTree = ""; }; D1F8BC842AFC405A00A6258C /* MvvmViewModel+Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MvvmViewModel+Alert.swift"; sourceTree = ""; }; + D1FFC9642C38135F00233C2F /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = ""; }; + D1FFC96B2C382FBE00233C2F /* StorageModel+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StorageModel+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -597,6 +605,22 @@ path = MvvmFoundation; sourceTree = ""; }; + 7C95B7A32C34B554000EC50F /* Storage */ = { + isa = PBXGroup; + children = ( + 7C95B7B32C35DE49000EC50F /* StoragePreferencesView.swift */, + ); + path = Storage; + sourceTree = ""; + }; + 7C95B7B52C385B8E000EC50F /* UIKit */ = { + isa = PBXGroup; + children = ( + 7C95B7B62C385B97000EC50F /* UICellAccessory+Image.swift */, + ); + path = UIKit; + sourceTree = ""; + }; 7CAD301A2BC3456700592990 /* RssFeed */ = { isa = PBXGroup; children = ( @@ -764,6 +788,7 @@ D111384C2AF9663F008907F7 /* Preferences */ = { isa = PBXGroup; children = ( + 7C95B7A32C34B554000EC50F /* Storage */, D1DB718E2BD92206007F9267 /* Patreon */, 7CB6F6CC2BD82B8A00D0813B /* FileSharing */, D1352D1F2BBC338300104E7B /* Base */, @@ -786,8 +811,8 @@ D11333B32AF19C3400FA017E /* TorrentService */ = { isa = PBXGroup; children = ( + D1FFC9632C37F44800233C2F /* Extensions */, D1A226F12AEF019400669D6D /* TorrentService.swift */, - D11333B42AF19C4900FA017E /* TorrentHandle+Extension.swift */, ); path = TorrentService; sourceTree = ""; @@ -999,6 +1024,7 @@ D19E00242AF045DF000A17A2 /* Extensions */ = { isa = PBXGroup; children = ( + 7C95B7B52C385B8E000EC50F /* UIKit */, 7C5FBE722BC2F0940069E5A0 /* MvvmFoundation */, D173D9DF2BC0285800E4F9EB /* UIMenu */, 7C5FBE2F2BBF5C990069E5A0 /* SwiftUI */, @@ -1006,6 +1032,7 @@ D1048D872BBEA23C0027EF2F /* Combine */, D1352D282BBD418200104E7B /* UIViewController */, D1D1279A2BC7CA7600C04533 /* SwiftUILayoutGuides.swift */, + D1FFC9642C38135F00233C2F /* URL+Extensions.swift */, D19E00252AF045F9000A17A2 /* SpeedFormat.swift */, D1ACFDC82AF6ED200098FF56 /* UIImage+File.swift */, D1AA00D12AFAC95500B74629 /* String+Localized.swift */, @@ -1305,6 +1332,15 @@ path = TorrentFilesDictionaryItem; sourceTree = ""; }; + D1FFC9632C37F44800233C2F /* Extensions */ = { + isa = PBXGroup; + children = ( + D11333B42AF19C4900FA017E /* TorrentHandle+Extension.swift */, + D1FFC96B2C382FBE00233C2F /* StorageModel+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1497,6 +1533,7 @@ buildActionMask = 2147483647; files = ( 7CF6DA352C0F3DB90033D03F /* BCHostingConfiguration.swift in Sources */, + D1FFC96C2C382FC900233C2F /* StorageModel+Extensions.swift in Sources */, D1F8BC852AFC405A00A6258C /* MvvmViewModel+Alert.swift in Sources */, D17871FF2AF2C0A700F11F9F /* UIKitSwiftUIInarop.swift in Sources */, D135C59C2AEFB96F00440680 /* TorrentDetailsViewModel.swift in Sources */, @@ -1528,6 +1565,7 @@ 7C5FBE642BC158BC0069E5A0 /* SceneDelegate+URLProcessing.swift in Sources */, D1A2269E2AEEEFCC00669D6D /* AppDelegate.swift in Sources */, 7C5FBE702BC2EF8B0069E5A0 /* EditTextViewController.swift in Sources */, + 7C95B7B42C35DE5C000EC50F /* StoragePreferencesView.swift in Sources */, 7CFEBEA02BC6F3CD0013233F /* Date+Extensions.swift in Sources */, D1DB718B2BD91606007F9267 /* ImageCache.swift in Sources */, 7CBDBAAD2C31EF0C008C986B /* UserDefaults+AppGroup.swift in Sources */, @@ -1572,6 +1610,7 @@ 7CAD301C2BC3457900592990 /* RssModel.swift in Sources */, 7CFEBE882BC439C30013233F /* RssChannelItemCell.swift in Sources */, 7C5FBE312BBF5CAB0069E5A0 /* MinHeightModifier.swift in Sources */, + 7C95B7B72C385B99000EC50F /* UICellAccessory+Image.swift in Sources */, D1EFCD192AF6AEC700D33A7A /* TorrentFilesDictionaryItemViewModel.swift in Sources */, D1B99D892BEE613B00F51514 /* PatreonWebServer.swift in Sources */, 7CFEBE762BC3ED550013233F /* RssFeedCellViewModel.swift in Sources */, @@ -1586,6 +1625,7 @@ D135C5992AEFB96100440680 /* TorrentDetailsViewController.swift in Sources */, D1048D8B2BBEB6DE0027EF2F /* CombineLatest.swift in Sources */, D11138572AF976CE008907F7 /* TorrentAddDirectoryItemViewModel.swift in Sources */, + D1FFC9652C38136600233C2F /* URL+Extensions.swift in Sources */, 7C1C08AB2C31F31900569B45 /* PauseTorrentIntent.swift in Sources */, D1CAB8872AF3B52E00EB6AFF /* ToggleCellViewModel.swift in Sources */, D11BE5492AFBA03D00780C1B /* PRButtonView.swift in Sources */, @@ -1706,7 +1746,6 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Dev"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1748,7 +1787,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -1769,7 +1808,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.1; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 2.0.5; + MARKETING_VERSION = 2.0.6; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1778,6 +1817,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; }; name = RelWithDebInfo; }; @@ -1851,7 +1891,6 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Dev"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1893,7 +1932,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1909,7 +1948,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.1; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 2.0.5; + MARKETING_VERSION = 2.0.6; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -1918,6 +1957,7 @@ SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; @@ -1991,7 +2031,6 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Distrib"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -2033,7 +2072,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2054,7 +2093,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.1; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 2.0.5; + MARKETING_VERSION = 2.0.6; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -2063,6 +2102,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; diff --git a/iTorrent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iTorrent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..7a4525e9 --- /dev/null +++ b/iTorrent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,168 @@ +{ + "originHash" : "11bb8e91059647586820412f0c343b58cc80736343ec98a17b3c6d40900620dd", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "748c7837511d0e6a507737353af268484e1745e2", + "version" : "1.2024011601.1" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", + "version" : "10.19.2" + } + }, + { + "identity" : "combinecocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/XITRIX/CombineCocoa.git", + "state" : { + "branch" : "main", + "revision" : "c1bd62b53589e053c56958beebb6d9aee1367d5d" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "e57841b296d04370ea23580f908881b0ccab17b9", + "version" : "10.28.1" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "fe727587518729046fc1465625b9afd80b5ab361", + "version" : "10.28.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", + "version" : "9.4.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", + "version" : "7.13.3" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", + "version" : "1.62.2" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "marqueelabel", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cbpowell/MarqueeLabel.git", + "state" : { + "revision" : "877e810534cda9afabb8143ae319b7c3341b121b", + "version" : "4.5.0" + } + }, + { + "identity" : "marqueetext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/joekndy/MarqueeText.git", + "state" : { + "branch" : "master", + "revision" : "e18d514455563ad00581e0d825146c6f15b1c6c4" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "openssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/OpenSSL.git", + "state" : { + "revision" : "bb0cbdf1e01c6a9fde4077f041abc766ea48bd7f", + "version" : "3.1.5006" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb", + "version" : "1.26.0" + } + }, + { + "identity" : "swxmlhash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/drmohundro/SWXMLHash.git", + "state" : { + "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version" : "7.0.2" + } + } + ], + "version" : 3 +} diff --git a/iTorrent/Base/BaseViewController.swift b/iTorrent/Base/BaseViewController.swift index 69a36c71..1d7e951a 100644 --- a/iTorrent/Base/BaseViewController.swift +++ b/iTorrent/Base/BaseViewController.swift @@ -58,3 +58,59 @@ class BaseViewController: SAViewController: SAHostingViewController, ToolbarHidingProtocol { + var isToolbarItemsHidden: Bool { toolbarItems?.isEmpty ?? true } + var useMarqueeLabel: Bool { true } + +// override var title: String? { +// get { super.title } +// set { +// super.title = newValue +// titleLabel.text = newValue +// titleLabel.sizeToFit() +// } +// } + + override func viewDidLoad() { + super.viewDidLoad() + #if os(visionOS) + view.backgroundColor = nil + #endif + +// #if !os(visionOS) // Not renders properly on VisionOS +// if useMarqueeLabel { +// navigationItem.titleView = titleLabel +// titleLabel.sizeToFit() +// } +// #endif + + title = rootView.title + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setToolbarHidden(isToolbarItemsHidden, animated: false) + + if navigationController?.viewControllers.count == 1 { + navigationItem.trailingItemGroups = [.fixedGroup(items: [ + UIBarButtonItem(systemItem: .close, primaryAction: .init { [unowned self] _ in + dismiss(animated: true) + }) + ])] + } + } + + private let titleLabel: MarqueeLabel = { + let titleLabel = MarqueeLabel() +#if !os(visionOS) + titleLabel.font = .preferredFont(forTextStyle: .headline) +#else + titleLabel.font = .preferredFont(forTextStyle: .title1) +#endif + titleLabel.fadeLength = 16 + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.textAlignment = .center + return titleLabel + }() +} diff --git a/iTorrent/Components/Cells/DetailCell/DetailCellView.swift b/iTorrent/Components/Cells/DetailCell/DetailCellView.swift index 2bb8a321..46d06e4f 100644 --- a/iTorrent/Components/Cells/DetailCell/DetailCellView.swift +++ b/iTorrent/Components/Cells/DetailCell/DetailCellView.swift @@ -17,7 +17,8 @@ struct DetailCellView: MvvmSwiftUICellProtocol { if isHorizontal { HStack { Text(viewModel.title) - .fontWeight(.semibold) + .fontWeight(viewModel.isBold ? .semibold : .regular) + .foregroundStyle(viewModel.isEnabled ? Color.primary : Color.secondary) Spacer(minLength: viewModel.spacer) Text(LocalizedStringKey(viewModel.detail)) .foregroundStyle(Color.accentColor) @@ -43,6 +44,7 @@ struct DetailCellView: MvvmSwiftUICellProtocol { cell.contentConfiguration = UIHostingConfiguration { Self(viewModel: itemIdentifier) } + cell.isUserInteractionEnabled = itemIdentifier.isEnabled cell.accessories = itemIdentifier.selectAction == nil ? [] : [.disclosureIndicator(displayed: .always)] } } diff --git a/iTorrent/Components/Cells/DetailCell/DetailCellViewModel.swift b/iTorrent/Components/Cells/DetailCell/DetailCellViewModel.swift index a4802ed0..60b94bf4 100644 --- a/iTorrent/Components/Cells/DetailCell/DetailCellViewModel.swift +++ b/iTorrent/Components/Cells/DetailCell/DetailCellViewModel.swift @@ -22,12 +22,16 @@ class DetailCellViewModel: BaseViewModel, ObservableObject, MvvmSelectableProtoc @Published var title: String = "" @Published var detail: String = "" @Published var spacer: Double = 0 + @Published var isBold: Bool = true + @Published var isEnabled: Bool = true - init(title: String = "", detail: String = "", spacer: Double = 24, selectAction: (() -> Void)? = nil) { + init(title: String = "", detail: String = "", spacer: Double = 24, isBold: Bool = true, isEnabled: Bool = true, selectAction: (() -> Void)? = nil) { self.title = title self.detail = detail self.spacer = spacer self.selectAction = selectAction + self.isEnabled = isEnabled + self.isBold = isBold } required init() { diff --git a/iTorrent/Core/AppDelegate/AppDelegate+Notifications.swift b/iTorrent/Core/AppDelegate/AppDelegate+Notifications.swift index c05b3765..5aa5bfd6 100644 --- a/iTorrent/Core/AppDelegate/AppDelegate+Notifications.swift +++ b/iTorrent/Core/AppDelegate/AppDelegate+Notifications.swift @@ -26,7 +26,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { guard let hash = response.notification.request.content.userInfo["hash"] as? String, - let torrentHandle = TorrentService.shared.torrents.first(where: { $0.snapshot.infoHashes.best.hex == hash }) + let torrentHandle = TorrentService.shared.torrents.values.first(where: { $0.snapshot.infoHashes.best.hex == hash }) else { return } Self.showTorrentDetailScreen(with: torrentHandle) diff --git a/iTorrent/Core/Assets/Localizable.xcstrings b/iTorrent/Core/Assets/Localizable.xcstrings index 21c622bd..5cecef01 100644 --- a/iTorrent/Core/Assets/Localizable.xcstrings +++ b/iTorrent/Core/Assets/Localizable.xcstrings @@ -102,6 +102,38 @@ } } }, + "addTorrent.storage.manage" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настроить..." + } + } + } + }, + "addTorrent.storage.selected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select storage" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите хранилище" + } + } + } + }, "common.add" : { "localizations" : { "en" : { @@ -150,6 +182,22 @@ } } }, + "common.continue" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить" + } + } + } + }, "common.delete" : { "localizations" : { "en" : { @@ -647,6 +695,55 @@ } } }, + "details.path" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хранилище" + } + } + } + }, + "details.path.browse" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущее" + } + } + } + }, + "details.path.migrate" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Migrate storage" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мигрировать хранилище" + } + } + } + }, "details.pause" : { "localizations" : { "en" : { @@ -663,6 +760,54 @@ } } }, + "details.refreshStorage.fail.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage not found" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хранилище не найдено" + } + } + } + }, + "details.refreshStorage.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iTorrent will try to find missing storage" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "iTorrent попробует найти утерянное хранилище" + } + } + } + }, + "details.refreshStorage.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh storage" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезагрузить хранилище" + } + } + } + }, "details.rehash" : { "localizations" : { "en" : { @@ -2975,6 +3120,38 @@ } } }, + "preferences.storage.add" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add more..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить..." + } + } + } + }, + "preferences.storage.add.error.notDirectory" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selected path is not a directory" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранный путь не является директорией" + } + } + } + }, "preferences.storage.allocate" : { "localizations" : { "en" : { @@ -3007,6 +3184,86 @@ } } }, + "preferences.storage.storages" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storages" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хранилища" + } + } + } + }, + "preferences.storage.storages.available%lld/%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Available: %1$lld/%2$lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступно: %1$lld/%2$lld" + } + } + } + }, + "preferences.storage.warning.accept" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "I understood, continue" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Я понял, продолжить" + } + } + } + }, + "preferences.storage.warning.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In case of using external drives, you should eject it only after closing iTorrent app. Otherwise iTorrent cannot garantee safety storing of your downloaded files and could potentially corrups current states of torrents from ejected drive." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В случае использования внешних дисков извлекать их следует только после закрытия приложения iTorrent. В противном случае iTorrent не может гарантировать безопасное хранение загруженных вами файлов и потенциально может испортить текущее состояние торрентов с извлеченного диска." + } + } + } + }, + "preferences.storage.warning.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WARNING!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ВНИМАНИЕ!" + } + } + } + }, "preferences.version" : { "localizations" : { "en" : { @@ -3775,6 +4032,22 @@ } } }, + "torrent.state.storageError" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage error" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка хранилища" + } + } + } + }, "tracker.leeches" : { "localizations" : { "en" : { diff --git a/iTorrent/Core/SceneDelegate/SceneDelegate+URLProcessing.swift b/iTorrent/Core/SceneDelegate/SceneDelegate+URLProcessing.swift index bb75b17d..9272883f 100644 --- a/iTorrent/Core/SceneDelegate/SceneDelegate+URLProcessing.swift +++ b/iTorrent/Core/SceneDelegate/SceneDelegate+URLProcessing.swift @@ -27,7 +27,7 @@ private extension SceneDelegate { guard url.absoluteString.hasPrefix(prefix) else { return false } let hash = url.absoluteString.replacingOccurrences(of: prefix, with: "") - guard let torrent = TorrentService.shared.torrents.first(where: { $0.snapshot.infoHashes.best.hex == hash }) + guard let torrent = TorrentService.shared.torrents.values.first(where: { $0.snapshot.infoHashes.best.hex == hash }) else { return false } AppDelegate.showTorrentDetailScreen(with: torrent) diff --git a/iTorrent/Core/SceneDelegate/SceneDelegate.swift b/iTorrent/Core/SceneDelegate/SceneDelegate.swift index d0676f74..6b14ec19 100644 --- a/iTorrent/Core/SceneDelegate/SceneDelegate.swift +++ b/iTorrent/Core/SceneDelegate/SceneDelegate.swift @@ -52,6 +52,8 @@ class SceneDelegate: MvvmSceneDelegate { router.register(PRColorPickerCell.self) // MARK: Controllers + router.register(BaseHostingViewController.self) + router.register(TorrentListViewController.self) router.register(TorrentDetailsViewController.self) router.register(TorrentFilesViewController.self) diff --git a/iTorrent/Screens/Preferences/Root/Cells/PRButton/PRButtonView.swift b/iTorrent/Screens/Preferences/Root/Cells/PRButton/PRButtonView.swift index bd0969f7..504adc25 100644 --- a/iTorrent/Screens/Preferences/Root/Cells/PRButton/PRButtonView.swift +++ b/iTorrent/Screens/Preferences/Root/Cells/PRButton/PRButtonView.swift @@ -14,6 +14,8 @@ struct PRButtonView: MvvmSwiftUICellProtocol { var body: some View { HStack { Text(viewModel.title) + .fontWeight(viewModel.isBold ? .semibold : .regular) + .foregroundStyle(viewModel.tintedTitle ? Color.accentColor : Color.primary) Spacer() Text(viewModel.value) .foregroundStyle(viewModel.tinted ? Color.accentColor : Color.secondary) @@ -21,6 +23,15 @@ struct PRButtonView: MvvmSwiftUICellProtocol { .multilineTextAlignment(.trailing) } .systemMinimumHeight() + .swipeActions { + if let removeAction = viewModel.removeAction { + Button(role: .destructive) { + removeAction() + } label: { + Image(systemName: "trash") + } + } + } } static var registration: UICollectionView.CellRegistration, PRButtonViewModel> = .init { cell, indexPath, itemIdentifier in diff --git a/iTorrent/Screens/Preferences/Root/Cells/PRButton/PRButtonViewModel.swift b/iTorrent/Screens/Preferences/Root/Cells/PRButton/PRButtonViewModel.swift index ca198ef9..b5a08eff 100644 --- a/iTorrent/Screens/Preferences/Root/Cells/PRButton/PRButtonViewModel.swift +++ b/iTorrent/Screens/Preferences/Root/Cells/PRButton/PRButtonViewModel.swift @@ -12,13 +12,16 @@ import SwiftUI extension PRButtonViewModel { struct Config { var id: String? + var removeAction: (() -> Void)? = nil var title: String - var value: AnyPublisher?// = Just("").eraseToAnyPublisher() + var tintedTitle: Bool = false + var isBold: Bool = false + var value: AnyPublisher? var canReorder: Bool = false var tinted: Bool = true var singleLine: Bool = false var accessories: [UICellAccessory] = [] - var selectAction: () -> Void = {} + var selectAction: (() -> Void)? = {} } } @@ -27,17 +30,22 @@ class PRButtonViewModel: BaseViewModelWith, Observable var id: String? @Published var title = "" + @Published var tintedTitle: Bool = false + @Published var isBold: Bool = false @Published var value: String = "" @Published var tinted: Bool = true @Published var singleLine: Bool = false @Published var canReorder: Bool = false @Published var accessories: [UICellAccessory] = [] + @Published var removeAction: (() -> Void)? var metadata: Any? override func prepare(with model: Config) { id = model.id title = model.title + tintedTitle = model.tintedTitle + isBold = model.isBold if let value = model.value { value.assign(to: &self.$value) } @@ -46,15 +54,21 @@ class PRButtonViewModel: BaseViewModelWith, Observable canReorder = model.canReorder tinted = model.tinted singleLine = model.singleLine + removeAction = model.removeAction } override func isEqual(to other: MvvmViewModel) -> Bool { guard let other = other as? Self else { return false } - return id == other.id && title == other.title + return id == other.id && + title == other.title && + isBold == other.isBold && + accessories.count == other.accessories.count } override func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(title) + hasher.combine(isBold) + hasher.combine(accessories.count) } } diff --git a/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift b/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift index 619e5d94..230145b9 100644 --- a/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift +++ b/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift @@ -60,7 +60,13 @@ private extension PreferencesViewModel { sections.append(.init(id: "memory", header: %"preferences.storage") { storageVM - PRSwitchViewModel(with: .init(title: %"preferences.storage.allocate", value: preferences.$allocateMemory.binding)) + PRButtonViewModel(with: .init(title: %"preferences", accessories: [.disclosureIndicator()]) { [unowned self] in + navigate(to: StoragePreferencesViewModel.self, by: .show) + }) +// PRButtonViewModel(with: .init(title: "preferences2", accessories: [.disclosureIndicator()]) { [unowned self] in +// navigate(to: StoragePreferencesViewModel.self, by: .show) +//// navigate(to: SUIStoragePreferencesViewModel.self, by: .show) +// }) }) #if IS_SUPPORT_LOCATION_BG diff --git a/iTorrent/Screens/Preferences/Storage/StoragePreferencesView.swift b/iTorrent/Screens/Preferences/Storage/StoragePreferencesView.swift new file mode 100644 index 00000000..1ea135b3 --- /dev/null +++ b/iTorrent/Screens/Preferences/Storage/StoragePreferencesView.swift @@ -0,0 +1,187 @@ +// +// StoragePreferencesView.swift +// iTorrent +// +// Created by Даниил Виноградов on 03.07.2024. +// + +import LibTorrent +import MvvmFoundation +import SwiftUI +import UniformTypeIdentifiers + +class StoragePreferencesViewModel: BaseViewModel, ObservableObject { + @Published var allocateMemory: Bool = false + @Published var customStoragesVM: [UUID: StorageModel] = [:] + @Published var currentStorage: UUID? + @Published var isStorageRulesAccepted: Bool = false + + required init() { + super.init() + allocateMemory = preferences.allocateMemory + preferences.$storageScopes.assign(to: &$customStoragesVM) + + isStorageRulesAccepted = preferences.isStorageRulesAccepted + preferences.$isStorageRulesAccepted.assign(to: &$isStorageRulesAccepted) + + preferences.$defaultStorage.assign(to: &$currentStorage) + + disposeBag.bind { + $allocateMemory.sink { [unowned self] in preferences.allocateMemory = $0 } + } + } + + @Injected var preferences: PreferencesStorage +} + +struct StoragePreferencesView: MvvmSwiftUIViewProtocol { + @ObservedObject var viewModel: VM + @State var filePickerPresented: Bool = false + + let storagesLimit = 5 + var title: String = %"preferences.storage" + + init(viewModel: VM) { + self.viewModel = viewModel + } + + var body: some View { + List { + Section { + Toggle("preferences.storage.allocate", isOn: $viewModel.allocateMemory) + } + + if !viewModel.isStorageRulesAccepted { + Section { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text("preferences.storage.warning.title") + .fontWeight(.semibold) + + Text("preferences.storage.warning.message") + } + + Button { + withAnimation { + viewModel.preferences.isStorageRulesAccepted = true + } + } label: { + Spacer() + Text("preferences.storage.warning.accept") + .fontWeight(.semibold) + Spacer() + } + .frame(maxWidth: .infinity) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .accentColor(.red) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .listRowBackground(Color.red.opacity(0.1)) + .listRowSeparator(.hidden) + } + + Section { + Button { + viewModel.preferences.defaultStorage = nil + } label: { + HStack { + Text(StorageModel.defaultName) + .foregroundStyle(Color.primary) + Spacer() + if viewModel.preferences.defaultStorage == nil { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + .fontWeight(.medium) + } + } + } + ForEach(Array(viewModel.customStoragesVM.values.sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }))) { scope in + Button { + if scope.allowed { + viewModel.preferences.defaultStorage = scope.uuid + } + } label: { + HStack { + Text(scope.name) + .foregroundStyle(scope.allowed ? Color.primary : Color.secondary) + Spacer() + if viewModel.preferences.defaultStorage == scope.uuid { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + .fontWeight(.medium) + } else if !scope.allowed { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + } + } + }.swipeActions { + Button(role: .destructive) { + viewModel.preferences.storageScopes[scope.uuid] = nil + if viewModel.preferences.defaultStorage == scope.uuid { + viewModel.preferences.defaultStorage = nil + } + } label: { + Image(systemName: "trash") + } + } + } + if viewModel.customStoragesVM.count < storagesLimit - 1 { + Button("preferences.storage.add") { + filePickerPresented = true + }.disabled(!viewModel.isStorageRulesAccepted) + } + } header: { + HStack { + Text("preferences.storage.storages") + Spacer() + Text("preferences.storage.storages.available\(viewModel.customStoragesVM.count + 1)/\(storagesLimit)") + } + } + }.fileImporter(isPresented: $filePickerPresented, allowedContentTypes: [.folder]) { result in + guard let url = try? result.get() else { return } + + guard url.isDirectory else { + return viewModel.alert(title: %"common.error", message: %"preferences.storage.add.error.notDirectory", actions: [ + .init(title: %"common.close", style: .cancel) + ]) + } + + let allowed = url.startAccessingSecurityScopedResource() + print("Path - \(url) | write permissions - \(allowed)") + + guard let bookmark = try? url.bookmarkData(options: [.minimalBookmark]) + else { return } + + if let storage = viewModel.preferences.storageScopes.values.first(where: { + $0.url == url || $0.url == TorrentService.downloadPath + }) { + storage.pathBookmark = bookmark + return + } + + let storage = StorageModel() + storage.uuid = UUID() + storage.name = url.lastPathComponent + storage.url = url + storage.allowed = allowed + storage.resolved = true + + do { + let name = try url.resourceValues(forKeys: [.localizedNameKey]) + if let name = name.allValues[.localizedNameKey] as? String { + storage.name = name + } + } catch {} + + storage.pathBookmark = bookmark + + withAnimation { + viewModel.preferences.storageScopes[storage.uuid] = storage + } + } + } +} diff --git a/iTorrent/Screens/TorrentAdd/TorrentAddViewController.swift b/iTorrent/Screens/TorrentAdd/TorrentAddViewController.swift index 708671b6..44fd6a81 100644 --- a/iTorrent/Screens/TorrentAdd/TorrentAddViewController.swift +++ b/iTorrent/Screens/TorrentAdd/TorrentAddViewController.swift @@ -11,18 +11,18 @@ import UIKit class TorrentAddViewController: BaseViewController { @IBOutlet private var collectionView: UICollectionView! private lazy var delegates = Deletates(parent: self) + private lazy var dataPickerDelegate = DataPickerDelegate(parent: self) private let cancelButton = UIBarButtonItem(systemItem: .close) private let downloadButton = UIModernBarButtonItem(image: .init(systemName: "arrow.down")) private let diskLabel = makeDiskLabel() - private let moreButton = UIBarButtonItem(title: "More", image: .init(systemName: "ellipsis.circle")) + private let priorityButton = UIBarButtonItem(title: %"prioriry.change.title", image: .init(resource: .icSort)) + private let storageButton = UIBarButtonItem(title: %"addTorrent.storage.selected", image: .init(systemName: "externaldrive")) override func viewDidLoad() { super.viewDidLoad() title = viewModel.title - moreButton.menu = .makeForChangePriority { [unowned self] priority in - viewModel.setAllFilesPriority(priority) - } + updateMenu() collectionView.register(TorrentFilesDictionaryItemViewCell.self, forCellWithReuseIdentifier: TorrentFilesDictionaryItemViewCell.reusableId) collectionView.register(type: TorrentFilesFileListCell.self, hasXib: false) @@ -45,7 +45,9 @@ class TorrentAddViewController: BaseViewController toolbarItems = [ .init(customView: diskLabel), .init(systemItem: .flexibleSpace), - moreButton + storageButton, + .fixedSpace(16), + priorityButton ] disposeBag.bind { @@ -75,6 +77,36 @@ private extension TorrentAddViewController { label.font = .preferredFont(forTextStyle: .callout) return label } + + func updateMenu() { + priorityButton.menu = UIMenu.makeForChangePriority { [unowned self] priority in + viewModel.setAllFilesPriority(priority) + } + + storageButton.menu = UIMenu( + title: %"addTorrent.storage.selected", + image: .init(systemName: "externaldrive"), + children: + [ + UIAction(title: %"addTorrent.storage.manage") { [unowned self] _ in + viewModel.navigate(to: StoragePreferencesViewModel.self, by: .present(wrapInNavigation: true)) + } + ] + + viewModel.storages.map { storage in + var attributes: UIMenuElement.Attributes = [] + var image: UIImage? + + if !storage.allowed { + attributes = .disabled + image = .init(systemName: "exclamationmark.triangle.fill")?.withTintColor(.systemRed, renderingMode: .alwaysOriginal) + } + + return UIAction(title: storage.name, image: image, attributes: attributes, state: storage.selected ? .on : .off) { [unowned self] _ in + viewModel.downloadStorage.value = storage.uuid + updateMenu() + } + }) + } } private extension TorrentAddViewController { @@ -129,3 +161,11 @@ private extension TorrentAddViewController { } } } + +private extension TorrentAddViewController { + class DataPickerDelegate: DelegateObject, UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + print(urls) + } + } +} diff --git a/iTorrent/Screens/TorrentAdd/TorrentAddViewModel.swift b/iTorrent/Screens/TorrentAdd/TorrentAddViewModel.swift index ad4d622e..27d7c7ab 100644 --- a/iTorrent/Screens/TorrentAdd/TorrentAddViewModel.swift +++ b/iTorrent/Screens/TorrentAdd/TorrentAddViewModel.swift @@ -24,11 +24,15 @@ class TorrentAddViewModel: BaseViewModelWith { private(set) var isRoot: Bool = false let updatePublisher = CurrentValueRelay(()) + let downloadStorage = CurrentValueRelay(nil) + let downloadStorages = CurrentValueRelay<[StorageModel]>([]) override func prepare(with model: Config) { torrentFile = model.torrentFile isRoot = model.rootDirectory == nil rootDirectory = model.rootDirectory ?? generateRoot() + downloadStorage.value = preferences.defaultStorage + keys = rootDirectory.storage .sorted(by: { first, second in let f = first.value.name @@ -49,6 +53,8 @@ class TorrentAddViewModel: BaseViewModelWith { override func willAppear() { updatePublisher.send() } + + @Injected private var preferences: PreferencesStorage } extension TorrentAddViewModel { @@ -91,7 +97,7 @@ extension TorrentAddViewModel { } func download() { - TorrentService.shared.addTorrent(by: torrentFile) + TorrentService.shared.addTorrent(by: torrentFile, at: downloadStorage.value) dismiss() } @@ -113,6 +119,12 @@ extension TorrentAddViewModel { return "\(selected.bitrateToHumanReadable) / \(total.bitrateToHumanReadable)" }.eraseToAnyPublisher() } + + var storages: [(name: String, selected: Bool, uuid: UUID?, allowed: Bool)] { + [(StorageModel.defaultName, downloadStorage.value == nil, nil, true)] + + preferences.storageScopes.values.sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }) + .map { ($0.name, downloadStorage.value == $0.uuid, $0.uuid, $0.allowed ) } + } } private extension TorrentAddViewModel { @@ -152,7 +164,7 @@ extension TorrentAddViewModel { } private static func presentAlert(from navigationContext: NavigationProtocol, ifTorrentExists torrentFile: TorrentFile) -> Bool { - guard TorrentService.shared.torrents.contains(where: { $0.snapshot.infoHashes == torrentFile.infoHashes }) + guard TorrentService.shared.torrents[torrentFile.infoHashes] != nil else { return false } let alert = UIAlertController(title: %"addTorrent.exists", message: %"addTorrent.\(torrentFile.infoHashes.best.hex)_exists", preferredStyle: .alert) diff --git a/iTorrent/Screens/TorrentDetails/TorrentDetailsViewController.swift b/iTorrent/Screens/TorrentDetails/TorrentDetailsViewController.swift index b7c21b18..ba58413d 100644 --- a/iTorrent/Screens/TorrentDetails/TorrentDetailsViewController.swift +++ b/iTorrent/Screens/TorrentDetails/TorrentDetailsViewController.swift @@ -41,9 +41,12 @@ class TorrentDetailsViewController: BaseViewControl shareButton.isEnabled = available } - viewModel.$isPaused.sink { [unowned self] isPaused in - playButton.isEnabled = isPaused - pauseButton.isEnabled = !isPaused + viewModel.$canResume.sink { [unowned self] canResume in + playButton.isEnabled = canResume + } + + viewModel.$canPause.sink { [unowned self] canPause in + pauseButton.isEnabled = canPause } } diff --git a/iTorrent/Screens/TorrentDetails/TorrentDetailsViewModel.swift b/iTorrent/Screens/TorrentDetails/TorrentDetailsViewModel.swift index fcab6234..07faa7a7 100644 --- a/iTorrent/Screens/TorrentDetails/TorrentDetailsViewModel.swift +++ b/iTorrent/Screens/TorrentDetails/TorrentDetailsViewModel.swift @@ -16,6 +16,10 @@ class TorrentDetailsViewModel: BaseViewModelWith { @Published var sections: [MvvmCollectionSectionModel] = [] @Published var title: String = "" @Published var isPaused: Bool = false + @Published var canResume: Bool = false + @Published var canPause: Bool = false + + @Published private var storageError: Bool = false let dismissSignal = PassthroughSubject() @@ -46,6 +50,25 @@ class TorrentDetailsViewModel: BaseViewModelWith { sequentialModel.$isOn.sink { [unowned self] value in torrentHandle.setSequentialDownload(value) } + + $storageError.removeDuplicates().sink { [unowned self] error in + runOnMainThreadIfNeeded { [self] in + downloadPathModel.accessories = error ? + [ + .image(.init(systemName: "exclamationmark.triangle.fill"), options: .init(tintColor: .systemRed)) + ] : + [ +// .popUpMenu( +// .init(title: %"details.path.migrate", children: [ +// UIAction(title: "Default", state: .off) { _ in }, +// UIAction(title: "Browse", state: .off) { _ in }, +// ]), options: .init(tintColor: .tintColor) +// ) + ] + + downloadPathModel.selectAction = nil //error ? nil : {} + } + } } hashModel.longPressAction = { [unowned self] in @@ -84,6 +107,7 @@ class TorrentDetailsViewModel: BaseViewModelWith { private let commentModel = DetailCellViewModel(title: %"details.info.comment", spacer: 80) private let createdModel = DetailCellViewModel(title: %"details.info.created") private let addedModel = DetailCellViewModel(title: %"details.info.added") + private let downloadPath2Model = DetailCellViewModel(title: "Download Path") private let selectedModel = DetailCellViewModel(title: %"details.transfer.selectedTotal") private let completedModel = DetailCellViewModel(title: %"details.transfer.completed") @@ -93,12 +117,17 @@ class TorrentDetailsViewModel: BaseViewModelWith { private let seedersModel = DetailCellViewModel(title: %"details.transfer.seeders") private let leechersModel = DetailCellViewModel(title: %"details.transfer.leechers") + private lazy var downloadPathModel = PRButtonViewModel(with: .init(title: %"details.path.browse", isBold: true, value: nil)) + private lazy var trackersModel = DetailCellViewModel(title: %"details.actions.trackers") { [unowned self] in navigate(to: TorrentTrackersViewModel.self, with: torrentHandle, by: .show) } private lazy var filesModel = DetailCellViewModel(title: %"details.actions.files") { [unowned self] in navigate(to: TorrentFilesViewModel.self, with: .init(torrentHandle: torrentHandle), by: .show) } + + @Injected private var torrentService: TorrentService + @Injected private var preferences: PreferencesStorage } extension TorrentDetailsViewModel { @@ -117,10 +146,30 @@ extension TorrentDetailsViewModel { } func rehash() { + // If Storage is not available, try to reconnect storage + if torrentHandle.snapshot.friendlyState == .storageError { + return refreshStorage() + } + alert(title: %"details.rehash.title", message: %"details.rehash.message", actions: [ .init(title: %"common.cancel", style: .cancel), .init(title: %"details.rehash.action", style: .destructive, action: { [unowned self] in torrentHandle.rehash() + }), + ]) + } + + func refreshStorage() { + guard let storage = torrentHandle.storage else { return } + alert(title: %"details.refreshStorage.title", message: %"details.refreshStorage.message", actions: [ + .init(title: %"common.cancel", style: .cancel), + .init(title: %"common.continue", style: .default, action: { [self] in + Task { + guard !torrentService.refreshStorage(storage) else { return } + alert(title: %"common.error", message: %"details.refreshStorage.fail.message", actions: [ + .init(title: %"common.close", style: .cancel) + ]) + } }) ]) } @@ -133,7 +182,7 @@ extension TorrentDetailsViewModel { .init(title: %"torrent.remove.action.keepData", style: .default, action: { [unowned self] in TorrentService.shared.removeTorrent(by: torrentHandle.snapshot.infoHashes, deleteFiles: false) }), - .init(title: %"common.cancel", style: .cancel) + .init(title: %"common.cancel", style: .cancel), ]) } @@ -154,7 +203,11 @@ extension TorrentDetailsViewModel { private extension TorrentDetailsViewModel { func dataUpdate() { isPaused = torrentHandle.snapshot.isPaused - stateModel.detail = "\(torrentHandle.snapshot.friendlyState.name)" // "\(torrentHandle.snapshot.state.rawValue) | \(torrentHandle.snapshot.isPaused ? "Paused" : "Running")" + canResume = torrentHandle.snapshot.canResume + canPause = torrentHandle.snapshot.canPause + storageError = torrentHandle.snapshot.friendlyState == .storageError + + stateModel.detail = torrentHandle.snapshot.friendlyState.name downloadModel.detail = "\(torrentHandle.snapshot.downloadRate.bitrateToHumanReadable)/s" uploadModel.detail = "\(torrentHandle.snapshot.uploadRate.bitrateToHumanReadable)/s" @@ -191,6 +244,11 @@ private extension TorrentDetailsViewModel { uploadedModel.detail = "\(torrentHandle.snapshot.totalUpload.bitrateToHumanReadable)" seedersModel.detail = "\(torrentHandle.snapshot.numberOfSeeds)(\(torrentHandle.snapshot.numberOfTotalSeeds))" leechersModel.detail = "\(torrentHandle.snapshot.numberOfLeechers)(\(torrentHandle.snapshot.numberOfTotalLeechers))" + + downloadPath2Model.detail = torrentHandle.snapshot.downloadPath.path() + downloadPathModel.value = torrentHandle.snapshot.storage.name + + filesModel.isEnabled = torrentHandle.snapshot.friendlyState != .storageError } func reload() { @@ -253,6 +311,13 @@ private extension TorrentDetailsViewModel { leechersModel }) + if !preferences.storageScopes.isEmpty { + sections.append(.init(id: "path", header: %"details.path") { + // downloadPath2Model + downloadPathModel + }) + } + sections.append(.init(id: "actions", header: %"details.actions") { trackersModel filesModel diff --git a/iTorrent/Screens/TorrentFiles/Cells/TorrentFilesFileItem/TorrentFilesFileItemViewModel.swift b/iTorrent/Screens/TorrentFiles/Cells/TorrentFilesFileItem/TorrentFilesFileItemViewModel.swift index 49ae9089..5b1d392c 100644 --- a/iTorrent/Screens/TorrentFiles/Cells/TorrentFilesFileItem/TorrentFilesFileItemViewModel.swift +++ b/iTorrent/Screens/TorrentFiles/Cells/TorrentFilesFileItem/TorrentFilesFileItemViewModel.swift @@ -56,7 +56,7 @@ class TorrentFilesFileItemViewModel: BaseViewModelWith<(TorrentHandle, Int)>, Mv } var path: URL { - TorrentService.downloadPath.appending(path: file.path) + torrentHandle.snapshot.downloadPath.appending(path: file.path) } func setPriority(_ priority: FileEntry.Priority) { diff --git a/iTorrent/Screens/TorrentFiles/Cells/TorrentFilesFileItem/TorrentFilesFileListCell.swift b/iTorrent/Screens/TorrentFiles/Cells/TorrentFilesFileItem/TorrentFilesFileListCell.swift index fa161b65..49b8f17e 100644 --- a/iTorrent/Screens/TorrentFiles/Cells/TorrentFilesFileItem/TorrentFilesFileListCell.swift +++ b/iTorrent/Screens/TorrentFiles/Cells/TorrentFilesFileItem/TorrentFilesFileListCell.swift @@ -57,17 +57,17 @@ class TorrentFilesFileListCell: MvvmCollectionVie disposeBag.bind { viewModel.updatePublisher - .receive(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: .global(qos: .userInitiated)) .sink - { [weak self] _ in - self?.reload() + { [weak self, viewModel] _ in + self?.reload(with: viewModel) } viewModel.selected.sink { [unowned self] _ in switchView.setOn(!switchView.isOn, animated: true) switcher(switchView) } } - reload() + reload(with: viewModel) } func shareAction() { @@ -96,7 +96,7 @@ class TorrentFilesFileListCell: MvvmCollectionVie } private extension TorrentFilesFileListCell { - func reload() { + func reload(with viewModel: VM) { let file = viewModel.file let percent = "\(String(format: "%.2f", file.progress * 100))%" diff --git a/iTorrent/Screens/TorrentFiles/TorrentFilesViewController.swift b/iTorrent/Screens/TorrentFiles/TorrentFilesViewController.swift index 745ba8c3..acffb43b 100644 --- a/iTorrent/Screens/TorrentFiles/TorrentFilesViewController.swift +++ b/iTorrent/Screens/TorrentFiles/TorrentFilesViewController.swift @@ -139,7 +139,8 @@ private extension TorrentFilesViewController { // @MainActor func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { let path = parent.viewModel.filesForPreview[index].path - return TorrentService.downloadPath.appending(path: path) as NSURL + let res = parent.viewModel.downloadPath.appending(path: path).standardized as NSURL + return res } } @@ -148,7 +149,7 @@ private extension TorrentFilesViewController { else { return } let path = viewModel.filesForPreview[startIndex].path - let url = TorrentService.downloadPath.appending(path: path) + let url = viewModel.downloadPath.appending(path: path) Task { // Allow to choose be @@ -183,7 +184,7 @@ private extension TorrentFilesViewController { } catch {} let path = viewModel.filesForPreview[startIndex].path - let url = TorrentService.downloadPath.appending(path: path) + let url = viewModel.downloadPath.appending(path: path) let player = AVPlayer(url: url) let playerController = AVPlayerViewController.resolve() playerController.canStartPictureInPictureAutomaticallyFromInline = true diff --git a/iTorrent/Screens/TorrentFiles/TorrentFilesViewModel.swift b/iTorrent/Screens/TorrentFiles/TorrentFilesViewModel.swift index b345ba82..6827d70f 100644 --- a/iTorrent/Screens/TorrentFiles/TorrentFilesViewModel.swift +++ b/iTorrent/Screens/TorrentFiles/TorrentFilesViewModel.swift @@ -102,6 +102,10 @@ extension TorrentFilesViewModel { keys.count } + var downloadPath: URL { + torrentHandle.snapshot.downloadPath + } + var filesForPreview: [FileEntry] { keys.flatMap { switch rootDirectory.storage[$0] { diff --git a/iTorrent/Screens/TorrentList/TorrentListViewController.swift b/iTorrent/Screens/TorrentList/TorrentListViewController.swift index 5cc8f26e..b7ab31a4 100644 --- a/iTorrent/Screens/TorrentList/TorrentListViewController.swift +++ b/iTorrent/Screens/TorrentList/TorrentListViewController.swift @@ -157,10 +157,10 @@ class TorrentListViewController: BaseViewController { Just(()) - .combineLatest($allocateMemory) .combineLatest($maxActiveTorrents) .combineLatest($maxDownloadingTorrents) .combineLatest($maxUploadingTorrents) diff --git a/iTorrent/Services/TorrentMonitoringService.swift b/iTorrent/Services/TorrentMonitoringService.swift index 397f005e..8c5311ae 100644 --- a/iTorrent/Services/TorrentMonitoringService.swift +++ b/iTorrent/Services/TorrentMonitoringService.swift @@ -17,12 +17,22 @@ class TorrentMonitoringService { disposeBag.bind { torrentService.updateNotifier.sink { [unowned self] updateModel in checkDoneNotification(with: updateModel) + checkStorageAvailability(with: updateModel) } } } } private extension TorrentMonitoringService { + func checkStorageAvailability(with model: TorrentService.TorrentUpdateModel) { + guard let storage = model.handle.storage, + !storage.allowed, + !model.handle.snapshot.isPaused + else { return } + + model.handle.pause() + } + func checkDoneNotification(with model: TorrentService.TorrentUpdateModel) { guard PreferencesStorage.shared.isDownloadNotificationsEnabled, model.oldSnapshot.state != .checkingFiles, diff --git a/iTorrent/Services/TorrentService/Extensions/StorageModel+Extensions.swift b/iTorrent/Services/TorrentService/Extensions/StorageModel+Extensions.swift new file mode 100644 index 00000000..1c904ff2 --- /dev/null +++ b/iTorrent/Services/TorrentService/Extensions/StorageModel+Extensions.swift @@ -0,0 +1,23 @@ +// +// StorageModel+Extensions.swift +// iTorrent +// +// Created by Daniil Vinogradov on 05/07/2024. +// + +import LibTorrent + +extension StorageModel { + static var defaultName: String { "iTorrent Default" } +} + +extension Optional where Wrapped: StorageModel { + var name: String { + switch self { + case .none: + return StorageModel.defaultName + case .some(let wrapped): + return wrapped.name + } + } +} diff --git a/iTorrent/Services/TorrentService/TorrentHandle+Extension.swift b/iTorrent/Services/TorrentService/Extensions/TorrentHandle+Extension.swift similarity index 92% rename from iTorrent/Services/TorrentService/TorrentHandle+Extension.swift rename to iTorrent/Services/TorrentService/Extensions/TorrentHandle+Extension.swift index 337c140e..3db6c844 100644 --- a/iTorrent/Services/TorrentService/TorrentHandle+Extension.swift +++ b/iTorrent/Services/TorrentService/Extensions/TorrentHandle+Extension.swift @@ -66,6 +66,10 @@ extension TorrentHandle { extension TorrentHandle.Snapshot { var friendlyState: TorrentHandle.State { + if isStorageMissing { + return .storageError + } + switch state { case .downloading: if isPaused { return .paused } @@ -80,6 +84,14 @@ extension TorrentHandle.Snapshot { return state } } + + var canResume: Bool { + isPaused && friendlyState != .storageError + } + + var canPause: Bool { + !isPaused + } } extension TorrentHandle.State { @@ -99,6 +111,8 @@ extension TorrentHandle.State { return String(localized: "torrent.state.resuming") case .paused: return String(localized: "torrent.state.paused") + case .storageError: + return String(localized: "torrent.state.storageError") @unknown default: assertionFailure("Unregistered \(Self.self) enum value is not allowed: \(self)") return "" @@ -106,6 +120,14 @@ extension TorrentHandle.State { } } +// MARK: - Storage +extension TorrentHandle.Snapshot { + var storage: StorageModel? { + guard let storageUUID else { return nil } + return TorrentService.shared.storages[storageUUID] + } +} + // MARK: - Metadata extension TorrentHandle { struct Metadata: Codable { diff --git a/iTorrent/Services/TorrentService/TorrentService.swift b/iTorrent/Services/TorrentService/TorrentService.swift index 7c8f0842..8e35b54c 100644 --- a/iTorrent/Services/TorrentService/TorrentService.swift +++ b/iTorrent/Services/TorrentService/TorrentService.swift @@ -8,6 +8,7 @@ import Combine import LibTorrent import MvvmFoundation +import UIKit extension TorrentService { struct TorrentUpdateModel { @@ -17,7 +18,7 @@ extension TorrentService { } class TorrentService { - @Published var torrents: [TorrentHandle] = [] + @Published var torrents: [TorrentHashes: TorrentHandle] = [:] var updateNotifier = PassthroughSubject() static let shared = TorrentService() @@ -30,10 +31,10 @@ class TorrentService { static var fastResumePath: URL { downloadPath.appending(path: "config") } static var metadataPath: URL { downloadPath.appending(path: "config") } - private let session: Session = { + private lazy var session: Session = { var settings = Session.Settings() - print("Working directory: \(downloadPath.path())") - return .init(downloadPath.path(), torrentsPath: torrentPath.path(), fastResumePath: fastResumePath.path(), settings: .fromPreferences(with: [])) + print("Working directory: \(Self.downloadPath.path())") + return .init(Self.downloadPath.path(), torrentsPath: Self.torrentPath.path(), fastResumePath: Self.fastResumePath.path(), settings: .fromPreferences(with: []), storages: PreferencesStorage.shared.storageScopes) }() private let disposeBag = DisposeBag() @@ -43,35 +44,26 @@ class TorrentService { } extension TorrentService { + var storages: Dictionary { + get { session.storages } + set { session.storages = newValue } + } + func checkTorrentExists(with hash: TorrentHashes) -> Bool { - torrents.contains(where: { $0.snapshot.infoHashes == hash }) + session.torrentsMap[hash] != nil } @discardableResult - func addTorrent(by file: Downloadable) -> Bool { - guard !torrents.contains(where: { file.infoHashes == $0.snapshot.infoHashes }) + func addTorrent(by file: Downloadable, at storage: UUID? = nil) -> Bool { + guard session.torrentsMap[file.infoHashes] == nil else { return false } - session.addTorrent(file) - return true - } - - @discardableResult - func addTorrent(by path: URL) -> Bool { - defer { path.stopAccessingSecurityScopedResource() } - guard path.startAccessingSecurityScopedResource(), - let file = TorrentFile(with: path) - else { return false } - - guard !torrents.contains(where: { file.infoHashes == $0.snapshot.infoHashes }) - else { return false } - - session.addTorrent(file) + session.addTorrent(file, to: storage) return true } func removeTorrent(by infoHashes: TorrentHashes, deleteFiles: Bool) { - guard let handle = torrents.first(where: { $0.snapshot.infoHashes == infoHashes }) + guard let handle = session.torrentsMap[infoHashes] else { return } handle.deleteMetadata() @@ -81,35 +73,33 @@ extension TorrentService { func updateSettings(_ settings: Session.Settings) { session.settings = settings } + + func refreshStorage(_ storage: StorageModel) -> Bool { + guard storage.resolveSequrityScopes() else { return false } + + let handles = torrents.values.filter { $0.snapshot.storageUUID == storage.uuid } + handles.forEach { $0.reload() } + return true + } } // MARK: - SessionDelegate extension TorrentService: SessionDelegate { func torrentManager(_ manager: Session, didAddTorrent torrent: TorrentHandle) { - // Do not use snapshot for 'torrent' because it is not generated yet - guard torrents.firstIndex(where: { $0.snapshot.infoHashes == torrent.infoHashes }) == nil - else { return } - torrent.prepareToAdd(into: self) - torrents.append(torrent) + torrents[torrent.snapshot.infoHashes] = torrent } func torrentManager(_ manager: Session, didRemoveTorrentWithHash hashesData: TorrentHashes) { - // Already on Main thread - guard let index = torrents.firstIndex(where: { $0.snapshot.infoHashes == hashesData }) + guard let torrent = torrents[hashesData] else { return } - let torrent = torrents[index] torrent.removePublisher.send(torrent) - torrents.remove(at: index) + torrents[hashesData] = nil } func torrentManager(_ manager: Session, didReceiveUpdateForTorrent torrent: TorrentHandle) { - // Do not use snapshot for 'torrent' because it could be not generated yet - guard let existingTorrent = torrents.first(where: { $0.snapshot.infoHashes == torrent.infoHashes }) - else { return } - - existingTorrent.__unthrottledUpdatePublisher.send() + torrent.__unthrottledUpdatePublisher.send() } func torrentManager(_ manager: Session, didErrorOccur error: Error) {} @@ -117,9 +107,12 @@ extension TorrentService: SessionDelegate { private extension TorrentService { func setup() { + // Resolve storage scopes, so Core will be able to restore states + resolveStorageScopes() + // Pause the core, so it will not deadlock app on making first snapshots session.pause(); - torrents = session.torrents.map { torrent in + torrents = session.torrentsMap.mapValues { torrent in torrent.prepareToAdd(into: self) return torrent } @@ -139,6 +132,21 @@ private extension TorrentService { session.settings = Session.Settings.fromPreferences(with: interfaces) } } + + preferences.$storageScopes.sink { [unowned self] storages in + session.storages = storages + } + } + } + + func resolveStorageScopes() { + preferences.storageScopes.values.forEach { scope in + scope.resolveSequrityScopes() + + // If storage is not allowed and it used as default, reset default + if !scope.allowed, preferences.defaultStorage == scope.uuid { + preferences.defaultStorage = nil + } } } } diff --git a/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift b/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift index 2744a3d4..6bbcc6ce 100644 --- a/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift +++ b/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift @@ -35,13 +35,19 @@ extension Publisher where Self.Failure == Never { /// - Returns: A cancellable instance, which you use when you end assignment of the received value. Deallocation of the result will tear down the subscription stream. public func uiSink(receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable { sink { value in - if Thread.isMainThread { + runOnMainThreadIfNeeded { receiveValue(value) - } else { - DispatchQueue.main.async { - receiveValue(value) - } } } } } + +func runOnMainThreadIfNeeded(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async { + action() + } + } +} diff --git a/iTorrent/Utils/Extensions/UIKit/UICellAccessory+Image.swift b/iTorrent/Utils/Extensions/UIKit/UICellAccessory+Image.swift new file mode 100644 index 00000000..ea0f51e5 --- /dev/null +++ b/iTorrent/Utils/Extensions/UIKit/UICellAccessory+Image.swift @@ -0,0 +1,25 @@ +// +// UICellAccessory+Image.swift +// iTorrent +// +// Created by Даниил Виноградов on 05.07.2024. +// + +import UIKit + +extension UICellAccessory { + public struct ImageOptions { + public var isHidden: Bool = false + public var tintColor: UIColor? = nil + } + + static func image(_ image: UIImage?, displayed: UICellAccessory.DisplayedState = .always, options: ImageOptions = .init()) -> UICellAccessory { + .customView(configuration: .init(customView: { + if let tintColor = options.tintColor { + return UIImageView(image: image?.withTintColor(tintColor, renderingMode: .alwaysOriginal)) + } else { + return UIImageView(image: image) + } + }(), placement: .trailing(displayed: displayed, at: { _ in 0 }))) + } +} diff --git a/iTorrent/Utils/Extensions/UIMenu/UIMenu+Priority.swift b/iTorrent/Utils/Extensions/UIMenu/UIMenu+Priority.swift index 2f8ca74b..9396b4f7 100644 --- a/iTorrent/Utils/Extensions/UIMenu/UIMenu+Priority.swift +++ b/iTorrent/Utils/Extensions/UIMenu/UIMenu+Priority.swift @@ -11,7 +11,7 @@ import UIKit extension UIMenu { static func makeForChangePriority( options: UIMenu.Options = [], _ setPriority: @escaping (FileEntry.Priority) -> ()) -> UIMenu { - .init(title: %"prioriry.change.title", options: options, children: [ + .init(title: %"prioriry.change.title", image: .init(resource: .icSort), options: options, children: [ UIAction(title: String(localized: "prioriry.top"), image: .init(systemName: "gauge.with.dots.needle.100percent"), handler: { _ in setPriority(.topPriority) }), diff --git a/iTorrent/Utils/Extensions/URL+Extensions.swift b/iTorrent/Utils/Extensions/URL+Extensions.swift new file mode 100644 index 00000000..b34e2978 --- /dev/null +++ b/iTorrent/Utils/Extensions/URL+Extensions.swift @@ -0,0 +1,14 @@ +// +// URL+Normalization.swift +// iTorrent +// +// Created by Daniil Vinogradov on 05/07/2024. +// + +import Foundation + +extension URL { + var isDirectory: Bool { + (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } +} diff --git a/iTorrent/Utils/SANavigation/SAViewController.swift b/iTorrent/Utils/SANavigation/SAViewController.swift index 45c1c536..85558569 100644 --- a/iTorrent/Utils/SANavigation/SAViewController.swift +++ b/iTorrent/Utils/SANavigation/SAViewController.swift @@ -20,3 +20,14 @@ class SAViewController: MvvmViewController: MvvmHostingViewController { + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if let nav = navigationController as? SANavigationController, + nav.viewControllers.last == self + { + nav.locker = false + } + } +} diff --git a/iTorrent/Utils/UserDefaultItem.swift b/iTorrent/Utils/UserDefaultItem.swift index beea181d..ba543e33 100644 --- a/iTorrent/Utils/UserDefaultItem.swift +++ b/iTorrent/Utils/UserDefaultItem.swift @@ -13,25 +13,22 @@ import MvvmFoundation struct UserDefaultItem { private let key: String private let defaultValue: T - private let value: CurrentValueSubject private let disposeBag = DisposeBag() - var wrappedValue: T { - get { value.value } - set { value.value = newValue } - } + let projectedValue: CurrentValueSubject - var projectedValue: CurrentValueSubject { - value + var wrappedValue: T { + get { projectedValue.value } + set { projectedValue.value = newValue } } init(_ key: String, _ defaultValue: T) { self.key = key self.defaultValue = defaultValue - value = CurrentValueSubject(Self.get(by: key) ?? defaultValue) + projectedValue = CurrentValueSubject(Self.get(by: key) ?? defaultValue) disposeBag.bind { - value.sink { value in + projectedValue.sink { value in Self.set(by: key, value) } }