diff --git a/.github/workflows/ios-debug.yml b/.github/workflows/ios-debug.yml new file mode 100644 index 00000000..84cff33e --- /dev/null +++ b/.github/workflows/ios-debug.yml @@ -0,0 +1,170 @@ +name: "Build iOS app" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build_with_signing: + runs-on: macos-14 + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.0 + + - name: checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Prepare Brew + run: brew install boost + + - name: Prepare LibTorrent + run: ./Submodules/LibTorrent-Swift/make.sh + + - name: Install the Apple certificate and provisioning profile + env: + FIREBASE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_INFO_PLIST_BASE64 }} + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + BUILD_CERTIFICATE_PASSWORD: ${{ secrets.BUILD_CERTIFICATE_PASSWORD }} + BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} + BUILD_PROVISION_PROFILE_PROD_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_PROD_BASE64 }} + BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_BASE64 }} + BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_PROD_BASE64: ${{ secrets.BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_PROD_BASE64 }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + # create variables + FIREBASE_INFO_PLIST_PATH=iTorrent/Core/Assets/GoogleService-Info.plist + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision + PP_PROD_PATH=$RUNNER_TEMP/build_pp_prod.mobileprovision + PW_PP_PATH=$RUNNER_TEMP/build_progresswidget_pp.mobileprovision + PW_PP_PROD_PATH=$RUNNER_TEMP/build_progresswidget_pp_prod.mobileprovision + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + + # import firebase plist to the project + echo -n "$FIREBASE_INFO_PLIST_BASE64" | base64 --decode -o $FIREBASE_INFO_PLIST_PATH + + # import certificate and provisioning profile from secrets + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH + echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH + echo -n "$BUILD_PROVISION_PROFILE_PROD_BASE64" | base64 --decode -o $PP_PROD_PATH + echo -n "$BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_BASE64" | base64 --decode -o $PW_PP_PATH + echo -n "$BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_PROD_BASE64" | base64 --decode -o $PW_PP_PROD_PATH + + # create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # import certificate to keychain + security import $CERTIFICATE_PATH -P "$BUILD_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # apply provisioning profile + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PROD_PATH ~/Library/MobileDevice/Provisioning\ Profiles + cp $PW_PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles + cp $PW_PP_PROD_PATH ~/Library/MobileDevice/Provisioning\ Profiles + + - name: build archive + run: | + xcodebuild \ + -workspace iTorrent.xcworkspace \ + -scheme "iTorrent" \ + -archivePath $RUNNER_TEMP/itorrent.xcarchive \ + -sdk iphoneos \ + -configuration Release \ + -destination generic/platform=iOS \ + clean archive + + - name: Upload archive + uses: actions/upload-artifact@v4 + with: + name: itorrent.xcarchive + path: | + ${{ runner.temp }}/itorrent.xcarchive + + export_ipa: + needs: [build_with_signing] + runs-on: macos-14 + strategy: + matrix: + org: [adhoc, prod] + include: + - org: adhoc + export_options_plist_secret: EXPORT_OPTIONS_PLIST + - org: prod + export_options_plist_secret: EXPORT_PROD_OPTIONS_PLIST + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.0 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Install the Apple certificate and provisioning profile + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + BUILD_CERTIFICATE_PASSWORD: ${{ secrets.BUILD_CERTIFICATE_PASSWORD }} + BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} + BUILD_PROVISION_PROFILE_PROD_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_PROD_BASE64 }} + BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_BASE64 }} + BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_PROD_BASE64: ${{ secrets.BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_PROD_BASE64 }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + # create variables + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision + PP_PROD_PATH=$RUNNER_TEMP/build_pp_prod.mobileprovision + PW_PP_PATH=$RUNNER_TEMP/build_progresswidget_pp.mobileprovision + PW_PP_PROD_PATH=$RUNNER_TEMP/build_progresswidget_pp_prod.mobileprovision + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + + # import certificate and provisioning profile from secrets + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH + echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH + echo -n "$BUILD_PROVISION_PROFILE_PROD_BASE64" | base64 --decode -o $PP_PROD_PATH + echo -n "$BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_BASE64" | base64 --decode -o $PW_PP_PATH + echo -n "$BUILD_PROGRESS_WIDGET_PROVISION_PROFILE_PROD_BASE64" | base64 --decode -o $PW_PP_PROD_PATH + + # create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # import certificate to keychain + security import $CERTIFICATE_PATH -P "$BUILD_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # apply provisioning profile + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PROD_PATH ~/Library/MobileDevice/Provisioning\ Profiles + cp $PW_PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles + cp $PW_PP_PROD_PATH ~/Library/MobileDevice/Provisioning\ Profiles + + - name: Export ipa + env: + EXPORT_OPTIONS_PLIST: ${{ secrets[matrix.export_options_plist_secret] }} + run: | + EXPORT_OPTS_PATH=$RUNNER_TEMP/ExportOptions.plist + echo -n "$EXPORT_OPTIONS_PLIST" | base64 --decode -o $EXPORT_OPTS_PATH + xcodebuild -exportArchive -archivePath itorrent.xcarchive -exportOptionsPlist $EXPORT_OPTS_PATH -exportPath $RUNNER_TEMP/build + + - name: Move dSYMs + run: mv itorrent.xcarchive/dSYMs $RUNNER_TEMP/dSYMs + + - name: Upload application + uses: actions/upload-artifact@v4 + with: + name: app-Release-${{ matrix.org }} + path: | + ${{ runner.temp }}/build/iTorrent.ipa + ${{ runner.temp }}/build/manifest.plist + ${{ runner.temp }}/dSYMs diff --git a/.github/workflows/ios.yml b/.github/workflows/ios-release.yml similarity index 98% rename from .github/workflows/ios.yml rename to .github/workflows/ios-release.yml index df6ff1c5..d4ca7ba0 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios-release.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 15.4 + xcode-version: 16.0 - name: checkout repository uses: actions/checkout@v4 @@ -114,7 +114,7 @@ jobs: steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 15.4 + xcode-version: 16.0 - name: Download artifacts uses: actions/download-artifact@v4 @@ -190,7 +190,7 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 15.4 + xcode-version: 16.0 - name: Download artifacts uses: actions/download-artifact@v4 @@ -224,7 +224,7 @@ jobs: steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 15.4 + xcode-version: 16.0 - name: Checkout repository uses: actions/checkout@v4 @@ -259,7 +259,7 @@ jobs: steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 15.4 + xcode-version: 16.0 - name: Download artifacts uses: actions/download-artifact@v4 diff --git a/ProgressWidget/ProgressWidgetLiveActivity.swift b/ProgressWidget/ProgressWidgetLiveActivity.swift index 4a681a7a..0b5962be 100644 --- a/ProgressWidget/ProgressWidgetLiveActivity.swift +++ b/ProgressWidget/ProgressWidgetLiveActivity.swift @@ -38,16 +38,9 @@ struct ProgressWidgetLiveActivity: Widget { // Lock screen/banner UI goes here if #available(iOS 18, *) { -#if XCODE16 ProgressWidgetLiveActivityWatchSupportContent(context: context) .tint(Color(uiColor: context.tintColor)) .padding() -#else - ProgressWidgetLiveActivityContent(context: context) - .tint(Color(uiColor: context.tintColor)) - .padding() -#endif - } else { ProgressWidgetLiveActivityContent(context: context) .tint(Color(uiColor: context.tintColor)) @@ -105,18 +98,13 @@ struct ProgressWidgetLiveActivity: Widget { } if #available(iOS 18.0, *) { -#if XCODE16 return config.supplementalActivityFamilies([.small]) -#else - return config -#endif } else { return config } } } -#if XCODE16 @available(iOS 18.0, *) struct ProgressWidgetLiveActivityWatchSupportContent: View { @Environment(\.activityFamily) var activityFamily @@ -180,7 +168,6 @@ struct ProgressWidgetLiveActivityWatchSupportContent: View { } } } -#endif struct ProgressWidgetLiveActivityContent: View { @State var context: ActivityViewContext diff --git a/Submodules/LibTorrent-Swift b/Submodules/LibTorrent-Swift index 6764be64..57838148 160000 --- a/Submodules/LibTorrent-Swift +++ b/Submodules/LibTorrent-Swift @@ -1 +1 @@ -Subproject commit 6764be6455c1d493e576c775445cb861c0424025 +Subproject commit 5783814835ec5bb110a7747186d3ad2e6ad82024 diff --git a/Submodules/MVVMFoundation b/Submodules/MVVMFoundation index 91ae3e81..af9920f8 160000 --- a/Submodules/MVVMFoundation +++ b/Submodules/MVVMFoundation @@ -1 +1 @@ -Subproject commit 91ae3e8165b347697afe2e62a51b39b90aedafa9 +Subproject commit af9920f8407a71a76a16e088768400088c775427 diff --git a/iTorrent.xcodeproj/project.pbxproj b/iTorrent.xcodeproj/project.pbxproj index 00abf062..0d83523a 100644 --- a/iTorrent.xcodeproj/project.pbxproj +++ b/iTorrent.xcodeproj/project.pbxproj @@ -186,6 +186,9 @@ D1B99D932BEE631B00F51514 /* Benefit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B99D8F2BEE631B00F51514 /* Benefit.swift */; }; D1B99D942BEE631B00F51514 /* Tier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B99D902BEE631B00F51514 /* Tier.swift */; }; D1B99D962BEE657F00F51514 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B99D952BEE657F00F51514 /* API.swift */; }; + D1BB073B2C98524B00981D5F /* TrackersListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BB073A2C98524A00981D5F /* TrackersListService.swift */; }; + D1BB07432C985EB800981D5F /* TrackersListPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BB07422C985EAE00981D5F /* TrackersListPreferencesView.swift */; }; + D1BB07452C9869C300981D5F /* TrackersListDetailsPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BB07442C9869B900981D5F /* TrackersListDetailsPreferencesView.swift */; }; D1CAB8852AF3B51E00EB6AFF /* ToggleCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CAB8842AF3B51E00EB6AFF /* ToggleCellView.swift */; }; D1CAB8872AF3B52E00EB6AFF /* ToggleCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CAB8862AF3B52E00EB6AFF /* ToggleCellViewModel.swift */; }; D1D1279B2BC7CA7600C04533 /* SwiftUILayoutGuides.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D1279A2BC7CA7600C04533 /* SwiftUILayoutGuides.swift */; }; @@ -418,6 +421,9 @@ D1B99D8F2BEE631B00F51514 /* Benefit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Benefit.swift; sourceTree = ""; }; D1B99D902BEE631B00F51514 /* Tier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Tier.swift; sourceTree = ""; }; D1B99D952BEE657F00F51514 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + D1BB073A2C98524A00981D5F /* TrackersListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackersListService.swift; sourceTree = ""; }; + D1BB07422C985EAE00981D5F /* TrackersListPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackersListPreferencesView.swift; sourceTree = ""; }; + D1BB07442C9869B900981D5F /* TrackersListDetailsPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackersListDetailsPreferencesView.swift; sourceTree = ""; }; D1CAB8842AF3B51E00EB6AFF /* ToggleCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleCellView.swift; sourceTree = ""; }; D1CAB8862AF3B52E00EB6AFF /* ToggleCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleCellViewModel.swift; sourceTree = ""; }; D1D1279A2BC7CA7600C04533 /* SwiftUILayoutGuides.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUILayoutGuides.swift; sourceTree = ""; }; @@ -793,6 +799,7 @@ D111384C2AF9663F008907F7 /* Preferences */ = { isa = PBXGroup; children = ( + D1BB07412C985E9700981D5F /* TrackersList */, 7C95B7A32C34B554000EC50F /* Storage */, D1DB718E2BD92206007F9267 /* Patreon */, 7CB6F6CC2BD82B8A00D0813B /* FileSharing */, @@ -1118,6 +1125,7 @@ D1A226F02AEF018500669D6D /* Services */ = { isa = PBXGroup; children = ( + D1BB07392C98522D00981D5F /* TrackersListService */, 7C1C08AE2C31FEF700569B45 /* IntentsService */, 7C3142D42C31ED4600397E82 /* LiveActivityService */, D1B99D842BEE5E4100F51514 /* Patreon */, @@ -1256,6 +1264,23 @@ path = Models; sourceTree = ""; }; + D1BB07392C98522D00981D5F /* TrackersListService */ = { + isa = PBXGroup; + children = ( + D1BB073A2C98524A00981D5F /* TrackersListService.swift */, + ); + path = TrackersListService; + sourceTree = ""; + }; + D1BB07412C985E9700981D5F /* TrackersList */ = { + isa = PBXGroup; + children = ( + D1BB07442C9869B900981D5F /* TrackersListDetailsPreferencesView.swift */, + D1BB07422C985EAE00981D5F /* TrackersListPreferencesView.swift */, + ); + path = TrackersList; + sourceTree = ""; + }; D1CAB8832AF3B50C00EB6AFF /* ToggleCell */ = { isa = PBXGroup; children = ( @@ -1539,6 +1564,7 @@ D11138552AF97511008907F7 /* TorrentAddFileItemViewModel.swift in Sources */, D11333B52AF19C4900FA017E /* TorrentHandle+Extension.swift in Sources */, D19E00202AEFFA1B000A17A2 /* DetailCellView.swift in Sources */, + D1BB07452C9869C300981D5F /* TrackersListDetailsPreferencesView.swift in Sources */, D1ACFDC62AF6DB9F0098FF56 /* TorrentFilesFileListCell.swift in Sources */, D1A227072AEF0B2C00669D6D /* TorrentListItem.swift in Sources */, D111384B2AF965F1008907F7 /* TorrentAddViewModel.swift in Sources */, @@ -1602,6 +1628,7 @@ 7CFEBE7C2BC4318E0013233F /* RssChannelViewController.swift in Sources */, 7C5FBE2C2BBDD6B40069E5A0 /* UIView+LayerColors.swift in Sources */, 7C4ED08D2BE646E40034B62C /* AdView+Meta.swift in Sources */, + D1BB07432C985EB800981D5F /* TrackersListPreferencesView.swift in Sources */, D1B538572AF1171900694AFD /* TorrentDetailProgressCellView.swift in Sources */, 7C5FBE742BC2F0A30069E5A0 /* MvvmViewModelProtocol+Alert.swift in Sources */, D1EFCD122AF572E400D33A7A /* TorrentFilesFileItemViewModel.swift in Sources */, @@ -1672,6 +1699,7 @@ D1DB71802BD6773E007F9267 /* RssSearchViewModel.swift in Sources */, D17733EE2BBC2C5F006FC81A /* ProxyPreferencesViewModel.swift in Sources */, 7CF6DA3E2C0F9DC40033D03F /* LiveActivityService.swift in Sources */, + D1BB073B2C98524B00981D5F /* TrackersListService.swift in Sources */, 7C4ED0982BEF8B8E0034B62C /* PatreonCredentials.swift in Sources */, D173D9E12BC0286800E4F9EB /* UIMenu+Priority.swift in Sources */, D1AA00CE2AFA8B1000B74629 /* PreferencesStorage.swift in Sources */, @@ -1744,6 +1772,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Dev"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$inherited XCODE16"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1891,6 +1920,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Dev"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$inherited XCODE16"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -2033,6 +2063,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Distrib"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$inherited XCODE16"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/iTorrent/Core/Assets/Localizable.xcstrings b/iTorrent/Core/Assets/Localizable.xcstrings index 11a2bee0..c15608fe 100644 --- a/iTorrent/Core/Assets/Localizable.xcstrings +++ b/iTorrent/Core/Assets/Localizable.xcstrings @@ -11,7 +11,7 @@ }, "es" : { "stringUnit" : { - "state" : "new", + "state" : "translated", "value" : "%1$@ de %2$@ (%3$@)" } }, @@ -27,8 +27,7 @@ "value" : "%1$@ 的 %2$@ (%3$@)" } } - }, - "shouldTranslate" : false + } }, "%lld / %lld items" : { "localizations" : { @@ -4912,6 +4911,34 @@ } } }, + "preferences.network.trackers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trackers sources" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Trackers sources" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Источники трекеров" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Trackers sources" + } + } + } + }, "preferences.notifications" : { "localizations" : { "en" : { @@ -5780,6 +5807,314 @@ } } }, + "preferences.trackers.add.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add trackers list" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add trackers list" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить список трекеров" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add trackers list" + } + } + } + }, + "preferences.trackers.add.titlePlaceholder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Title" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Title" + } + } + } + }, + "preferences.trackers.add.urlPlaceholder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL to trackers source file" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "URL to trackers source file" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL на источник списка трекеров" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "URL to trackers source file" + } + } + } + }, + "preferences.trackers.autoadding" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable auto-addition" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable auto-addition" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить автодобавление" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable auto-addition" + } + } + } + }, + "preferences.trackers.autoadding.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatically add all trackers to newly added torrents" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically add all trackers to newly added torrents" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматически добавляет все трекеры в новые торренты" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically add all trackers to newly added torrents" + } + } + } + }, + "preferences.trackers.empty.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add URL to trackers list source by pressing «+»" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add URL to trackers list source by pressing «+»" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавьте URL на источник списка трекеров нажав «+»" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add URL to trackers list source by pressing «+»" + } + } + } + }, + "preferences.trackers.empty.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker sources list is empty" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tracker sources list is empty" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Список источников трекеров пуст" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tracker sources list is empty" + } + } + } + }, + "preferences.trackers.exists.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This trackers source already added" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "This trackers source already added" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот источник уже присутствует" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "This trackers source already added" + } + } + } + }, + "preferences.trackers.remove.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you want to remove this trackers source?" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Do you want to remove this trackers source?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы хотите удалить этот источник трекеров?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Do you want to remove this trackers source?" + } + } + } + }, + "preferences.trackers.remove.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove source" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remove source" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить источник" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remove source" + } + } + } + }, + "preferences.trackers.rename.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rename source" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rename source" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переименовать источник" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rename source" + } + } + } + }, "preferences.version" : { "localizations" : { "en" : { @@ -7544,6 +7879,118 @@ } } }, + "trackers.add" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add trackers" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add trackers" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить трекеры" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add trackers" + } + } + } + }, + "trackers.add.fromSource" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "From sources list" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "From sources list" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Из списка источников" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "From sources list" + } + } + } + }, + "trackers.add.fromSource.all" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import all" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Import all" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Импортировать все" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Import all" + } + } + } + }, + "trackers.add.manually" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manually" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Manually" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вручную" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Manually" + } + } + } + }, "trackers.add.message" : { "localizations" : { "en" : { @@ -7740,6 +8187,34 @@ } } }, + "trackers.remove" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Trackers" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remove Trackers" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить трекеры" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remove Trackers" + } + } + } + }, "trackers.remove.title" : { "localizations" : { "en" : { @@ -7768,6 +8243,90 @@ } } }, + "trackersList.add" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add source" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add source" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить источник" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add source" + } + } + } + }, + "trackersList.add.local" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Local source" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Local source" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Локальный" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Local source" + } + } + } + }, + "trackersList.add.remote" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remote source" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remote source" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалённый" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remote source" + } + } + } + }, "webserver.unavailable" : { "localizations" : { "en" : { diff --git a/iTorrent/Core/SceneDelegate/SceneDelegate.swift b/iTorrent/Core/SceneDelegate/SceneDelegate.swift index 6b14ec19..14eede22 100644 --- a/iTorrent/Core/SceneDelegate/SceneDelegate.swift +++ b/iTorrent/Core/SceneDelegate/SceneDelegate.swift @@ -24,6 +24,7 @@ class SceneDelegate: MvvmSceneDelegate { container.registerSingleton(factory: { BackgroundService.shared }) container.registerSingleton(factory: NetworkMonitoringService.init) container.registerSingleton(factory: ImageLoader.init) + container.registerSingleton(factory: TrackersListService.init) container.registerDaemon(factory: PatreonService.init) container.registerDaemon(factory: TorrentMonitoringService.init) container.registerDaemon(factory: RssFeedProvider.init) @@ -62,6 +63,8 @@ class SceneDelegate: MvvmSceneDelegate { router.register(BasePreferencesViewController.self) router.register(BasePreferencesViewController.self) + router.register(TrackersListPreferencesViewController.self) + router.register(TrackersListDetailsPreferencesViewController.self) router.register(BasePreferencesViewController.self) router.register(BasePreferencesViewController.self) router.register(PreferencesSectionGroupingViewController.self) diff --git a/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift b/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift index 230145b9..31f7a4cf 100644 --- a/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift +++ b/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift @@ -143,6 +143,9 @@ private extension PreferencesViewModel { PRButtonViewModel(with: .init(title: %"preferences.network.proxy", accessories: [.disclosureIndicator()]) { [unowned self] in navigate(to: ProxyPreferencesViewModel.self, by: .show) }) + PRButtonViewModel(with: .init(title: %"preferences.network.trackers", accessories: [.disclosureIndicator()]) { [unowned self] in + navigate(to: TrackersListPreferencesViewModel.self, by: .show) + }) PRButtonViewModel(with: .init(title: %"preferences.network.connection", accessories: [.disclosureIndicator()]) { [unowned self] in navigate(to: ConnectionPreferencesViewModel.self, by: .show) }) diff --git a/iTorrent/Screens/Preferences/TrackersList/TrackersListDetailsPreferencesView.swift b/iTorrent/Screens/Preferences/TrackersList/TrackersListDetailsPreferencesView.swift new file mode 100644 index 00000000..2a991eba --- /dev/null +++ b/iTorrent/Screens/Preferences/TrackersList/TrackersListDetailsPreferencesView.swift @@ -0,0 +1,108 @@ +// +// TrackersListDetailsPreferencesView.swift +// iTorrent +// +// Created by Daniil Vinogradov on 16/09/2024. +// + +import LibTorrent +import MvvmFoundation +import SwiftUI + +class TrackersListDetailsPreferencesViewModel: BaseViewModelWith, ObservableObject { + @Published var trackers: [String] = [] + @Published var title: String = "" + @Published var source: TrackersListService.ListState.Source! + + override func prepare(with model: TrackersListService.ListState) { + source = model.source + trackers = model.trackers + title = model.title + } + + func addTracker() { + #if os(visionOS) + textInput(title: %"trackers.add.title.single", message: %"trackers.add.message.single", placeholder: "http://x.x.x.x:8080/announce", cancel: %"common.cancel", accept: %"common.add") { [unowned self] result in + guard let url = URL(string: result ?? "") else { return } + + withAnimation { + trackers.append(url.absoluteString) + trackers.removeDuplicates() + trackersListService.trackerSources.value[source]?.trackers = trackers + } + } + #else + textMultilineInput(title: %"trackers.add.title", message: %"trackers.add.message", placeholder: "http://x.x.x.x:8080/announce", accept: %"common.add") { [unowned self] result in + guard let result else { return } + + withAnimation { + result.components(separatedBy: .newlines).forEach { urlString in + guard let url = URL(string: urlString) else { return } + trackers.append(url.absoluteString) + } + + trackers.removeDuplicates() + trackersListService.trackerSources.value[source]?.trackers = trackers + } + } + #endif + } + + func removeTracker(_ tracker: String) { + trackers.removeAll(where: { $0 == tracker }) + trackersListService.trackerSources.value[source]?.trackers = trackers + } + + @Injected var trackersListService: TrackersListService +} + +struct TrackersListDetailsPreferencesView: MvvmSwiftUIViewProtocol { + @ObservedObject var viewModel: VM + var title: String + + init(viewModel: VM) { + self.viewModel = viewModel + title = viewModel.title + } + + var body: some View { + List { + Section { + ForEach(viewModel.trackers, id: \.self) { tracker in + Button { + UIPasteboard.general.string = tracker + viewModel.alertWithTimer(1, title: %"trackers.action.copy") + } label: { + Text(tracker) + .foregroundStyle(Color(uiColor: .label)) + } + .swipeActions { + if case .local = viewModel.source { + Button { + withAnimation { + viewModel.removeTracker(tracker) + } + } label: { + Image(systemName: "trash") + }.tint(.red) + } + } + } + } + } + } +} + +class TrackersListDetailsPreferencesViewController: BaseHostingViewController> { + private lazy var addButtonItem = UIBarButtonItem(systemItem: .add, primaryAction: .init(title: "") { [unowned self] _ in + viewModel.addTracker() + }) + + override func viewDidLoad() { + super.viewDidLoad() + + if case .local = viewModel.source { + navigationItem.trailingItemGroups.append(.fixedGroup(items: [addButtonItem])) + } + } +} diff --git a/iTorrent/Screens/Preferences/TrackersList/TrackersListPreferencesView.swift b/iTorrent/Screens/Preferences/TrackersList/TrackersListPreferencesView.swift new file mode 100644 index 00000000..e1c4a28a --- /dev/null +++ b/iTorrent/Screens/Preferences/TrackersList/TrackersListPreferencesView.swift @@ -0,0 +1,151 @@ +// +// TrackersListPreferencesView.swift +// iTorrent +// +// Created by Daniil Vinogradov on 16/09/2024. +// + +import LibTorrent +import MvvmFoundation +import SwiftUI + +class TrackersListPreferencesViewModel: BaseViewModel, ObservableObject { + @Published var sorces: [TrackersListService.ListState] = [] + @Published var isAutoaddingEnabled: Bool + + required init() { + isAutoaddingEnabled = PreferencesStorage.shared.isTrackersAutoaddingEnabled + super.init() + + disposeBag.bind { + $isAutoaddingEnabled.sink { value in + PreferencesStorage.shared.isTrackersAutoaddingEnabled = value + } + + trackersListService.trackerSources.uiSink { [unowned self] values in + withAnimation { + sorces = Array(values.values) + } + } + } + } + + func addRemoteTracker() { + textInputs(title: %"preferences.trackers.add.title", textInputs: [ + .init(placeholder: %"preferences.trackers.add.titlePlaceholder"), + .init(placeholder: %"preferences.trackers.add.urlPlaceholder") + ]) { [weak self] results in + guard let self, + let results, + let url = URL(string: results[1]) + else { return } + + Task { + guard !self.trackersListService.trackerSources.value.keys.contains(where: { $0 == .remote(url) }) else { + self.alert(title: %"preferences.trackers.exists.title", actions: [.init(title: %"common.ok", style: .cancel)]) + return + } + try await self.trackersListService.addTrackersSource(url, title: results[0]) + } + } + } + + func addLocalTracker() { + textInput(title: %"preferences.trackers.add.title", placeholder: %"preferences.trackers.add.titlePlaceholder") { [weak self] text in + guard let self, let text + else { return } + + self.trackersListService.createLocalSource(title: text) + } + } + + func showDetails(_ model: TrackersListService.ListState) { + navigate(to: TrackersListDetailsPreferencesViewModel.self, with: model, by: .show) + } + + func renameDetails(_ model: TrackersListService.ListState) { + textInput(title: %"preferences.trackers.rename.title", placeholder: model.title, defaultValue: model.title) { [weak self] result in + guard let self, let result, !result.isEmpty else { return } + trackersListService.trackerSources.value[model.source]?.title = result + } + } + + @Injected var trackersListService: TrackersListService +} + +struct TrackersListPreferencesView: MvvmSwiftUIViewProtocol { + @ObservedObject var viewModel: VM + var title: String = %"preferences.network.trackers" + + init(viewModel: VM) { + self.viewModel = viewModel + } + + var body: some View { + List { + if !viewModel.sorces.isEmpty { + Section { + Toggle(isOn: $viewModel.isAutoaddingEnabled) { + Text("preferences.trackers.autoadding") + } + } footer: { + Text("preferences.trackers.autoadding.footer") + } + Section { + ForEach(viewModel.sorces) { state in + Button { + viewModel.showDetails(state) + } label: { + NavigationLink(state.title, destination: EmptyView()) + } + .foregroundColor(Color(uiColor: .label)) + .swipeActions { + Button { + viewModel.alert(title: %"preferences.trackers.remove.title", message: %"preferences.trackers.remove.message", actions: [ + .init(title: %"common.cancel", style: .cancel), + .init(title: %"common.remove", style: .destructive, action: { + withAnimation { + viewModel.trackersListService.trackerSources.value[state.source] = nil + } + }) + ]) + } label: { + Image(systemName: "trash") + }.tint(.red) + + Button { + viewModel.renameDetails(state) + } label: { + Image(systemName: "character.textbox") + }.tint(.init(uiColor: PreferencesStorage.shared.tintColor)) + } + } + } + } + } + .overlay { + if viewModel.sorces.isEmpty { + if #available(iOS 17.0, *) { + ContentUnavailableView("preferences.trackers.empty.title", systemImage: "folder", description: Text("preferences.trackers.empty.message")) + } + } + } + } +} + +class TrackersListPreferencesViewController: BaseHostingViewController> { + private lazy var addButtonItem = UIBarButtonItem(systemItem: .add, menu: .init(title: %"trackersList.add", children: [ + UIAction(title: %"trackersList.add.remote", image: .init(systemName: "link")) { [unowned self] _ in + viewModel.addRemoteTracker() + }, + UIAction(title: %"trackersList.add.local", image: .init(systemName: "opticaldiscdrive.fill")) { [unowned self] _ in + viewModel.addLocalTracker() + } + ])) + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.trailingItemGroups.append(.fixedGroup(items: [addButtonItem])) + } +} diff --git a/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewController.swift b/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewController.swift index 9cd795eb..9e5a14bc 100644 --- a/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewController.swift +++ b/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewController.swift @@ -10,7 +10,7 @@ import UIKit class TorrentTrackersViewController: BaseViewController { @IBOutlet private var collectionView: MvvmCollectionView! - private let addButton = UIBarButtonItem() + private let addButton = UIBarButtonItem(systemItem: .add) private let removeButton = UIBarButtonItem() private let reannounceButton = UIBarButtonItem() @@ -36,8 +36,8 @@ class TorrentTrackersViewController: BaseViewContr if #available(iOS 17.0, *) { var config = UIContentUnavailableConfiguration.empty() config.image = .init(systemName: "externaldrive.fill.badge.questionmark") - config.text = %"trackers.empty.title" //"No trackers" - config.secondaryText = %"trackers.empty.subtitle" //"You can add trackers manually by editing this page" + config.text = %"trackers.empty.title" // "No trackers" + config.secondaryText = %"trackers.empty.subtitle" // "You can add trackers manually by editing this page" contentUnavailableConfiguration = isEmpty ? config : nil } } @@ -51,10 +51,23 @@ class TorrentTrackersViewController: BaseViewContr // collectionView.delegate = delegates - addButton.primaryAction = .init(title: "Add Trackers", image: .init(systemName: "plus"), handler: { [unowned self] _ in - viewModel.addTrackers() - }) - removeButton.primaryAction = .init(title: "Remove Trackers", image: .init(systemName: "trash"), handler: { [unowned self] _ in + let importActions: [UIAction] = viewModel.trackersListService.trackerSources.value.values.map { [unowned self] source in + .init(title: source.title) { [unowned self] _ in + viewModel.addTrackers(from: source) + } + } + + addButton.menu = .init(title: %"trackers.add", children: [ + UIAction(title: %"trackers.add.manually") { [unowned self] _ in + viewModel.addTrackers() + }, + UIMenu(title: %"trackers.add.fromSource", children: importActions + [ + UIAction(title: %"trackers.add.fromSource.all", image: .init(systemName: "list.bullet")) { [unowned self] _ in + viewModel.addAllTrackersFromSourcesList() + } + ]) + ]) + removeButton.primaryAction = .init(title: %"trackers.remove", image: .init(systemName: "trash"), handler: { [unowned self] _ in viewModel.removeSelected() }) reannounceButton.primaryAction = .init(title: %"trackers.reannounceAll") { [unowned self] _ in @@ -81,15 +94,13 @@ class TorrentTrackersViewController: BaseViewContr private lazy var editToolbar: [UIBarButtonItem] = { [ - .init(systemItem: .flexibleSpace), + // .init(systemItem: .flexibleSpace), addButton, .init(systemItem: .flexibleSpace), - removeButton, - .init(systemItem: .flexibleSpace) + removeButton +// .init(systemItem: .flexibleSpace) ] }() } -private extension TorrentTrackersViewController { - -} +private extension TorrentTrackersViewController {} diff --git a/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewModel.swift b/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewModel.swift index 2014ef2b..4c0ae24b 100644 --- a/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewModel.swift +++ b/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewModel.swift @@ -17,6 +17,8 @@ class TorrentTrackersViewModel: BaseViewModelWith { @Published var sections: [MvvmCollectionSectionModel] = [] @Published var selectedIndexPaths: [IndexPath] = [] + @Injected var trackersListService: TrackersListService + var isRemoveAvailable: AnyPublisher { $selectedIndexPaths.map { !$0.isEmpty } .eraseToAnyPublisher() @@ -35,24 +37,37 @@ class TorrentTrackersViewModel: BaseViewModelWith { extension TorrentTrackersViewModel { func addTrackers() { - #if !os(visionOS) + #if os(visionOS) + textInput(title: %"trackers.add.title.single", message: %"trackers.add.message.single", placeholder: "http://x.x.x.x:8080/announce", cancel: %"common.cancel", accept: %"common.add") { [unowned self] result in + guard let url = URL(string: result ?? "") else { return } + torrentHandle.addTracker(url.absoluteString) + reload() + } + #else textMultilineInput(title: %"trackers.add.title", message: %"trackers.add.message", placeholder: "http://x.x.x.x:8080/announce", accept: %"common.add") { [unowned self] result in guard let result else { return } result.components(separatedBy: .newlines).forEach { urlString in guard let url = URL(string: urlString) else { return } torrentHandle.addTracker(url.absoluteString) - reload() } - } - #else - textInput(title: %"trackers.add.title.single", message: %"trackers.add.message.single", placeholder: "http://x.x.x.x:8080/announce", cancel: %"common.cancel", accept: %"common.add") { [unowned self] result in - guard let url = URL(string: result ?? "") else { return } - torrentHandle.addTracker(url.absoluteString) reload() } #endif } + func addTrackers(from list: TrackersListService.ListState) { + list.trackers.forEach { urlString in + guard let url = URL(string: urlString) else { return } + torrentHandle.addTracker(url.absoluteString) + } + reload() + } + + func addAllTrackersFromSourcesList() { + trackersListService.addAllTrackers(to: torrentHandle) + reload() + } + func removeSelected() { alert(title: %"trackers.remove.title", actions: [ .init(title: %"common.delete", style: .destructive, action: { [unowned self] in diff --git a/iTorrent/Services/Preferences/PreferencesStorage.swift b/iTorrent/Services/Preferences/PreferencesStorage.swift index 19827d82..ba713788 100644 --- a/iTorrent/Services/Preferences/PreferencesStorage.swift +++ b/iTorrent/Services/Preferences/PreferencesStorage.swift @@ -69,6 +69,8 @@ class PreferencesStorage: Resolvable { @UserDefaultItem("preferencesMaxUploadSpeed", 0) var maxUploadSpeed: UInt @UserDefaultItem("preferencesMaxDownloadSpeed", 0) var maxDownloadSpeed: UInt + @UserDefaultItem("preferencesTrackersAutoaddingEnabled", false) var isTrackersAutoaddingEnabled: Bool + @UserDefaultItem("preferencesIsCellularEnabled", false) var isCellularEnabled: Bool @UserDefaultItem("preferencesUseAllAvailableInterfaces", false) var useAllAvailableInterfaces: Bool diff --git a/iTorrent/Services/TorrentService/TorrentService.swift b/iTorrent/Services/TorrentService/TorrentService.swift index 6c59f7aa..6c7f3207 100644 --- a/iTorrent/Services/TorrentService/TorrentService.swift +++ b/iTorrent/Services/TorrentService/TorrentService.swift @@ -41,6 +41,7 @@ class TorrentService { @Injected private var network: NetworkMonitoringService @Injected private var preferences: PreferencesStorage + @Injected private var trackersListService: TrackersListService } extension TorrentService { @@ -91,6 +92,11 @@ extension TorrentService: SessionDelegate { func torrentManager(_ manager: Session, didAddTorrent torrent: TorrentHandle) { torrent.prepareToAdd(into: self) torrents[torrent.snapshot.infoHashes] = torrent + + // Add trackers from torrent list service if needed + if preferences.isTrackersAutoaddingEnabled { + trackersListService.addAllTrackers(to: torrent) + } } func torrentManager(_ manager: Session, didRemoveTorrentWithHash hashesData: TorrentHashes) { diff --git a/iTorrent/Services/TrackersListService/TrackersListService.swift b/iTorrent/Services/TrackersListService/TrackersListService.swift new file mode 100644 index 00000000..8b9c4c2d --- /dev/null +++ b/iTorrent/Services/TrackersListService/TrackersListService.swift @@ -0,0 +1,112 @@ +// +// TrackersListService.swift +// iTorrent +// +// Created by Daniil Vinogradov on 16/09/2024. +// + +import Foundation +import LibTorrent +import MvvmFoundation +import Combine + +extension TrackersListService.ListState { + enum Status: Codable { + case updated + case error + } + + enum Source: Identifiable, Codable, Hashable { + var id: Self { self } + + case remote(URL) + case local(UUID) + } +} + +extension TrackersListService { + struct ListState: Identifiable, Codable { + var id: Source { source } + + var source: Source + var title: String + var status: Status + var trackers: [String] + } +} + +class TrackersListService { + let trackerSources: CurrentValueSubject<[ListState.Source: ListState], Never> + + init() { + trackerSources = CurrentValueSubject<[ListState.Source: ListState], Never>((try? Self.load()) ?? [:]) + + trackerSources.sink { urls in + try? Self.safe(urls) + }.store(in: disposeBag) + + Task { await Self.refresh(trackerSources.value) } + } + + private let disposeBag = DisposeBag() + private static let key = "TrackersListServiceDataKey" +} + +extension TrackersListService { + func addTrackersSource(_ url: URL, title: String) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + let trackers = String(data: data, encoding: .utf8)?.components(separatedBy: "\n").filter { !$0.isEmpty } ?? [] + let listState = ListState(source: .remote(url), title: title, status: .updated, trackers: trackers) + trackerSources.value[listState.source] = listState + } + + func createLocalSource(title: String) { + let listState: TrackersListService.ListState = .init(source: .local(UUID()), title: title, status: .updated, trackers: []) + trackerSources.value[listState.source] = listState + } + + func addAllTrackers(to torrent: TorrentHandle) { + trackerSources.value.values.forEach { state in + state.trackers.forEach { urlString in + guard let url = URL(string: urlString) else { return } + torrent.addTracker(url.absoluteString) + } + } + } +} + +private extension TrackersListService { + static func refresh(_ oldValues: [ListState.Source: ListState]) async -> [ListState.Source: ListState] { + await withTaskGroup(of: ListState.self, returning: [ListState.Source: ListState].self) { taskGroup in + oldValues.values.forEach { value in + guard case let .remote(url) = value.source else { return } + taskGroup.addTask { + do { + let (data, _) = try await URLSession.shared.data(from: url) + let trackers = String(data: data, encoding: .utf8)?.components(separatedBy: "\n").filter { !$0.isEmpty } ?? [] + return ListState(source: value.source, title: value.title, status: .updated, trackers: trackers) + } catch { + return .init(source: value.source, title: value.title, status: .error, trackers: value.trackers) + } + } + } + + return await taskGroup.reduce(into: [ListState.Source: ListState]()) { partialResult, state in + partialResult[state.source] = state + } + } + } +} + +private extension TrackersListService { + static func load() throws -> [ListState.Source: ListState] { + guard let data = UserDefaults.standard.data(forKey: key) + else { return [:] } + return try JSONDecoder().decode([ListState.Source: ListState].self, from: data) + } + + static func safe(_ urls: [ListState.Source: ListState]) throws { + let data = try JSONEncoder().encode(urls) + UserDefaults.standard.set(data, forKey: key) + } +} diff --git a/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift b/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift index 6bbcc6ce..04091647 100644 --- a/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift +++ b/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift @@ -8,7 +8,7 @@ import Combine import Foundation -@MainActor +//@MainActor extension Publisher where Self.Failure == Never { /// Attaches a subscriber with closure-based behavior to a publisher that never fails and receives on Main Thread if needed. diff --git a/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModel+Alert.swift b/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModel+Alert.swift index f804e7be..a4410b98 100644 --- a/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModel+Alert.swift +++ b/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModel+Alert.swift @@ -12,4 +12,8 @@ extension MvvmViewModelProtocol { func textInput(title: String?, message: String? = nil, placeholder: String?, defaultValue: String? = nil, type: UIKeyboardType = .default, secured: Bool = false, accept: String = String(localized: "common.ok"), result: @escaping (String?) -> Void) { textInput(title: title, message: message, placeholder: placeholder, defaultValue: defaultValue, type: type, secured: secured, cancel: String(localized: "common.cancel"), accept: accept, result: result) } + + func textInputs(title: String?, message: String? = nil, textInputs: [MvvmTextInputModel], accept: String = String(localized: "common.ok"), result: @escaping ([String]?) -> Void) { + self.textInputs(title: title, message: message, textInputs: textInputs, cancel: String(localized: "common.cancel"), accept: accept, result: result) + } } diff --git a/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModelProtocol+Alert.swift b/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModelProtocol+Alert.swift index 133c7315..2bc62438 100644 --- a/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModelProtocol+Alert.swift +++ b/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModelProtocol+Alert.swift @@ -9,6 +9,7 @@ import MvvmFoundation import UIKit extension MvvmViewModelProtocol { + @available(visionOS, unavailable) func textMultilineInput(title: String?, message: String? = nil, placeholder: String? = nil,