diff --git a/.github/release_message.md b/.github/release_message.md index 1396d951..001fed44 100644 --- a/.github/release_message.md +++ b/.github/release_message.md @@ -1,25 +1,27 @@ [![Release Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/RELEASE_TAG/total?style=flat-square&logo=github)](https://img.shields.io/github/downloads/hiddify/hiddify-next/RELEASE_TAG/) - - - **Release Highlights:** -- -- +- +-
تغییرات اصلی به فارسی -- -- - +- +- +
**Download based on your OS:** +
+ **بر اساس سیستم عامل خود دانلود کنید:** +
+
@@ -45,27 +47,19 @@ - + - +
macOS (v10.14+)
Linux
-
- -
+
**List of all changes:** [ChangeLog](https://github.com/hiddify/hiddify-next/blob/main/CHANGELOG.md) -
- - - - - diff --git a/.github/sync_translate.sh b/.github/sync_translate.sh index d67c2b09..d80daa1b 100644 --- a/.github/sync_translate.sh +++ b/.github/sync_translate.sh @@ -14,6 +14,7 @@ python3 auto_translator.py en fa python3 auto_translator.py en zh # python3 auto_translator.py en pt python3 auto_translator.py en ru +python3 auto_translator.py en tr diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c85dd45..945646d3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,12 +4,13 @@ on: branches: - main tags: - - "v*" + - 'v*' paths-ignore: - - "**.md" - - "docs/**" - - ".github/**" - - "!.github/workflows/build.yml" + - '**.md' + - 'docs/**' + - '.github/**' + - '!.github/workflows/build.yml' + - 'appcast.xml' # pull_request: # branches: # - main @@ -77,15 +78,15 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "3.13.x" - channel: "stable" + flutter-version: '3.16.x' + channel: 'stable' cache: true - name: Setup Java if: startsWith(matrix.platform,'android') uses: actions/setup-java@v3 with: - distribution: "zulu" + distribution: 'zulu' java-version: 11 - name: Setup NDK @@ -305,7 +306,7 @@ jobs: uses: 8Mi-Tech/delete-release-assets-action@main with: github_token: ${{ secrets.GITHUB_TOKEN }} - tag: "draft" + tag: 'draft' deleteOnlyFromDrafts: false - name: Create or Update Draft Release @@ -315,8 +316,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: files: ./out/* - name: "draft" - tag_name: "draft" + name: 'draft' + tag_name: 'draft' prerelease: true upload-release: @@ -356,7 +357,7 @@ jobs: with: prerelease: ${{ env.CHANNEL == 'dev' }} tag_name: ${{ github.ref_name }} - body_path: "./release.md" + body_path: './release.md' files: ./out/* - name: Create service_account.json @@ -369,7 +370,8 @@ jobs: packageName: app.hiddify.com releaseName: ${{ github.ref }} releaseFiles: ./hiddify-android-market.aab - track: ${{ env.CHANNEL == 'dev' && 'beta' || 'internal' }} + # track: ${{ env.CHANNEL == 'dev' && 'beta' || 'internal' }} + track: 'beta' # - name: "Upload app to TestFlight" # uses: apple-actions/upload-testflight-build@v1 @@ -389,28 +391,27 @@ jobs: id: version uses: ashley-taylor/regex-property-action@v1.3 with: - value: "${{ github.ref_name }}" - regex: "^v|.dev$" - flags: "" # Optional, defaults to "g" - replacement: "" + value: '${{ github.ref_name }}' + regex: '^v|.dev$' + flags: 'gi' # Optional, defaults to "g" + replacement: '' - name: Winget Publish if: ${{ env.CHANNEL != 'dev' }} uses: isaacrlevin/winget-publish-action@v.5 with: - publish-type: "Update" - user: "Hiddify" - package: "Next" + publish-type: 'Update' + user: 'Hiddify' + package: 'Next' version: ${{ steps.version.outputs.value }} - url: "https://github.com/hiddify/hiddify-next/releases/download/${{ github.ref_name }}/hiddify-windows-x64-setup.zip" + url: 'https://github.com/hiddify/hiddify-next/releases/download/${{ github.ref_name }}/hiddify-windows-x64-setup.zip' token: ${{ secrets.WINGET_TOKEN }} - - name: Winget Publish Beta uses: isaacrlevin/winget-publish-action@v.5 with: - publish-type: "Update" - user: "Hiddify" - package: "Next" + publish-type: 'Update' + user: 'Hiddify' + package: 'Next.Beta' version: ${{ steps.version.outputs.value }} - url: "https://github.com/hiddify/hiddify-next/releases/download/${{ github.ref_name }}/hiddify-windows-x64-setup.zip" + url: 'https://github.com/hiddify/hiddify-next/releases/download/${{ github.ref_name }}/hiddify-windows-x64-setup.zip' token: ${{ secrets.WINGET_TOKEN }} diff --git a/.github/workflows/dev-i.yml b/.github/workflows/dev-i.yml new file mode 100644 index 00000000..1fad9d30 --- /dev/null +++ b/.github/workflows/dev-i.yml @@ -0,0 +1,196 @@ +name: dev i +on: + push: + branches: + - main + tags: + - 'v*' + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/**' + - '!.github/workflows/*' + - 'appcast.xml' + # pull_request: + # branches: + # - main +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +env: + CHANNEL: ${{ github.ref_type == 'tag' && endsWith(github.ref_name, 'dev') && 'dev' || github.ref_type != 'tag' && 'dev' || 'prod' }} + NDK_VERSION: r26b + +jobs: + build: + permissions: write-all + strategy: + fail-fast: false + matrix: + include: + # - platform: android-apk + # os: ubuntu-latest + # targets: apk + + # - platform: android-aab + # os: ubuntu-latest + # targets: aab + + # - platform: windows + # os: windows-latest + # aarch: amd64 + # targets: exe + # filename: hiddify-windows-x64 + + # - platform: linux + # os: ubuntu-latest + # aarch: amd64 + # targets: AppImage + # filename: hiddify-linux-x64 + + # - platform: macos + # os: macos-13 + # aarch: universal + # targets: dmg + # filename: hiddify-macos-universal + + - platform: ios + os: macos-13 + aarch: universal + filename: hiddify-ios + targets: ipa + + runs-on: ${{ matrix.os }} + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Install macos dmg needed tools + if: matrix.platform == 'macos' || matrix.platform == 'ios' + run: | + # xcode-select --install || softwareupdate --all --install --force + # brew uninstall --force $(brew list | grep python@) && brew cleanup || echo "python not installed" + brew uninstall --ignore-dependencies python@3.12 + brew reinstall python@3.10 + python3 -m pip install --upgrade setuptools pip + brew install create-dmg tree + npm install -g appdmg + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.0.1' + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.16.x' + channel: 'stable' + cache: true + + + - name: Setup Flutter Distributor + if: ${{ !startsWith(matrix.platform,'android') }} + run: | + dart pub global activate flutter_distributor + + + - name: Get Geo Assets + run: | + make get-geo-assets + + - name: Get Dependencies + run: | + make get + + - name: Generate + run: | + make translate + make gen + + - name: Get Libs ${{ matrix.platform }} + run: | + make ${{ matrix.platform }}-libs + + + - name: Setup Apple certificate and provisioning profile + if: startsWith(matrix.os,'macos') + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.APPLE_BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }} + BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.APPLE_BUILD_PROVISION_PROFILE_BASE64 }} + BUILD_PACKET_TUNNEL_PROVISION_PROFILE_BASE64: ${{ secrets.APPLE_BUILD_PACKET_TUNNEL_PROVISION_PROFILE_BASE64 }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: | + # create variables + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision + PP_PACKET_TUNNEL_PATH=$RUNNER_TEMP/build_pppt.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_PACKET_TUNNEL_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PACKET_TUNNEL_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 "$P12_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_PACKET_TUNNEL_PATH ~/Library/MobileDevice/Provisioning\ Profiles + + - name: Release ${{ matrix.platform }} + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + run: | + make ${{ matrix.platform }}-release + + - name: Upload Debug Symbols + if: ${{ github.ref_type == 'tag' }} + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_DIST: ${{ matrix.platform == 'android-aab' && 'google-play' || 'general' }} + run: | + flutter packages pub run sentry_dart_plugin + + + - name: Copy to out unix + if: matrix.platform == 'linux' || matrix.platform == 'macos' || matrix.platform == 'ios' + run: | + ls -R dist/ + mkdir out + mkdir tmp_out + EXT="${{ matrix.targets }}" + mv dist/*/*.$EXT tmp_out/${{matrix.filename}}.$EXT + chmod +x tmp_out/${{matrix.filename}}.$EXT + if [ "${{matrix.platform}}" == "linux" ];then + cp ./.github/help/linux/* tmp_out/ + else + cp ./.github/help/mac-windows/* tmp_out/ + fi + if [[ "${{matrix.platform}}" == 'ios' ]];then + mv tmp_out/${{matrix.filename}}.ipa bin/${{matrix.filename}}.ipa + else + cd tmp_out + 7z a ${{matrix.filename}}.zip ./ + mv *.zip ../out/ + fi + + - name: Clean up keychain and provisioning profile + if: ${{ always() && startsWith(matrix.os,'macos')}} + run: | + security delete-keychain $RUNNER_TEMP/app-signing.keychain-db + rm ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision + - name: Upload Artifact + uses: actions/upload-artifact@v3 + with: + name: artifact + path: ./out + retention-days: 2 diff --git a/CHANGELOG.md b/CHANGELOG.md index cfbff204..8592e5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,52 @@ # Changelog -## Unreleased +## [0.12.0.dev] - 2023-12-01 + +### New Features and Improvements + +- Added soffchen to recommended geo assets + +### Bug Fixes + +- Refactored significant portions of the app +- Fixed incorrect profile parsing when missing headers +- Fixed geo assets bug where assets were deactivated +- Updated sing-box to version 1.7.0 +- Fixed Chinese typography bug (thanks to [betaxab](https://github.com/betaxab)) +- Fixed localization mistakes in Russian. [PR#189](https://github.com/hiddify/hiddify-next/pull/189) by [jomertix](https://github.com/jomertix) + +## [0.11.1] - 2023-11-19 + +### Bug Fixes + +- Fixed Android manifest bug. + +## [0.11.0] - 2023-11-19 ### New Features and Improvements - Changed Responsive UI Behavior - Now app is responsive on all platforms with appropriate routing setup. +- Added Simplified Service Modes + - Choose between VPN(Tun), System Proxy and Proxy only modes. (System Proxy available on desktop) +- Added Share Functionality + - Share configuration as json(export to clipboard) or share subscription link as QR code. - Redesigned System Tray on Desktop - - Options have been simplified and a new mode selector is added for easier access to TUN and Proxy modes. + - Options have been simplified and a new mode selector and navigation options are added. +- Added Privilege Checks for VPN(TUN) on Desktop - Added Auto Connect on Start - On desktop, app will try to connect to the last used profile on startup. (if last session was not explicitly disconnected by the user) - Added AppCast Update Checker - Checking for new versions of the app will use a more reliable approach on all platforms. +- Added Geo Asset Settings + - Update geo assets and use recommended providers - Added **winget** Release - Now you're able to install and update Hiddify Next on Windows using [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/). +- Added Turkish Translations. [PR#173](https://github.com/hiddify/hiddify-next/pull/173) by [Hasan Karlı](https://github.com/hasankarli) - Changed in-app Toasts - Updated Core Sing-box Version to 1.7.0 +- Improved Network Reliability While Adding/Updating Subscriptions +- Improved QR Code Scanner ### Bug Fixes @@ -25,7 +56,12 @@ - Fixed translator script. [PR#108](https://github.com/hiddify/hiddify-next/pull/108) by [Hirad Rasoolinejad](https://github.com/Hiiirad) - Fixed localization mistakes in Chinese. [PR#113](https://github.com/hiddify/hiddify-next/pull/113) and [PR#123](https://github.com/hiddify/hiddify-next/pull/123) by [Nyar233](https://github.com/Nyar233) - Fixed localization mistakes in Chinese Readme. [PR#137](https://github.com/hiddify/hiddify-next/pull/137) by [wldjdjsks](https://github.com/huajizhige) -- Fixed localization mistakes in Chinese. [PR#138](https://github.com/hiddify/hiddify-next/pull/138) by [wldjdjsks](https://github.com/huajizhige) +- Fixed localization mistakes in Chinese. [PR#138](https://github.com/hiddify/hiddify-next/pull/138) and [PR#165](https://github.com/hiddify/hiddify-next/pull/165) by [wldjdjsks](https://github.com/huajizhige) +- Fixed localization mistakes in Russian. [PR#155](https://github.com/hiddify/hiddify-next/pull/155), [PR#162](https://github.com/hiddify/hiddify-next/pull/162) and [PR#169](https://github.com/hiddify/hiddify-next/pull/169) by [solokot](https://github.com/solokot) +- Fixed linux build libs command. [PR#161](https://github.com/hiddify/hiddify-next/pull/161) by [Aloxaf](https://github.com/Aloxaf) +- Fixed localization mistakes in Russian. [PR#164](https://github.com/hiddify/hiddify-next/pull/164) and [PR#168](https://github.com/hiddify/hiddify-next/pull/168) by [jomertix](https://github.com/jomertix) +- Fixed localization mistakes in Chinese. [PR#179](https://github.com/hiddify/hiddify-next/pull/179) by [betaxab](https://github.com/betaxab) +- Fixed localization mistakes in Chinese Readme. [PR#172](https://github.com/hiddify/hiddify-next/pull/172) by [Locas](https://github.com/Locas56227) ## [0.10.0] - 2023-10-27 @@ -52,4 +88,7 @@ - Fixed localization mistakes in Russian. [PR#95](https://github.com/hiddify/hiddify-next/pull/95) by [solokot](https://github.com/solokot) - Fixed localization mistakes in Russian. [PR#74](https://github.com/hiddify/hiddify-next/pull/74) by [Elshad Guseynov](https://github.com/lifeindarkside) -[0.10.0]: https://github.com/hiddify/hiddify-next/releases/tag/0.10.0 +[0.12.0.dev]: https://github.com/hiddify/hiddify-next/releases/tag/v0.12.0.dev +[0.11.1]: https://github.com/hiddify/hiddify-next/releases/tag/v0.11.1 +[0.11.0]: https://github.com/hiddify/hiddify-next/releases/tag/v0.11.0 +[0.10.0]: https://github.com/hiddify/hiddify-next/releases/tag/v0.10.0 diff --git a/Makefile b/Makefile index 9dbd4bbf..333c0284 100644 --- a/Makefile +++ b/Makefile @@ -114,7 +114,7 @@ build-windows-libs: make -C libcore -f Makefile windows-amd64 && mv $(BINDIR)/$(CORE_NAME)-windows-amd64.dll $(DESKTOP_OUT)/libcore.dll build-linux-libs: - make -C libcore -f Makefile linux-amd64 && mv $(BINDIR)/$(CORE_NAME)-linux-amd64.dll $(DESKTOP_OUT)/libcore.so + make -C libcore -f Makefile linux-amd64 && mv $(BINDIR)/$(CORE_NAME)-linux-amd64.so $(DESKTOP_OUT)/libcore.so build-macos-libs: make -C libcore -f Makefile macos-universal && mv $(BINDIR)/$(CORE_NAME)-macos-universal.dylib $(DESKTOP_OUT)/libcore.dylib @@ -133,12 +133,10 @@ release: # Create a new tag for release. VERSION_STR="$${VERSION_ARRAY[0]}.$${VERSION_ARRAY[1]}.$${VERSION_ARRAY[2]}" && \ BUILD_NUMBER=$$(( $${VERSION_ARRAY[0]} * 10000 + $${VERSION_ARRAY[1]} * 100 + $${VERSION_ARRAY[2]} )) && \ echo "version: $${VERSION_STR}+$${BUILD_NUMBER}" && \ - sed -i "s/version: .*/version: $${VERSION_STR}\+$${BUILD_NUMBER}/g" pubspec.yaml && \ + sed -i "s/^version: .*/version: $${VERSION_STR}\+$${BUILD_NUMBER}/g" pubspec.yaml && \ git tag $${TAG} > /dev/null && \ git tag -d $${TAG} > /dev/null && \ git add pubspec.yaml CHANGELOG.md && \ - make sync_translate && \ - git add assets/translations/* && \ git commit -m "release: version $${TAG}" && \ echo "creating git tag : v$${TAG}" && \ git tag v$${TAG} && \ diff --git a/README.md b/README.md index fa5b6f06..476871d9 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,14 @@

+
+ + -[![GP-Intalls](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dapp.hiddify.com%26l%3DGoogle%2520Play%26m%3D%24shortinstalls&style=flat-square)](https://play.google.com/store/apps/details?id=app.hiddify.com) [![Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/total?style=flat-square&logo=github)](https://github.com/hiddify/hiddify-next/releases/)[![Last Version](https://img.shields.io/github/release/hiddify/hiddify-next/all.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/)[![Last Release Date](https://img.shields.io/github/release-date/hiddify/hiddify-next.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/)[![commits](https://img.shields.io/github/commit-activity/m/hiddify/hiddify-next?style=flat-square)](https://github.com/hiddify/hiddify-next/) +[![GP-Intalls](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dapp.hiddify.com%26l%3DGoogle%2520Play%26m%3D%24shortinstalls&style=flat-square)](https://play.google.com/store/apps/details?id=app.hiddify.com) [![Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/total?style=flat-square&logo=github)](https://github.com/hiddify/hiddify-next/releases/)[![Last Version](https://img.shields.io/github/release/hiddify/hiddify-next/all.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/releases/)[![Last Release Date](https://img.shields.io/github/release-date/hiddify/hiddify-next.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/releases/)[![commits](https://img.shields.io/github/commit-activity/m/hiddify/hiddify-next?style=flat-square)](https://github.com/hiddify/hiddify-next/) [![Youtube](https://img.shields.io/youtube/channel/views/UCxrmeMvVryNfB4XL35lXQNg?label=Youtube&style=flat-square&logo=youtube)](https://www.youtube.com/@hiddify)[![Telegram Channel](https://img.shields.io/endpoint?label=Channel&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify&color=blue)](https://telegram.dog/hiddify)[![Telegram Group](https://img.shields.io/endpoint?color=neon&label=Support%20Group&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify_board)](https://telegram.dog/hiddify_board/5)
@@ -18,9 +23,9 @@ ## What is Hiddify-Next? -

A multi-platform client based on Sing-box that serves as a universal proxy tool-chain. This app offers a wide range of capabilities, which are listed below. It also supports a large number of protocols. The app is free to use, ad-free, and open-source. It provides a secure and private tool for getting access to the free internet.

+

A multi-platform auto-client based on Sing-box that serves as a universal proxy tool-chain. This app offers a wide range of capabilities, which are listed below. It also supports a large number of protocols. The app is free to use, ad-free, and open-source. It provides a secure and private tool for getting access to the free internet.

-The app is developed using [Flutter](https://flutter.dev) and [Go](https://go.dev). For more information you can read through our [Contribution Guidelines](https://github.com/hiddify/hiddify-next/blob/main/CONTRIBUTING.md) for development. +The app is developed using [Flutter](https://flutter.dev) and [Go](https://go.dev). For more information about development, you can read through our [Contribution Guidelines](CONTRIBUTING.md) .
English Demo diff --git a/README_cn.md b/README_cn.md index 9af45c8c..f92eff6c 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,55 +1,61 @@ -
- -[**![Lang_farsi](https://user-images.githubusercontent.com/125398461/234186932-52f1fa82-52c6-417f-8b37-08fe9250a55f.png) فارسی**](README_fa.md)          [**Русский 🇷🇺**](README_ru.md)          [**English 🇺🇸**](README.md)           +
+ +[**![Lang_farsi](https://user-images.githubusercontent.com/125398461/234186932-52f1fa82-52c6-417f-8b37-08fe9250a55f.png) فارسی**](README_fa.md)          [**Русский 🇷🇺**](README_ru.md)          [**English 🇺🇸**](README.md) +

- -[![GP-Intalls](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dapp.hiddify.com%26l%3DGoogle%2520Play%26m%3D%24shortinstalls&style=flat-square)](https://play.google.com/store/apps/details?id=app.hiddify.com) [![Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/total?style=flat-square&logo=github)](https://github.com/hiddify/hiddify-next/releases/)[![Last Version](https://img.shields.io/github/release/hiddify/hiddify-next/all.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/)[![Last Release Date](https://img.shields.io/github/release-date/hiddify/hiddify-next.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/)[![commits](https://img.shields.io/github/commit-activity/m/hiddify/hiddify-next?style=flat-square)](https://github.com/hiddify/hiddify-next/) + +[![GP-Intalls](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dapp.hiddify.com%26l%3DGoogle%2520Play%26m%3D%24shortinstalls&style=flat-square)](https://play.google.com/store/apps/details?id=app.hiddify.com) [![Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/total?style=flat-square&logo=github)](https://github.com/hiddify/hiddify-next/releases/)[![Last Version](https://img.shields.io/github/release/hiddify/hiddify-next/all.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/releases/)[![Last Release Date](https://img.shields.io/github/release-date/hiddify/hiddify-next.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/releases/)[![commits](https://img.shields.io/github/commit-activity/m/hiddify/hiddify-next?style=flat-square)](https://github.com/hiddify/hiddify-next/) [![Youtube](https://img.shields.io/youtube/channel/views/UCxrmeMvVryNfB4XL35lXQNg?label=Youtube&style=flat-square&logo=youtube)](https://www.youtube.com/@hiddify)[![Telegram Channel](https://img.shields.io/endpoint?label=Channel&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify&color=blue)](https://telegram.dog/hiddify)[![Telegram Group](https://img.shields.io/endpoint?color=neon&label=Support%20Group&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify_board)](https://telegram.dog/hiddify_board/5)
+ + ## Hiddify-Next 是什么? -基于 [Sing-box](https://github.com/SagerNet/sing-box) 的多平台客户端,用作通用代理工具链。 该应用程序提供了广泛的功能,如下所列。 它还支持大量协议。 该应用程序免费使用、无广告且开源。 它提供了一个安全且私密的工具来访问免费互联网。 -该应用程序是使用 [Flutter](https://flutter.dev/) 和 [Go](https://go.dev/) 开发的。 欲了解更多信息,您可以阅读我们的开发贡献指南。 +

一个基于 Sing-box 的跨平台自动客户端,用作通用代理工具链。该应用提供了广泛的功能,如下所列。它还支持大量协议。该应用免费使用、无广告且开源。它为访问自由互联网提供了一个安全且私密的工具。

+该应用是使用 [Flutter](https://flutter.dev/) 和 [Go](https://go.dev/) 开发的。 有关开发的更多信息,您可以阅读我们的[贡献指南](CONTRIBUTING.md)。
-Chinese Demo +English Demo +
-## 🚀 主要特点 -⭐ 简单的用户界面易于使用 -✈️ 多平台:Android、Windows、Linux 和 macOS(欢迎 iOS 的 PR) +## 🚀 主要功能 + +⭐ 简单易用的用户界面 + +✈️ 跨平台:Android、Windows、Linux 和 macOS(欢迎 iOS 的 PR) 🔍 基于延迟的自动选择 -🟡 广泛的协议支持:ECH、Sing-box、V2ray、Xray、Vless、Vmess、Trojan、Trojan with websocket、Reality、TUIC、Hysteria、Hysteria2、ShadowTLS、SSH、Clash、Clash meta +🟡 广泛的协议支持:**ECH, Sing-box, V2ray, Xray, Vless, Vmess, Reality, TUIC, Hysteria, ShadowTLS, SSH, Clash, Clash meta** -🟡 支持多种订阅链接导入:Clash、Clash meta、Sing-box 和 Shadowsocks +🟡 支持多种订阅链接导入: **Clash, Clash meta, Sing-box and Shadowsocks** -🔄 自动订阅更新 +🔄 自动更新订阅 -🔎 显示个人资料信息,包括剩余天数和流量使用情况 +🔎 显示包含了剩余天数和流量使用情况的配置文件信息 💻 完全免费,没有任何广告和干扰 -🛡 开源、安全且社区驱动 +🛡 开源、安全且由社区驱动 🌙 深色和浅色模式 -⚙ 与所有代理管理面板的节点兼容 +⚙ 兼容所有的代理管理面板 -⭐ 适用于伊朗、中国、俄罗斯等国家配置 +⭐ 适用于伊朗、中国、俄罗斯或其他国家的配置 -📱 可在 Google Play 上获取 +📱 可在 [Google Play](https://play.google.com/store/apps/details?id=app.hiddify.com) 上获取 ## 下载 @@ -58,7 +64,7 @@ 操作系统 - 下载 + 下载链接 @@ -66,9 +72,9 @@ Android

-
-
- +
+
+ @@ -83,58 +89,58 @@ Linux - + + +
## 安装和教程 -请在 [wiki 页面](https://github.com/hiddify/hiddify-next/wiki) 上查找教程信息。 +请在 [wiki 页面](https://github.com/hiddify/hiddify-next/wiki) 上获取教程信息。 ## 改进翻译 -您可以使用以下链接轻松地为该项目做出贡献以改进翻译: -- [简体中文](https://inlang.com/editor/github.com/hiddify/hiddify-next?lang=en&lang=zh) -- [英语](https://inlang.com/editor/github.com/hiddify/hiddify-next?lang=en) +您可以使用以下链接轻松地为该项目改进翻译以做出贡献: + - [英语](https://inlang.com/editor/github.com/hiddify/hiddify-next?lang=en) - [波斯语](https://inlang.com/editor/github.com/hiddify/hiddify-next?lang=en&lang=fa) - [俄语](https://inlang.com/editor/github.com/hiddify/hiddify-next?lang=en&lang=ru) +- [简体中文](https://inlang.com/editor/github.com/hiddify/hiddify-next?lang=en&lang=zh) ## 致谢 + - [Sing-box](https://github.com/SagerNet/sing-box) - [Sing-box for Android](https://github.com/SagerNet/sing-box-for-android) - [Clash](https://github.com/Dreamacro/clash) - [Clash Meta](https://github.com/MetaCubeX/Clash.Meta) - [FClash](https://github.com/Fclash/Fclash) -- [其他](./pubspec.yaml) -## 捐赠与支持 +- [Others](./pubspec.yaml) -支持我们的最简单方法是单击此页面顶部的 Star (⭐)。 +## 捐赠和支持 + +支持我们的最简单方法是单击此页面顶部的Star (⭐) 。
- + Star History Chart
-我们的服务也需要资金支持。我们所有的活动都是自愿进行的,资金支持将用于项目的开发和维护。您可以在 [此处](https://github.com/hiddify/hiddify-manager/wiki/support) 查看我们的支持地址。 +我们的服务也需要经济支持。我们所有的活动都是志愿性质的,经济支持将被用于项目的发展。您可以在 [这里](https://github.com/hiddify/hiddify-server/wiki/support) 查看我们的支持地址。 +## 合作与联系信息 +我们需要您的合作来推动这个项目的发展。如果您在这些领域是专家,请不要犹豫联系我们并提及您的技能。 + +- Flutter 开发 +- Swift 开发 +- Kotlin 开发 +- Go 开发 +

-## 协作和联系信息 -我们需要您的协作才能继续开发并维护此项目。如果您是这些领域的专家,请随时与我们联系 并提及你的技能。 - -* Flutter 开发 -* Swift 开发 -* Kotlin 开发 -* Go 开发 - - - -
- [![Email](https://img.shields.io/badge/Email-contribute@hiddify.com-005FF9?style=flat-square&logo=mail.ru)](mailto:contribute@hiddify.com) [![Telegram Channel](https://img.shields.io/endpoint?label=Channel&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify&color=blue)](https://telegram.dog/hiddify) [![Telegram Group](https://img.shields.io/endpoint?color=neon&label=Support%20Group&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify_board)](https://telegram.dog/hiddify_board) @@ -144,9 +150,10 @@

- 感谢所有参与该项目的人。包括以下列出的人,和更多其他来自 Github 的人。你们对我们的意义非常重大。 ♥

+我们非常感谢所有参与此项目的人,包括在这里的一些人和在Github之外的。这对我们来说意义重大。♥

- + +

@@ -156,4 +163,3 @@ 使用 Contrib.Rocks 制作

- diff --git a/README_fa.md b/README_fa.md index 060fd675..d8d23408 100644 --- a/README_fa.md +++ b/README_fa.md @@ -7,18 +7,17 @@

- - -[![GP-Intalls](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dapp.hiddify.com%26l%3DGoogle%2520Play%26m%3D%24shortinstalls&style=flat-square)](https://play.google.com/store/apps/details?id=app.hiddify.com) [![Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/total?style=flat-square&logo=github)](https://github.com/hiddify/hiddify-next/releases/)[![Last Version](https://img.shields.io/github/release/hiddify/hiddify-next/all.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/)[![Last Release Date](https://img.shields.io/github/release-date/hiddify/hiddify-next.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/)[![commits](https://img.shields.io/github/commit-activity/m/hiddify/hiddify-next?style=flat-square)](https://github.com/hiddify/hiddify-next/) -[![Youtube](https://img.shields.io/youtube/channel/views/UCxrmeMvVryNfB4XL35lXQNg?label=Youtube&style=flat-square&logo=youtube)](https://www.youtube.com/@hiddify)[![Telegram Channel](https://img.shields.io/endpoint?label=Channel&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify&color=blue)](https://telegram.dog/hiddify)[![Telegram Group](https://img.shields.io/endpoint?color=neon&label=Support%20Group&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify_board)](https://telegram.dog/hiddify_board) + +[![GP-Intalls](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dapp.hiddify.com%26l%3DGoogle%2520Play%26m%3D%24shortinstalls&style=flat-square)](https://play.google.com/store/apps/details?id=app.hiddify.com) [![Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/total?style=flat-square&logo=github)](https://github.com/hiddify/hiddify-next/releases/)[![Last Version](https://img.shields.io/github/release/hiddify/hiddify-next/all.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/releases/)[![Last Release Date](https://img.shields.io/github/release-date/hiddify/hiddify-next.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/releases/)[![commits](https://img.shields.io/github/commit-activity/m/hiddify/hiddify-next?style=flat-square)](https://github.com/hiddify/hiddify-next/) +[![Youtube](https://img.shields.io/youtube/channel/views/UCxrmeMvVryNfB4XL35lXQNg?label=Youtube&style=flat-square&logo=youtube)](https://www.youtube.com/@hiddify)[![Telegram Channel](https://img.shields.io/endpoint?label=Channel&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify&color=blue)](https://telegram.dog/hiddify)[![Telegram Group](https://img.shields.io/endpoint?color=neon&label=Support%20Group&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify_board)](https://telegram.dog/hiddify_board/5)
## هیدیفای‌نکست چیست؟ -یک کلاینت مالتی‌پلتفرم مبتنی بر [سینگ‌باکس](https://github.com/SagerNet/sing-box) که به عنوان یک ابزار عمومی برای پروکسی عمل می‌کند. این برنامه طیف گسترده‌ای از قابلیت‌ها را ارائه می‌دهد که در زیر لیست شده است. همچنین از تعداد زیادی پروتکل پشتیبانی می‌کند. این برنامه رایگان، بدون آگهی و منبع باز است. این یک ابزار امن و مطمئن برای دسترسی به اینترنت رایگان فراهم می‌کند. +یک کلاینت خودکار مالتی‌پلتفرم مبتنی بر [سینگ‌باکس](https://github.com/SagerNet/sing-box) که به عنوان یک ابزار عمومی برای پروکسی عمل می‌کند. این برنامه طیف گسترده‌ای از قابلیت‌ها را ارائه می‌دهد که در زیر لیست شده است. همچنین از تعداد زیادی پروتکل پشتیبانی می‌کند. این برنامه رایگان، بدون آگهی و منبع باز است. این یک ابزار امن و مطمئن برای دسترسی به اینترنت رایگان فراهم می‌کند. -این برنامه با استفاده از [Flutter](https://flutter.dev/) و [Go](https://go.dev/) توسعه یافته است. برای اطلاعات بیشتر در خصوص توسعه می‌توانید [دستورالعمل‌های مشارکت](https://github.com/hiddify/hiddify-next/blob/main/CONTRIBUTING.md) در پروژه ما را مطالعه نمایید. +این برنامه با استفاده از [Flutter](https://flutter.dev/) و [Go](https://go.dev/) توسعه یافته است. برای اطلاعات بیشتر در خصوص توسعه می‌توانید [دستورالعمل‌های مشارکت](CONTRIBUTING.md) در پروژه ما را مطالعه نمایید. diff --git a/README_ru.md b/README_ru.md index c828d642..7a1bf5d3 100644 --- a/README_ru.md +++ b/README_ru.md @@ -9,15 +9,15 @@
-[![GP-Intalls](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dapp.hiddify.com%26l%3DGoogle%2520Play%26m%3D%24shortinstalls&style=flat-square)](https://play.google.com/store/apps/details?id=app.hiddify.com) [![Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/total?style=flat-square&logo=github)](https://github.com/hiddify/hiddify-next/releases/)[![Last Version](https://img.shields.io/github/release/hiddify/hiddify-next/all.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/)[![Last Release Date](https://img.shields.io/github/release-date/hiddify/hiddify-next.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/)[![commits](https://img.shields.io/github/commit-activity/m/hiddify/hiddify-next?style=flat-square)](https://github.com/hiddify/hiddify-next/) +[![GP-Intalls](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dapp.hiddify.com%26l%3DGoogle%2520Play%26m%3D%24shortinstalls&style=flat-square)](https://play.google.com/store/apps/details?id=app.hiddify.com) [![Downloads](https://img.shields.io/github/downloads/hiddify/hiddify-next/total?style=flat-square&logo=github)](https://github.com/hiddify/hiddify-next/releases/)[![Last Version](https://img.shields.io/github/release/hiddify/hiddify-next/all.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/releases/)[![Last Release Date](https://img.shields.io/github/release-date/hiddify/hiddify-next.svg?style=flat-square)](https://github.com/hiddify/hiddify-next/releases/)[![commits](https://img.shields.io/github/commit-activity/m/hiddify/hiddify-next?style=flat-square)](https://github.com/hiddify/hiddify-next/) [![Youtube](https://img.shields.io/youtube/channel/views/UCxrmeMvVryNfB4XL35lXQNg?label=Youtube&style=flat-square&logo=youtube)](https://www.youtube.com/@hiddify)[![Telegram Channel](https://img.shields.io/endpoint?label=Channel&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify&color=blue)](https://telegram.dog/hiddify)[![Telegram Group](https://img.shields.io/endpoint?color=neon&label=Support%20Group&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fhiddify_board)](https://telegram.dog/hiddify_board/5)
## Что такое Hiddify-Next? -Многоплатформенный клиент на основе [Sing-box](https://github.com/SagerNet/sing-box), который служит универсальным набором инструментов прокси. Это приложение предлагает широкий спектр возможностей, которые перечислены ниже. Он также поддерживает большое количество протоколов. Приложение бесплатное, без рекламы и с открытым исходным кодом. Он предоставляет безопасный и конфиденциальный инструмент для получения доступа к бесплатному Интернету. +Многоплатформенный авто-клиент на основе [Sing-box](https://github.com/SagerNet/sing-box), который служит универсальным набором инструментов прокси. Это приложение предлагает широкий спектр возможностей, которые перечислены ниже. Он также поддерживает большое количество протоколов. Приложение бесплатное, без рекламы и с открытым исходным кодом. Он предоставляет безопасный и конфиденциальный инструмент для получения доступа к бесплатному Интернету. -Приложение разработано с использованием [Flutter](https://flutter.dev/) и [Go](https://go.dev/). Для получения дополнительной информации вы можете прочитать наши Рекомендации по участию в разработке. +Приложение разработано с использованием [Flutter](https://flutter.dev/) и [Go](https://go.dev/). Для получения дополнительной информации о разработке вы можете прочитать наши [Рекомендации по участию](CONTRIBUTING.md) .
English Demo diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ced8b599..18fd9dd8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ - + + @@ -115,4 +116,4 @@ android:name="flutterEmbedding" android:value="2" /> - + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/EventHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/EventHandler.kt index cf5717ad..95a58b42 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/EventHandler.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/EventHandler.kt @@ -6,6 +6,7 @@ import com.hiddify.hiddify.constant.Alert import com.hiddify.hiddify.constant.Status import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.JSONMethodCodec class EventHandler : FlutterPlugin { @@ -22,8 +23,8 @@ class EventHandler : FlutterPlugin { private var alertsObserver: Observer? = null override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - statusChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_STATUS) - alertsChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_ALERTS) + statusChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_STATUS, JSONMethodCodec.INSTANCE) + alertsChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_ALERTS, JSONMethodCodec.INSTANCE) statusChannel!!.setStreamHandler(object : EventChannel.StreamHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt index d40ae0ba..f2656c25 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt @@ -2,6 +2,7 @@ package com.hiddify.hiddify import android.util.Log import com.hiddify.hiddify.bg.BoxService +import com.hiddify.hiddify.constant.Alert import com.hiddify.hiddify.constant.Status import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall @@ -24,6 +25,7 @@ class MethodHandler(private val scope: CoroutineScope) : FlutterPlugin, enum class Trigger(val method: String) { ParseConfig("parse_config"), ChangeConfigOptions("change_config_options"), + GenerateConfig("generate_config"), Start("start"), Stop("stop"), Restart("restart"), @@ -70,6 +72,21 @@ class MethodHandler(private val scope: CoroutineScope) : FlutterPlugin, } } + Trigger.GenerateConfig.method -> { + scope.launch { + result.runCatching { + val args = call.arguments as Map<*, *> + val path = args["path"] as String + val options = Settings.configOptions + if (options.isBlank() || path.isBlank()) { + error("blank properties") + } + val config = BoxService.buildConfig(path, options) + success(config) + } + } + } + Trigger.Start.method -> { scope.launch { result.runCatching { diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt index 41fa05e1..6268b739 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt @@ -72,6 +72,10 @@ class BoxService( } } + fun buildConfig(path: String, options: String):String { + return Mobile.buildConfig(path, options) + } + fun start() { val intent = runBlocking { withContext(Dispatchers.IO) { diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/PlatformInterfaceWrapper.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/PlatformInterfaceWrapper.kt index 9345bef7..7ce41726 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/PlatformInterfaceWrapper.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/PlatformInterfaceWrapper.kt @@ -10,6 +10,7 @@ import io.nekohasekai.libbox.NetworkInterfaceIterator import io.nekohasekai.libbox.PlatformInterface import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.TunOptions +import io.nekohasekai.libbox.WIFIState import java.net.Inet6Address import java.net.InetSocketAddress import java.net.InterfaceAddress @@ -101,6 +102,10 @@ interface PlatformInterfaceWrapper : PlatformInterface { override fun clearDNSCache() { } + override fun readWIFIState(): WIFIState? { + return null + } + private class InterfaceArray(private val iterator: Enumeration) : NetworkInterfaceIterator { diff --git a/appcast.xml b/appcast.xml index 8ccbcb8d..ad44f335 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,31 +3,32 @@ Release - Version 0.10.0 - Sat, 28 Oct 2023 12:00:00 +0000 - + Version 0.11.1 + Sun, 20 Nov 2023 22:00:00 +0000 + - Version 0.10.0 - Sat, 28 Oct 2023 12:00:00 +0000 + Version 0.11.1 + Sun, 20 Nov 2023 22:00:00 +0000 + url="https://github.com/hiddify/hiddify-next/releases/download/v0.11.1/hiddify-windows-x64-setup.zip" + sparkle:version="0.11.1" sparkle:os="windows" /> - Version 0.10.0 - Sat, 28 Oct 2023 12:00:00 +0000 + Version 0.11.1 + Sun, 20 Nov 2023 22:00:00 +0000 + url="https://github.com/hiddify/hiddify-next/releases/download/v0.11.1/hiddify-macos-universal.zip" + sparkle:version="0.11.1" sparkle:os="macos" /> - Version 0.10.0 - Sat, 28 Oct 2023 12:00:00 +0000 + Version 0.11.1 + Sun, 20 Nov 2023 22:00:00 +0000 + url="https://github.com/hiddify/hiddify-next/releases/download/v0.11.1/hiddify-linux-x64.zip" + sparkle:version="0.11.1" sparkle:os="linux" /> \ No newline at end of file diff --git a/assets/translations/strings_en.i18n.json b/assets/translations/strings_en.i18n.json index ad62901c..6bb29c05 100644 --- a/assets/translations/strings_en.i18n.json +++ b/assets/translations/strings_en.i18n.json @@ -57,6 +57,12 @@ "shortBtnTxt": "New Profile", "fromClipboard": "Add From Clipboard", "scanQr": "Scan QR code", + "qrScanner": { + "permissionDeniedError": "Permission denied", + "unexpectedError": "Something went wrong", + "torchSemanticLabel": "Flash light", + "facingSemanticLabel": "Camera facing" + }, "manually": "Manual Entry", "addingProfileMsg": "Adding Profile", "failureMsg": "Failed to add profile" @@ -67,6 +73,14 @@ "failureMsg": "Failed to update profile", "successMsg": "Profile updated successfully" }, + "share": { + "buttonText": "Share", + "exportToClipboardSuccess": "Exported to clipboard", + "exportSubLinkToClipboard": "Export subscription link to clipboard", + "subLinkQrCode": "Subscription link QR code", + "exportConfigToClipboard": "Export configuration to clipboard", + "exportConfigToClipboardSuccess": "Configuration copied to clipboard" + }, "edit": { "buttonTxt": "Edit", "selectActiveTxt": "Select active profile" @@ -203,6 +217,16 @@ "enableFakeDns": "Enable Fake DNS", "bypassLan": "Bypass Lan", "strictRoute": "Strict Route" + }, + "geoAssets": { + "pageTitle": "Routing Assets", + "version": "Version ${version}", + "fileMissing": "File Missing", + "update": "Update", + "download": "Download", + "failureMsg": "Failed to update asset", + "successMsg": "Successfully updated asset", + "addRecommended": "Add Recommended Assets" } }, "about": { @@ -227,6 +251,7 @@ "tray": { "dashboard": "Dashboard", "quit": "Quit", + "open": "Open", "status": { "connect": "Connect", "connecting": "Connecting", @@ -245,6 +270,8 @@ "serviceNotRunning": "Service is not running", "missingPrivilege": "Missing Privilege", "missingPrivilegeMsg": "VPN mode requires administrator privileges. Either relaunch the app as administrator or change service mode.", + "missingGeoAssets": "Missing Geo Assets", + "missingGeoAssetsMsg": "Geo assets are missing. consider changing active asset or download selected one in the settings.", "invalidConfigOptions": "Invalid configuration options", "invalidConfig": "Invalid Configuration", "create": "Service creation error", @@ -268,6 +295,11 @@ "badResponse": "Bad response", "connectionError": "Connection error", "badCertificate": "Bad certificate" + }, + "geoAssets": { + "unexpected": "Unexpected Error", + "notUpdate": "No Update Available", + "activeNotFound": "Active Geo Asset Not Found" } }, "play": { diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 6e4f06de..24a7dfce 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -57,6 +57,12 @@ "shortBtnTxt": "افزودن", "fromClipboard": "افزودن از کلیپ‌بورد", "scanQr": "اسکن QR کد", + "qrScanner": { + "permissionDeniedError": "اجازه رد شد", + "unexpectedError": "خطایی رخ داده", + "torchSemanticLabel": "چراغ فلاش", + "facingSemanticLabel": "جهت دوربین" + }, "manually": "افزودن دستی", "addingProfileMsg": "در حال افزودن پروفایل", "failureMsg": "در افزودن پروفایل خطایی رخ داد" @@ -67,6 +73,14 @@ "failureMsg": "در بروزرسانی پروفایل خطایی رخ داد", "successMsg": "پروفایل با موفقیت بروزرسانی شد" }, + "share": { + "buttonText": "اشتراک گذاری", + "exportToClipboardSuccess": "به کلیپ بورد اضافه شد", + "exportSubLinkToClipboard": "افزودن لینک اشتراک به کلیپ بورد", + "subLinkQrCode": "کد QR لینک اشتراک", + "exportConfigToClipboard": "افزودن پیکربندی به کلیپ بورد", + "exportConfigToClipboardSuccess": "پیکربندی در کلیپ بورد کپی شد" + }, "edit": { "buttonTxt": "ویرایش", "selectActiveTxt": "انتخاب پروفایل فعال" @@ -203,6 +217,16 @@ "enableFakeDns": "Enable Fake DNS", "bypassLan": "Bypass Lan", "strictRoute": "Strict Route" + }, + "geoAssets": { + "pageTitle": "فایل‌های مسیریابی", + "version": "نسخه ${version}", + "fileMissing": "فایل موجود نیست", + "update": "به روز رسانی", + "download": "دانلود", + "failureMsg": "دارایی به روز نشد", + "successMsg": "دارایی با موفقیت به روز شد", + "addRecommended": "اضافه کردن دارایی های توصیه شده" } }, "about": { @@ -227,6 +251,7 @@ "tray": { "dashboard": "داشبورد", "quit": "خروج", + "open": "باز کن", "status": { "connect": "اتصال", "connecting": "در حال اتصال", @@ -248,7 +273,9 @@ "invalidConfigOptions": "تنظیمات کانفیگ نامعتبر", "invalidConfig": "کانفیگ غیر معتبر", "create": "در ایجاد سرویس خطایی رخ داده", - "start": "در راه‌اندازی سرویس خطایی رخ داده" + "start": "در راه‌اندازی سرویس خطایی رخ داده", + "missingGeoAssets": "دارایی های جغرافیایی از دست رفته", + "missingGeoAssetsMsg": "دارایی های جغرافیایی گم شده اند. تغییر دارایی فعال را در نظر بگیرید یا یکی را در تنظیمات دانلود کنید." }, "connectivity": { "unexpected": "خطای غیرمنتظره", @@ -268,6 +295,11 @@ "badResponse": "پاسخ نامعتبر", "connectionError": "خطای اتصال", "badCertificate": "خطای اعتبار سنجی" + }, + "geoAssets": { + "unexpected": "خطای غیرمنتظره", + "notUpdate": "به روز رسانی موجود نیست", + "activeNotFound": "Active Geo Asset یافت نشد" } }, "play": { @@ -275,4 +307,4 @@ "short_description": "Auto, SSH, VLESS, Vmess, Trojan, Reality, Sing-Box, Clash, Xray, Shadowsocks", "full_description": "هدف اصلی HiddifyNext ارائه یک کلاینت تونل زنی ایمن، کاربرپسند و کارآمد است. این به شما امکان می دهد تا با استفاده از مجوز VPN-Service، تمام ترافیک یا ترافیک برنامه انتخابی را به یک سرور راه دور مورد نظر خود هدایت کنید.\n\nتوجه: ما هیچ سروری ارائه نمی دهیم. کاربران موظفند با استفاده از سرورهای خود میزبان یا سرورهای مورد اعتماد، فعالیت‌های آنلاین خود را خصوصی نگه دارند.\n \nما از سرورهایی با موارد زیر پشتیبانی می کنیم:\n- لینک اشتراک V2ray/Xray معمولی\n- لینک اشتراک کلش\n- لینک اشتراک Sing-Box\n\nویژگی های منحصر به فرد ما چیست؟\n - کاربر پسند\n - بهینه و سریع\n - به طور خودکار LowestPing را انتخاب کنید\n - نمایش اطلاعات استفاده کاربر\n - به راحتی لینک فرعی را با یک کلیک با استفاده از دیپ لینک وارد کنید\n - رایگان و بدون تبلیغات\n - به راحتی پیوندهای فرعی کاربر را تغییر دهید\n - بیشتر و بیشتر\n\nحمایت کردن:\n- تمام پروتکل های پشتیبانی شده توسط Sing-Box\n- VLESS + xtls \n- VMESS\n- تروجان\n- ShoadowSocks\n- ریالیتی\n- V2ray\n- هیستریا 2\n- TUIC\n- SSH\n- ShadowTLS\n\n\nکد منبع در https://github.com/hiddify/Hiddify-Next وجود دارد\nهسته برنامه بر اساس sing-box منبع باز است.\n\nتوضیحات مجوز:\n- سرویس VPN: از آنجا که هدف این برنامه ارائه یک کلاینت تونل زنی ایمن، کاربر پسند و کارآمد است، ما به این مجوز نیاز داریم تا بتوانیم ترافیک را از طریق تونل به سرور راه دور هدایت کنیم.\n- QUERY ALL PACKAGES: این مجوز برای اجازه دادن به کاربران برای گنجاندن یا حذف برنامه های کاربردی خاص برای تونل زدن استفاده می شود.\n- دریافت بوت تکمیل شد: این مجوز را می توان از تنظیمات برنامه فعال یا غیرفعال کرد تا این برنامه پس از بوت شدن دستگاه فعال شود.\n- اعلان های ارسالی: این مجوز ضروری است زیرا ما از یک سرویس پیش زمینه برای اطمینان از عملکرد مداوم سرویس VPN استفاده می کنیم.\n- این برنامه بدون تبلیغات است. تجزیه و تحلیل و داده های اشکال فقط با رضایت صریح کاربر در اولین استفاده از برنامه اتفاق می افتد." } -} \ No newline at end of file +} diff --git a/assets/translations/strings_ru.i18n.json b/assets/translations/strings_ru.i18n.json index b7d6a63a..e8223be9 100644 --- a/assets/translations/strings_ru.i18n.json +++ b/assets/translations/strings_ru.i18n.json @@ -14,7 +14,7 @@ "addToClipboard": "Копировать в буфер обмена" }, "intro": { - "termsAndPolicyCaution(rich)": "Продолжая, вы соглашаетесь с ${tap(@:about.termsAndConditions)}", + "termsAndPolicyCaution(rich)": "Продолжая, Вы соглашаетесь с ${tap(@:about.termsAndConditions)}", "start": "Начать" }, "home": { @@ -28,10 +28,10 @@ "connected": "Подключено" }, "stats": { - "traffic": "Скорость", + "traffic": "Текущий трафик", "trafficTotal": "Трафик", - "uplink": "Исходящий канал", - "downlink": "Входящий канал" + "uplink": "Скорость отправки", + "downlink": "Скорость загрузки" } }, "profile": { @@ -44,29 +44,43 @@ "traffic": "Трафик", "updatedTimeAgo": "Обновлено ${timeago}", "remainingDuration": "Ещё ${duration} дн.", - "remainingTrafficSemanticLabel": "${consumed} из ${total} использованного трафика.", + "remainingTrafficSemanticLabel": "Использовано ${consumed} трафика из ${total}.", "expired": "Истекло", "noTraffic": "Нет доступного трафика" }, "sortBy": { - "lastUpdate": "Последнее обновление", - "name": "По алфавиту" + "lastUpdate": "по последнему обновлению", + "name": "по названию" }, "add": { "buttonText": "Новый профиль", "shortBtnTxt": "Новый профиль", "fromClipboard": "Добавить из буфера обмена", "scanQr": "Сканировать QR-код", - "manually": "Ручной ввод", + "qrScanner": { + "permissionDeniedError": "Нет прав", + "unexpectedError": "Неизвестная ошибка", + "torchSemanticLabel": "Вспышка", + "facingSemanticLabel": "Фронтальная камера" + }, + "manually": "Ввести вручную", "addingProfileMsg": "Добавление профиля", - "failureMsg": "Невозможно добавить профиль" + "failureMsg": "Не удалось добавить профиль" }, "update": { "buttonTxt": "Обновить", "tooltip": "Обновить профиль", - "failureMsg": "Ошибка обновления", + "failureMsg": "Не удалось обновить профиль", "successMsg": "Профиль успешно обновлён" }, + "share": { + "buttonText": "Поделиться", + "exportToClipboardSuccess": "Ссылка скопирована в буфер обмена", + "exportSubLinkToClipboard": "Скопировать ссылку на подписку в буфер обмена", + "subLinkQrCode": "QR-код ссылки на подписку", + "exportConfigToClipboard": "Скопировать конфигурацию в буфер обмена", + "exportConfigToClipboardSuccess": "Конфигурация скопирована в буфер обмена" + }, "edit": { "buttonTxt": "Изменить", "selectActiveTxt": "Выберите активный профиль" @@ -79,7 +93,7 @@ "save": { "buttonText": "Сохранить", "successMsg": "Профиль успешно сохранён", - "failureMsg": "Невозможно сохранить профиль" + "failureMsg": "Не удалось сохранить профиль" }, "detailsForm": { "nameLabel": "Имя", @@ -116,7 +130,7 @@ }, "settings": { "pageTitle": "Настройки", - "requiresRestartMsg": "Для применения перезапустите приложение.", + "requiresRestartMsg": "Чтобы применить изменения, перезапустите приложение.", "general": { "sectionTitle": "Основные", "locale": "Язык", @@ -136,7 +150,7 @@ "black": "Чёрная тема" }, "enableAnalytics": "Сбор аналитики", - "enableAnalyticsMsg": "Сбор аналитических данных и отправка отчётов о сбоях для улучшения приложения.", + "enableAnalyticsMsg": "Сбор данных аналитики и отправка отчётов о сбоях для улучшения приложения", "autoStart": "Запуск при загрузке", "silentStart": "Тихий запуск", "openWorkingDir": "Открыть рабочую папку", @@ -146,7 +160,7 @@ "advanced": { "sectionTitle": "Расширенные", "debugMode": "Режим отладки", - "debugModeMsg": "Для применения перезапустите приложение.", + "debugModeMsg": "Чтобы применить изменения, перезапустите приложение.", "memoryLimit": "Ограничение памяти" }, "network": { @@ -164,7 +178,7 @@ "clearSelection": "Очистить выбор" }, "config": { - "serviceMode": "Режим сервиса", + "serviceMode": "Режим работы", "serviceModes": { "proxy": "Прокси", "systemProxy": "Системный прокси", @@ -184,11 +198,11 @@ "disable": "Отключено", "enable": "Включено", "prefer": "Предпочтительно", - "only": "Эксклюзивно" + "only": "Исключительно" }, - "remoteDnsAddress": "Удалённая DNS", + "remoteDnsAddress": "Удалённый DNS", "remoteDnsDomainStrategy": "Стратегия удалённого домена DNS", - "directDnsAddress": "Прямая DNS", + "directDnsAddress": "Прямой DNS", "directDnsDomainStrategy": "Стратегия прямого домена DNS", "mixedPort": "Смешанный порт", "localDnsPort": "Локальный порт DNS", @@ -203,6 +217,16 @@ "enableFakeDns": "Использовать поддельную DNS", "bypassLan": "Обход локальной сети", "strictRoute": "Строгая маршрутизация" + }, + "geoAssets": { + "pageTitle": "Активы маршрутизации", + "version": "Версия ${version}", + "fileMissing": "Файл отсутствует", + "update": "Обновить", + "download": "Скачать", + "failureMsg": "Не удалось обновить объект", + "successMsg": "Объект успешно обновлен", + "addRecommended": "Добавить рекомендуемые активы" } }, "about": { @@ -227,37 +251,40 @@ "tray": { "dashboard": "Панель", "quit": "Выход", + "open": "Открыть", "status": { - "connect": "Подключено", + "connect": "Подключиться", "connecting": "Подключение", - "disconnect": "Отключено", + "disconnect": "Отключиться", "disconnecting": "Отключение" } }, "failure": { - "unexpected": "Неожиданная ошибка", + "unexpected": "Непредвиденная ошибка", "clash": { - "unexpected": "Неожиданная ошибка (Clash)", - "core": "Ошибка ${reason}" + "unexpected": "Непредвиденная ошибка (Clash)", + "core": "Ошибка ${reason}" }, "singbox": { - "unexpected": "Неожиданная ошибка (SingBox)", + "unexpected": "Непредвиденная ошибка (SingBox)", "serviceNotRunning": "Сервис не запущен", + "missingPrivilege": "Отсутствие прав", + "missingPrivilegeMsg": "Режим VPN требует прав администратора. Перезапустите приложение от имени администратора или измените режим работы приложения.", + "missingGeoAssets": "Отсутствуют географические ресурсы", + "missingGeoAssetsMsg": "Георесурсы отсутствуют. Изменените выбранный ресурс или загрузите собственный в настройках.", "invalidConfigOptions": "Неправильные параметры конфигурации", "invalidConfig": "Неправильная конфигурация", "create": "Ошибка создания сервиса", - "start": "Ошибка запуска сервиса", - "missingPrivilege": "Отсутствующие привилегии", - "missingPrivilegeMsg": "Режим VPN требует прав администратора. Либо перезапустите приложение от имени администратора, либо измените сервисный режим." + "start": "Ошибка запуска сервиса" }, "connectivity": { - "unexpected": "Неожиданная ошибка", + "unexpected": "Непредвиденная ошибка", "missingVpnPermission": "Отсутствует разрешение VPN", - "missingNotificationPermission": "Отсутствует разрешение на уведомление", + "missingNotificationPermission": "Отсутствует разрешение на показ уведомлений", "core": "Ошибка ядра" }, "profiles": { - "unexpected": "Неожиданная ошибка", + "unexpected": "Непредвиденная ошибка", "notFound": "Профиль не найден", "invalidConfig": "Неправильная конфигурация", "invalidUrl": "Неправильный URL" @@ -268,11 +295,16 @@ "badResponse": "Неправильный ответ", "connectionError": "Ошибка подключения", "badCertificate": "Неправильный сертификат" + }, + "geoAssets": { + "unexpected": "Неожиданная ошибка", + "notUpdate": "Нет доступных обновлений", + "activeNotFound": "Активный географический актив не найден" } }, "play": { "title": "Hiddify Next (Preview)", "short_description": "Автовыбор, SSH, VLESS, Vmess, Trojan, Reality, Sing-Box, Clash, Xray, Shadowsocks", - "full_description": "Основная цель HiddifyNext — предоставить безопасный, удобный и эффективный клиент туннелирования. Он позволяет направлять весь трафик или трафик выбранного приложения на выбранный вами удалённый сервер, используя разрешение VPN–сервиса.\n\nПримечание: мы не предоставляем серверы, пользователи должны обеспечивать конфиденциальность своих действий в Интернете, используя собственный сервер или доверенные серверы.\n \nПоддерживаемые серверы:\n— Обычная ссылка на подписку V2ray/Xray\n— Ссылка на подписку Clash\n— Ссылка на подписку на Sing–Box\n\nВ чём уникальные особенности?\n — Удобство\n — Оптимизация и скорость\n — Автоматический выбор минимальной задержки\n — Отображение информации об использовании\n — Простой импорт ссылок одним щелчком мыши\n — Бесплатно и без рекламы\n — Простое переключение ссылок\n — …и много больше\n\nПоддержка:\n• Все протоколы, поддерживаемые Sing-Box\n• VLESS + xtls reality, vision\n• VMESS\n• Trojan\n• ShoadowSocks\n• Reality\n• V2ray\n• Hystria2\n• TUIC\n• SSH\n• ShadowTLS\n\n\nИсходный код доступен по адресу https://github.com/hiddify/Hiddify-Next.\nЯдро приложения основано на открытом исходном коде Sing–Box.\n\nОписание разрешений:\n— СЛУЖБА VPN: поскольку целью данного приложения является предоставление безопасного, удобного и эффективного клиента туннелирования, это разрешение необходимо, чтобы иметь возможность направлять трафик через туннель на удалённый сервер.\n— ЗАПРОС ВСЕХ ПАКЕТОВ: это разрешение позволяет включать или исключать определённые приложения для туннелирования.\n— ИНФОРМИРОВАНИЕ О ЗАВЕРШЕНИИ ЗАГРУЗКИ: это разрешение можно включить или отключить в настройках приложения, чтобы (де)активировать запуск приложения при загрузке устройства.\n— ПОСТОЯННОЕ УВЕДОМЛЕНИЕ: это разрешение необходимо, поскольку используется приоритетная служба для обеспечения непрерывной работы службы VPN.\n— Приложение не содержит рекламы. Сбор аналитики и данных о сбоях происходят только с явного согласия пользователя при первом использовании приложения." + "full_description": "Основная цель HiddifyNext — предоставить безопасный, удобный и эффективный клиент туннелирования. Он позволяет направлять весь трафик или трафик выбранного приложения на указанный Вами удалённый сервер.\nПримечание: мы не предоставляем серверы, пользователи должны сами обеспечивать конфиденциальность своих действий в Интернете, используя собственный сервер или доверенные серверы. Поддерживаются сервера с:— Обычной ссылка на подписку V2ray/Xray— Ссылкой на подписку Clash— Ссылко на подписку на Sing–Box\nВ чём уникальные особенности? — Удобство — Оптимизация и скорость — Автоматический выбор минимальной задержки — Отображение информации об использовании — Простой импорт ссылок одним щелчком мыши — Бесплатно и без рекламы — Простое переключение ссылок — …и много больше\nПоддерживаются:• Все протоколы, поддерживаемые Sing-Box• VLESS + xtls reality, vision• VMESS• Trojan• ShoadowSocks• Reality• V2ray• Hystria2• TUIC• SSH• ShadowTLS\nИсходный код доступен по адресу https://github.com/hiddify/Hiddify-Next.Ядро приложения основано на открытом исходном коде Sing–Box.\nОписание разрешений:— СЛУЖБА VPN: поскольку целью данного приложения является предоставление безопасного, удобного и эффективного клиента туннелирования, это разрешение необходимо, чтобы иметь возможность направлять трафик через туннель на удалённый сервер.— ЗАПРОС ВСЕХ ПАКЕТОВ: это разрешение позволяет добавлять или удалять определённые приложения из списка для туннелирования.— ИНФОРМИРОВАНИЕ О ЗАВЕРШЕНИИ ЗАГРУЗКИ: это разрешение можно включить или отключить в настройках приложения, чтобы (де)активировать запуск приложения при загрузке устройства.— ПОСТОЯННОЕ УВЕДОМЛЕНИЕ: это разрешение необходимо, так как используется приоритетная служба для обеспечения непрерывной работы VPN.— Приложение не содержит рекламы. Сбор аналитики и данных о сбоях происходят только с явного согласия пользователя при первом использовании приложения." } -} +} \ No newline at end of file diff --git a/assets/translations/strings_tr.i18n.json b/assets/translations/strings_tr.i18n.json new file mode 100644 index 00000000..f381d98e --- /dev/null +++ b/assets/translations/strings_tr.i18n.json @@ -0,0 +1,310 @@ +{ + "general": { + "appTitle": "Hiddify Next", + "reset": "Sıfırla", + "toggle": { + "enabled": "Etkin", + "disabled": "Devre dışı" + }, + "state": { + "disable": "Devre dışı bırak" + }, + "sort": "Sırala", + "sortBy": "Sırala", + "addToClipboard": "Panoya ekle" + }, + "intro": { + "termsAndPolicyCaution(rich)": "devam ederek ${tap(@:about.termsAndConditions)} kabul etmiş olursunuz", + "start": "Başla" + }, + "home": { + "pageTitle": "Ana Sayfa", + "emptyProfilesMsg": "Aboneliği profili ekleyerek başlayın", + "noActiveProfileMsg": "Profil seçin", + "connection": { + "tapToConnect": "Bağlanmak için dokunun", + "connecting": "Bağlanıyor", + "disconnecting": "Bağlantı kesiliyor", + "connected": "Bağlandı" + }, + "stats": { + "traffic": "Canlı Trafik", + "trafficTotal": "Toplam Trafik", + "uplink": "Çıkış Yolu", + "downlink": "Giriş Yolu" + } + }, + "profile": { + "overviewPageTitle": "Profiller", + "detailsPageTitle": "Profil", + "activeProfileNameSemanticLabel": "Aktif profil adı: \"${name}\".", + "activeProfileBtnSemanticLabel": "Tüm profilleri görüntüleyin.", + "nonActiveProfileBtnSemanticLabel": "Aktif profil olarak \"${name}\" seçeneğini seçin.", + "subscription": { + "traffic": "Trafik", + "updatedTimeAgo": "${timeago} güncellendi", + "remainingDuration": "${duration} Gün Kaldı", + "remainingTrafficSemanticLabel": "${consumed}/${total} trafik tüketildi.", + "expired": "Süresi Doldu", + "noTraffic": "Kotal Doldu" + }, + "sortBy": { + "lastUpdate": "Yakın zamanda güncellendi", + "name": "Alfabetik" + }, + "add": { + "buttonText": "Yeni profil", + "shortBtnTxt": "Yeni profil", + "fromClipboard": "Panodan Ekle", + "scanQr": "QR kodunu tarayın", + "qrScanner": { + "permissionDeniedError": "İzin reddedildi", + "unexpectedError": "Bir şeyler yanlış gitti", + "torchSemanticLabel": "El feneri", + "facingSemanticLabel": "Kameraya önü" + }, + "manually": "Manuel giriş", + "addingProfileMsg": "Profil Ekleniyor", + "failureMsg": "Profil eklenemedi" + }, + "update": { + "buttonTxt": "Güncelle", + "tooltip": "Profili Güncelle", + "failureMsg": "Profil güncellenemedi", + "successMsg": "Profil başarıyla güncellendi" + }, + "share": { + "buttonText": "Paylaş", + "exportToClipboardSuccess": "Panoya aktarıldı", + "exportSubLinkToClipboard": "Abonelik bağlantısını panoya aktar", + "subLinkQrCode": "QR kodun abonelik bağlantısı ", + "exportConfigToClipboard": "Yapılandırmayı panoya aktar", + "exportConfigToClipboardSuccess": "Yapılandırma panoya kopyalandı" + }, + "edit": { + "buttonTxt": "Düzenle", + "selectActiveTxt": "Etkin profili seçin" + }, + "delete": { + "buttonTxt": "Sil", + "confirmationMsg": "Profil kalıcı olarak silinsin mi?", + "successMsg": "Profil başarıyla silindi" + }, + "save": { + "buttonText": "Kaydet", + "successMsg": "Profil başarıyla kaydedildi", + "failureMsg": "Profil kaydedilemedi" + }, + "detailsForm": { + "nameLabel": "İsim", + "nameHint": "Profil ismi", + "urlLabel": "URL", + "urlHint": "Tam yapılandırma URL'i", + "emptyNameMsg": "İsim gerekli", + "invalidUrlMsg": "Geçersiz URL", + "lastUpdate": "Son Güncelleme", + "updateInterval": "Otomatik güncelleme", + "updateIntervalDialogTitle": "Otomatik Güncelleme Aralığı (saat olarak)" + } + }, + "proxies": { + "pageTitle": "Proxyler", + "emptyProxiesMsg": "Kullanılabilir proxy yok", + "delayTestTooltip": "Test Gecikmesi", + "sortTooltip": "Proxy'leri Sırala", + "sortOptions": { + "unsorted": "Varsayılan", + "name": "Alfabetik olarak", + "delay": "Gecikmeyle" + } + }, + "logs": { + "pageTitle": "Log", + "filterHint": "Filtre", + "allLevelsFilter": "Tüm", + "shareCoreLogs": "Çekirdek Loglarını Paylaş", + "shareAppLogs": "Uygulama Loglarını paylaş", + "pauseTooltip": "Duraklat", + "resumeTooltip": "Devam et", + "clearTooltip": "Temizle" + }, + "settings": { + "pageTitle": "Ayarlar", + "requiresRestartMsg": "Bunun etkili olması için uygulamayı yeniden başlatın", + "general": { + "sectionTitle": "Genel", + "locale": "Dil", + "region": "Bölge", + "regionMsg": "Yerel adresleri atlamak için varsayılan seçeneği seçebilirsin", + "regions": { + "ir": "İran (ir)", + "cn": "Çin (cn)", + "ru": "Rusya (ru)", + "other": "Diğer" + }, + "themeMode": "Tema Modu", + "themeModes": { + "system": "Sistem temasını takip et", + "dark": "Karanlık mod", + "light": "Işık modu", + "black": "Siyah mod" + }, + "enableAnalytics": "Analitikleri Etkinleştir", + "enableAnalyticsMsg": "Uygulamayı iyileştirmek için analiz toplama ve kilitlenme raporları göndermeye izni verin", + "autoStart": "Önyüklemede Başlat", + "silentStart": "Sessiz Başlatma", + "openWorkingDir": "Çalışma Dizinini Aç", + "ignoreBatteryOptimizations": "Pil Optimizasyonunu Devre Dışı Bırak", + "ignoreBatteryOptimizationsMsg": "Optimum VPN performansı için kısıtlamaları kaldırın" + }, + "advanced": { + "sectionTitle": "Gelişmiş", + "debugMode": "Hata ayıklama modu", + "debugModeMsg": "Bu değişikliği uygulamak için uygulamayı yeniden başlatın", + "memoryLimit": "Bellek Sınırı" + }, + "network": { + "perAppProxyPageTitle": "Uygulama başına Proxy", + "perAppProxyModes": { + "off": "Tümü", + "offMsg": "Proxy tüm uygulamalar", + "include": "Proxy", + "includeMsg": "Yalnızca proxy seçilen uygulamalar", + "exclude": "Atlatma", + "excludeMsg": "Seçilen uygulamalara proxy uygulama" + }, + "showSystemApps": "Sistem uygulamalarını göster", + "hideSystemApps": "Sistem uygulamalarını gizle", + "clearSelection": "Seçimi temizle" + }, + "config": { + "serviceMode": "Servis modu", + "serviceModes": { + "proxy": "Proxy", + "systemProxy": "Sistem Proxy", + "tun": "VPN" + }, + "section": { + "route": "Rota Seçenekleri", + "dns": "DNS Seçenekleri", + "inbound": "Gelen Seçenekler", + "misc": "Çeşitli Seçenekler" + }, + "pageTitle": "Yapılandırma Seçenekleri", + "logLevel": "Log Seviyesi", + "resolveDestination": "Hedefi Çöz", + "ipv6Mode": "IPv6 Rotası", + "ipv6Modes": { + "disable": "Devre dışı bırak", + "enable": "Etkinleştir", + "prefer": "Tercih edilen", + "only": "Özel" + }, + "remoteDnsAddress": "Uzak DNS", + "remoteDnsDomainStrategy": "Uzak DNS Domain Stratejisi", + "directDnsAddress": "Doğrudan DNS", + "directDnsDomainStrategy": "Doğrudan DNS Domain Stratejisi", + "mixedPort": "Mixed Port", + "localDnsPort": "Yerel DNS Bağlantı Noktası", + "tunImplementation": "TUN İmplementasyonu", + "mtu": "MTU", + "connectionTestUrl": "Bağlantı Testi URL'i", + "urlTestInterval": "URL Test Aralığı", + "enableClashApi": "Clash API'yi etkinleştir", + "clashApiPort": "Clash API Bağlantı Noktası", + "enableTun": "TUN'u etkinleştir", + "setSystemProxy": "Sistem Proxy'sini Ayarla", + "enableFakeDns": "Sahte DNS'yi Etkinleştir", + "bypassLan": "Lan'ı Atla", + "strictRoute": "Kesin Rota" + }, + "geoAssets": { + "pageTitle": "Varlıkları Yönlendirme", + "version": "Sürüm ${version}", + "fileMissing": "Eksik dosya", + "update": "Güncelleme", + "download": "İndirmek", + "failureMsg": "Öğe güncellenemedi", + "successMsg": "Öğe başarıyla güncellendi", + "addRecommended": "Önerilen Varlıkları Ekle" + } + }, + "about": { + "pageTitle": "Hakkında", + "version": "Sürüm", + "sourceCode": "Kaynak kodu", + "telegramChannel": "Telegram Kanalı", + "checkForUpdate": "Güncellemeleri kontrol et", + "privacyPolicy": "Gizlilik Politikası", + "termsAndConditions": "Şartlar ve koşullar" + }, + "appUpdate": { + "notAvailableMsg": " En son sürümü kullanıyorsunuz", + "dialogTitle": "Yeni Güncell", + "updateMsg": "@:general.appTitle'ın yeni bir sürümü mevcut. Şimdi güncellemek ister misiniz?", + "currentVersionLbl": "Şimdiki versiyon", + "newVersionLbl": "Yeni versiyon", + "updateNowBtnTxt": "Şimdi güncelle", + "laterBtnTxt": "Daha sonra", + "ignoreBtnTxt": "Görmezden gel" + }, + "tray": { + "dashboard": "Gösterge Paneli", + "quit": "Çıkış yap", + "open": "Açık", + "status": { + "connect": "Bağlan", + "connecting": "Bağlanıyor", + "disconnect": "Bağlantıyı kes", + "disconnecting": "Bağlantı kesiliyor" + } + }, + "failure": { + "unexpected": "Beklenmeyen hata", + "clash": { + "unexpected": "Beklenmeyen hata", + "core": "Çakışma Hatası ${reason}" + }, + "singbox": { + "unexpected": "Beklenmedik Hizmet Hatası", + "serviceNotRunning": "Servis çalışmıyor", + "missingPrivilege": "Eksik Ayrıcalık", + "missingPrivilegeMsg": "VPN modu yönetici ayrıcalıkları gerektirir. Uygulamayı yönetici olarak yeniden başlatın veya hizmet modunu değiştirin.", + "invalidConfigOptions": "Geçersiz yapılandırma seçenekleri", + "invalidConfig": "Geçersiz Yapılandırma", + "create": "Servis oluşturma hatası", + "start": "Servis başlatma hatası", + "missingGeoAssets": "Eksik Coğrafi Varlıklar", + "missingGeoAssetsMsg": "Coğrafi öğeler eksik. Aktif varlığı değiştirmeyi veya ayarlarda seçileni indirmeyi düşünün." + }, + "connectivity": { + "unexpected": "Beklenmedik Hata", + "missingVpnPermission": "Eksik VPN İzni", + "missingNotificationPermission": "Eksik Bildirim İzni", + "core": "Temel Hata" + }, + "profiles": { + "unexpected": "Beklenmedik hata", + "notFound": "Profil bulunamadı", + "invalidConfig": "Geçersiz Yapılandırmalar", + "invalidUrl": "Geçersiz URL" + }, + "connection": { + "unexpected": "Beklenmeyen bağlantı hatası", + "timeout": "Bağlantı zamanaşımına uğradı", + "badResponse": "Kötü yanıt", + "connectionError": "Bağlantı hatası", + "badCertificate": "Kötü sertifika" + }, + "geoAssets": { + "unexpected": "Beklenmeyen hata", + "notUpdate": "Güncelleme mevcut değil", + "activeNotFound": "Etkin Coğrafi Varlık Bulunamadı" + } + }, + "play": { + "title": "Hiddify Next (Önizleme)", + "short_description": "Otomatik, SSH, VLESS, Vmess, Trojan, Reality, Sing-Box, Clash, Xray, Shadowsocks", + "full_description": "HiddifyNext'in temel hedefi güvenli, kullanıcı dostu ve verimli bir tünel istemcisi sağlamaktır. VPN Hizmeti iznini kullanarak tüm trafiği veya seçilen uygulama trafiğini seçtiğiniz uzak bir sunucuya yönlendirmenizi sağlar. Not: Herhangi bir sunucu sağlamıyoruz; kullanıcıların kendi barındırılan sunucularını veya güvenilir sunucularını kullanarak çevrimiçi etkinliklerinin gizli kalmasını sağlamaları gerekir. Sunucuları aşağıdakilerle destekliyoruz: - Normal V2ray/Xray Abonelik Bağlantısı - Clash Abonelik Bağlantısı - Sing-Box Abonelik Bağlantısı Benzersiz özelliklerimiz nelerdir? - Kullanıcı Dostu - Optimize Edilmiş ve Hızlı - En Düşük Ping'i otomatik olarak seçin - Kullanıcı kullanım bilgilerini gösterin - Derin bağlantı kullanarak tek tıklamayla alt bağlantıyı kolayca içe aktarın - Ücretsiz ve ADS Yok - Kullanıcı alt bağlantılarını kolayca değiştirin - giderek daha fazla Destek: - Sing-Box tarafından desteklenen tüm Protokoller - VLESS + xtls gerçeklik, vizyon - VMESS - Trojan - ShoadowSocks - Reality - V2ray - Hystria2 - TUIC - SSH - ShadowTLS Kaynak kodu https://github.com/hiddify/Hiddify-Next adresinde mevcuttur. Uygulama çekirdeği açık tabanlıdır. kaynak şarkı kutusu. İzin Açıklaması: - VPN Hizmeti: Bu uygulamanın amacı güvenli, kullanıcı dostu ve verimli bir tünel istemcisi sağlamak olduğundan, trafiği tünel aracılığıyla uzak sunucuya yönlendirebilmek için bu izne ihtiyacımız var. - TÜM PAKETLERİ SORGULAYIN: Bu izin, kullanıcıların tünelleme için belirli uygulamaları dahil etmesine veya hariç tutmasına izin vermek için kullanılır. - ALMA ÖNYÜKLEME TAMAMLANDI: Bu izin, cihaz önyüklemesi sırasında bu uygulamayı etkinleştirmek için uygulama ayarlarından etkinleştirilebilir veya devre dışı bırakılabilir. - BİLDİRİMLER SONRASI: VPN hizmetinin sürekli çalışmasını sağlamak için bir ön plan hizmeti kullandığımız için bu izin önemlidir. - Bu uygulama reklam içermez. Analitik ve kilitlenme verileri yalnızca uygulamanın ilk kullanımında kullanıcının açık rızası ile gerçekleşir." + } +} diff --git a/assets/translations/strings_zh.i18n.json b/assets/translations/strings_zh-CN.i18n.json similarity index 72% rename from assets/translations/strings_zh.i18n.json rename to assets/translations/strings_zh-CN.i18n.json index 54e5030d..b4152345 100644 --- a/assets/translations/strings_zh.i18n.json +++ b/assets/translations/strings_zh-CN.i18n.json @@ -45,7 +45,7 @@ "updatedTimeAgo": "更新 ${timeago}", "remainingDuration": "剩余 ${duration} 天", "remainingTrafficSemanticLabel": "已消耗 ${consumed} 流量,共 ${total} 流量。", - "expired": "已到期", + "expired": "已过期", "noTraffic": "超出配额" }, "sortBy": { @@ -56,7 +56,13 @@ "buttonText": "新的配置文件", "shortBtnTxt": "新的配置文件", "fromClipboard": "从剪贴板添加", - "scanQr": "扫二维码", + "scanQr": "扫描二维码", + "qrScanner": { + "permissionDeniedError": "权限不足", + "unexpectedError": "出了些问题", + "torchSemanticLabel": "手电筒", + "facingSemanticLabel": "相机朝向" + }, "manually": "手动输入", "addingProfileMsg": "添加配置文件", "failureMsg": "添加配置文件失败" @@ -67,9 +73,17 @@ "failureMsg": "更新配置文件失败", "successMsg": "配置文件更新成功" }, + "share": { + "buttonText": "分享", + "exportToClipboardSuccess": "导出到剪贴板", + "exportSubLinkToClipboard": "将订阅链接导出到剪贴板", + "subLinkQrCode": "订阅链接二维码", + "exportConfigToClipboard": "将配置导出到剪贴板", + "exportConfigToClipboardSuccess": "配置已复制到剪贴板" + }, "edit": { "buttonTxt": "编辑", - "selectActiveTxt": "选择活动配置文件" + "selectActiveTxt": "选择并激活配置文件" }, "delete": { "buttonTxt": "删除", @@ -79,7 +93,7 @@ "save": { "buttonText": "保存", "successMsg": "配置文件保存成功", - "failureMsg": "保存配置文件失败" + "failureMsg": "配置文件保存失败" }, "detailsForm": { "nameLabel": "名称", @@ -90,7 +104,7 @@ "invalidUrlMsg": "无效的网址", "lastUpdate": "最后更新", "updateInterval": "自动更新", - "updateIntervalDialogTitle": "自动更新间隔(以小时为单位)" + "updateIntervalDialogTitle": "自动更新间隔(小时)" } }, "proxies": { @@ -126,13 +140,13 @@ "ir": "伊朗 (ir)", "cn": "中国 (cn)", "ru": "俄罗斯 (ru)", - "other": "其他" + "other": "其它" }, "themeMode": "主题模式", "themeModes": { "system": "遵循系统主题", "dark": "黑暗模式", - "light": "明亮模式", + "light": "浅色模式", "black": "深色模式" }, "enableAnalytics": "启用分析", @@ -166,7 +180,7 @@ "config": { "serviceMode": "服务方式", "serviceModes": { - "proxy": "代理人", + "proxy": "仅代理", "systemProxy": "系统代理", "tun": "VPN" }, @@ -174,7 +188,7 @@ "route": "路由选项", "dns": "DNS 选项", "inbound": "入站选项", - "misc": "其他选项" + "misc": "其它选项" }, "pageTitle": "配置选项", "logLevel": "日志级别", @@ -200,9 +214,19 @@ "clashApiPort": "Clash API 端口", "enableTun": "启用 TUN", "setSystemProxy": "设置系统代理", - "enableFakeDns": "Enable Fake DNS", - "bypassLan": "Bypass Lan", - "strictRoute": "Strict Route" + "enableFakeDns": "启用 Fake DNS", + "bypassLan": "绕过局域网", + "strictRoute": "严格路由" + }, + "geoAssets": { + "pageTitle": "路由资源文件", + "version": "版本 ${version}", + "fileMissing": "文件丢失", + "update": "更新", + "download": "下载", + "failureMsg": "更新资源文件失败", + "successMsg": "已成功更新资源文件", + "addRecommended": "添加建议的资源文件" } }, "about": { @@ -221,12 +245,13 @@ "currentVersionLbl": "当前版本", "newVersionLbl": "新版本", "updateNowBtnTxt": "现在更新", - "laterBtnTxt": "之后", + "laterBtnTxt": "以后再说", "ignoreBtnTxt": "忽略" }, "tray": { "dashboard": "控制面板", "quit": "退出", + "open": "打开", "status": { "connect": "连接", "connecting": "正在连接", @@ -238,17 +263,19 @@ "unexpected": "意外错误", "clash": { "unexpected": "意外错误", - "core": "Clash错误 ${reason}" + "core": "Clash 错误 ${reason}" }, "singbox": { "unexpected": "意外服务错误", "serviceNotRunning": "服务未运行", + "missingPrivilege": "缺少权限", + "missingPrivilegeMsg": "VPN 模式需要管理员权限。以管理员身份重新启动应用程序或更改服务模式", + "missingGeoAssets": "缺失 GEO 资源文件", + "missingGeoAssetsMsg": "缺失 GEO 资源文件。请考虑更改激活的资源文件或在设置中下载所选资源文件。", "invalidConfigOptions": "配置选项无效", "invalidConfig": "无效配置", "create": "服务创建错误", - "start": "服务启动错误", - "missingPrivilege": "缺少特权", - "missingPrivilegeMsg": "VPN 模式需要管理员权限。以管理员身份重新启动应用程序或更改服务模式" + "start": "服务启动错误" }, "connectivity": { "unexpected": "意外失败", @@ -268,11 +295,16 @@ "badResponse": "错误响应", "connectionError": "连接错误", "badCertificate": "证书无效" + }, + "geoAssets": { + "unexpected": "意外的错误", + "notUpdate": "无可用更新", + "activeNotFound": "未找到激活的 GEO 资源文件" } }, "play": { "title": "Hiddify Next(预览)", "short_description": "自动,SSH, VLESS, Vmess, Trojan, Reality, Sing-Box, Clash, Xray, Shadowsocks", - "full_description": "HiddifyNext 的主要目标是提供安全、用户友好且高效的隧道客户端。它使您能够利用 VPN 服务权限将所有流量或选定的应用程序流量路由到您选择的远程服务器。\n\n注:我们不提供任何服务器;用户需要使用自己的自托管服务器或受信任的服务器来确保其在线活动的私密性。\n \n我们支持以下类型的服务器:\n- 普通V2ray/Xray订阅链接\n- Clash订阅链接\n- Sing-Box 订阅链接\n\n我们的独特特点是什么?\n\n-用户友好\n-优化和高速\n-自动选择最低延迟\n-显示用户使用信息\n-通过一键深度链接轻松导入子链接\n-免费且无广告\n-轻松切换用户子链接\n-等等\n\n支持:\n- Sing-Box 支持的所有协议\n- VLESS + xtls 现实、愿景\n- VMESS\n- Trojan\n- ShoadowSocks\n- Reality\n-V2ray\n- Hystria2\n- TUIC\n- SSH\n- ShadowTLS\n\n\n源代码位于https://github.com/hiddify/Hiddify-Next\n应用程序核心基于开源的Sing-Box。\n\n权限说明:\n\nVPN服务:由于此应用程序的目标是提供安全、用户友好和高效的隧道客户端,我们需要此权限以能够通过隧道将流量路由到远程服务器。\n查询所有包:此权限用于允许用户包括或排除特定应用程序以进行隧道传输。\n接收启动完成:此权限可以从应用程序设置中启用或禁用,以在设备启动时激活此应用程序。\n发送通知:此权限是必需的,因为我们使用前台服务来确保VPN服务的持续运行。\n此应用程序没有广告。分析和崩溃数据仅在用户在首次使用应用程序时明确同意的情况下发生。" + "full_description": "HiddifyNext 的主要目标是提供安全、用户友好且高效的隧道客户端。它使您能够利用 VPN 服务权限将所有流量或选定的应用程序流量路由到您选择的远程服务器。\n\n注:我们不提供任何服务器;用户需要使用自己托管的服务器或可信的服务器来确保您在线活动的私密性。\n \n我们支持以下类型的服务器:\n- 普通 V2ray/Xray 订阅链接\n- Clash 订阅链接\n- Sing-Box 订阅链接\n\n我们的特色是什么?\n\n- 用户友好\n- 优化和高速\n- 自动选择最低延迟\n- 显示用户使用信息\n- 通过一键链接轻松导入\n- 免费且无广告\n- 轻松切换线路\n- 等等\n\n支持:\n- Sing-Box 支持的所有协议\n- VLESS + XTLS Reality、Vision 协议\n- VMESS\n- Trojan\n- Shoadowsocks\n- Reality\n- V2ray\n- Hystria2\n- TUIC\n- SSH\n- ShadowTLS\n\n\n源代码位于 https://github.com/hiddify/Hiddify-Next\n应用程序核心基于开源的 Sing-Box。\n\n权限说明:\n\n- VPN 服务:由于此应用程序的目标是提供安全、用户友好和高效的隧道客户端,我们需要此权限以能够通过隧道将流量路由到远程服务器。\n获取应用程序列表:此权限用于允许用户包括或排除特定应用程序以进行隧道传输。\n- 接收开机广播:可以从应用程序设置中启用或禁用此权限,以便在设备启动时激活此应用程序。\n- 发送通知:此权限是必需的,因为我们使用前台服务来确保 VPN 服务的持续运行。\n- 本应用程序没有广告。分析和崩溃数据仅在首次使用应用程序时经用户明确同意的情况下发生。" } } \ No newline at end of file diff --git a/build.yaml b/build.yaml index b33aa2b2..c34cb667 100644 --- a/build.yaml +++ b/build.yaml @@ -2,8 +2,6 @@ targets: $default: builders: drift_dev: - generate_for: - - "lib/data/local/**" options: store_date_time_values_as_text: true slang_build_runner: diff --git a/dependencies.properties b/dependencies.properties index 2cb2a52d..ac07d8b0 100644 --- a/dependencies.properties +++ b/dependencies.properties @@ -1 +1 @@ -core.version=0.8.0 \ No newline at end of file +core.version=0.8.4 \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7bfd0a6a..9f45281a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - cupertino_http (0.0.1): + - Flutter - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) @@ -52,7 +54,7 @@ PODS: - GTMSessionFetcher/Core (< 3.0, >= 1.1) - MLImage (= 1.0.0-beta4) - MLKitCommon (~> 9.0) - - mobile_scanner (3.5.2): + - mobile_scanner (3.5.5): - Flutter - GoogleMLKit/BarcodeScanning (~> 4.0.0) - nanopb (2.30909.1): @@ -68,13 +70,13 @@ PODS: - PromisesObjC (2.3.1) - protocol_handler (0.0.1): - Flutter - - Sentry/HybridSDK (8.14.2): - - SentryPrivate (= 8.14.2) + - Sentry/HybridSDK (8.15.2): + - SentryPrivate (= 8.15.2) - sentry_flutter (0.0.1): - Flutter - FlutterMacOS - - Sentry/HybridSDK (= 8.14.2) - - SentryPrivate (8.14.2) + - Sentry/HybridSDK (= 8.15.2) + - SentryPrivate (8.15.2) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -99,6 +101,7 @@ PODS: - Flutter DEPENDENCIES: + - cupertino_http (from `.symlinks/plugins/cupertino_http/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) @@ -131,6 +134,8 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: + cupertino_http: + :path: ".symlinks/plugins/cupertino_http/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: @@ -157,6 +162,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + cupertino_http: 5f8b1161107fe6c8d94a0c618735a033d93fa7db device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef @@ -170,15 +176,15 @@ SPEC CHECKSUMS: MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505 MLKitCommon: c1b791c3e667091918d91bda4bba69a91011e390 MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49 - mobile_scanner: 5090a13b7a35fc1c25b0d97e18e84f271a6eb605 + mobile_scanner: 202ab6f652e40a9add68b10de4c4fb2a745c4348 nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 protocol_handler: ae9efcf3b307f3fdffcd9d5252775b9f7d9f0d09 - Sentry: e0ea366f95ebb68f26d6030d8c22d6b2e6d23dd0 - sentry_flutter: 9a04c51c373d76ee22167bf1e65bc468c0a91fed - SentryPrivate: 949a21fa59872427edc73b524c3ec8456761d97f + Sentry: 6f5742b4c47c17c9adcf265f6f328cf4a0ed1923 + sentry_flutter: 2c309a1d4b45e59d02cfa15795705687f1e2081b + SentryPrivate: b2f7996f37781080f04a946eb4e377ff63c64195 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 sqlite3: 6e2d4a4879854d0ec86b476bf3c3e30870bac273 diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 230502ed..2458c244 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -4,23 +4,25 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; -import 'package:hiddify/core/app/app_view.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/data/repository/app_repository_impl.dart'; -import 'package:hiddify/domain/environment.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; +import 'package:hiddify/core/app_info/app_info_provider.dart'; +import 'package:hiddify/core/model/environment.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; +import 'package:hiddify/core/preferences/preferences_provider.dart'; +import 'package:hiddify/features/app/widget/app.dart'; import 'package:hiddify/features/common/window/window_controller.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart'; +import 'package:hiddify/features/log/data/log_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; import 'package:hiddify/features/system_tray/system_tray_controller.dart'; import 'package:hiddify/services/auto_start_service.dart'; import 'package:hiddify/services/deep_link_service.dart'; import 'package:hiddify/services/service_providers.dart'; +import 'package:hiddify/singbox/service/singbox_service_provider.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:loggy/loggy.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; final _logger = Loggy('bootstrap'); @@ -38,15 +40,14 @@ Future lazyBootstrap( _loggers.addPrinter(sentryLogger); Loggy.initLoggy(); - final appInfo = await AppRepositoryImpl.getAppInfo(env); - final sharedPreferences = await SharedPreferences.getInstance(); final container = ProviderContainer( overrides: [ - appInfoProvider.overrideWithValue(appInfo), - sharedPreferencesProvider.overrideWithValue(sharedPreferences), + environmentProvider.overrideWithValue(env), ], ); + final appInfo = await container.read(appInfoProvider.future); + await container.read(sharedPreferencesProvider.future); final enableAnalytics = container.read(enableAnalyticsProvider); await SentryFlutter.init( @@ -86,27 +87,49 @@ Future _lazyBootstrap( final filesEditor = container.read(filesEditorServiceProvider); await filesEditor.init(); + await container.read(logRepositoryProvider.future); + await container.read(geoAssetRepositoryProvider.future); + await container.read(profileRepositoryProvider.future); initLoggers(container.read, debug); - _logger.info(container.read(appInfoProvider).format()); + _logger.info(container.read(appInfoProvider).requireValue.format()); final silentStart = container.read(silentStartNotifierProvider); if (silentStart) { FlutterNativeSplash.remove(); } + if (PlatformUtils.isDesktop) { + _logger.debug("initializing [Auto Start Service] and [Window Controller]"); await container.read(autoStartServiceProvider.future); await container.read(windowControllerProvider.future); } - await initAppServices(container.read); - await initControllers(container.read); + await container.read(singboxServiceProvider).init(); + _logger.debug("initialized [Singbox Service]"); + + await container.read(activeProfileProvider.future); + await container.read(deepLinkServiceProvider.future); + if (PlatformUtils.isDesktop) { + try { + await container + .read(systemTrayControllerProvider.future) + .timeout(const Duration(seconds: 1)); + _logger.debug("initialized [System Tray Controller]"); + } catch (error, stackTrace) { + _logger.warning( + "error initializing [System Tray Controller]", + error, + stackTrace, + ); + } + } runApp( ProviderScope( parent: container, child: SentryUserInteractionWidget( - child: const AppView(), + child: const App(), ), ), ); @@ -122,7 +145,7 @@ void initLoggers( final logToFile = debug || (!Platform.isAndroid && !Platform.isIOS); if (logToFile) { _loggers.addPrinter( - FileLogPrinter(read(filesEditorServiceProvider).appLogsFile.path), + FileLogPrinter(read(logPathResolverProvider).appFile().path), ); } Loggy.initLoggy( @@ -130,29 +153,3 @@ void initLoggers( logOptions: LogOptions(logLevel), ); } - -Future initAppServices( - Result Function(ProviderListenable) read, -) async { - _logger.debug("initializing app services"); - await Future.wait( - [ - read(singboxServiceProvider).init(), - ], - ); - _logger.debug('initialized app services'); -} - -Future initControllers( - Result Function(ProviderListenable) read, -) async { - _logger.debug("initializing controllers"); - await Future.wait( - [ - read(activeProfileProvider.future), - read(deepLinkServiceProvider.future), - if (PlatformUtils.isDesktop) read(systemTrayControllerProvider.future), - ], - ); - _logger.debug("initialized base controllers"); -} diff --git a/lib/core/app_info/app_info_provider.dart b/lib/core/app_info/app_info_provider.dart new file mode 100644 index 00000000..48042618 --- /dev/null +++ b/lib/core/app_info/app_info_provider.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:hiddify/core/model/app_info_entity.dart'; +import 'package:hiddify/core/model/environment.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'app_info_provider.g.dart'; + +@Riverpod(keepAlive: true) +Environment environment(EnvironmentRef ref) => + throw Exception("override environmentProvider"); + +@Riverpod(keepAlive: true) +class AppInfo extends _$AppInfo { + @override + Future build() async { + final packageInfo = await PackageInfo.fromPlatform(); + final environment = ref.watch(environmentProvider); + return AppInfoEntity( + name: packageInfo.appName, + version: packageInfo.version, + buildNumber: packageInfo.buildNumber, + release: Release.read(), + operatingSystem: Platform.operatingSystem, + operatingSystemVersion: Platform.operatingSystemVersion, + environment: environment, + ); + } +} diff --git a/lib/core/core_providers.dart b/lib/core/core_providers.dart deleted file mode 100644 index 35ce17e4..00000000 --- a/lib/core/core_providers.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/app/app.dart'; -import 'package:hiddify/domain/environment.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'core_providers.g.dart'; - -@Riverpod(keepAlive: true) -AppInfo appInfo(AppInfoRef ref) => - throw UnimplementedError('AppInfo must be overridden'); - -@Riverpod(keepAlive: true) -Environment env(EnvRef ref) => ref.watch(appInfoProvider).environment; - -@Riverpod(keepAlive: true) -TranslationsEn translations(TranslationsRef ref) => - ref.watch(localeNotifierProvider).build(); - -@Riverpod(keepAlive: true) -AppTheme theme(ThemeRef ref) => AppTheme( - ref.watch(themeModeNotifierProvider), - ref.watch(localeNotifierProvider).preferredFontFamily, - ); diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart new file mode 100644 index 00000000..5130d37a --- /dev/null +++ b/lib/core/database/app_database.dart @@ -0,0 +1,59 @@ +import 'package:drift/drift.dart'; +import 'package:hiddify/core/database/connection/database_connection.dart'; +import 'package:hiddify/core/database/converters/duration_converter.dart'; +import 'package:hiddify/core/database/schema_versions.dart'; +import 'package:hiddify/core/database/tables/database_tables.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_data_mapper.dart'; +import 'package:hiddify/features/geo_asset/model/default_geo_assets.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; + +part 'app_database.g.dart'; + +@DriftDatabase(tables: [ProfileEntries, GeoAssetEntries]) +class AppDatabase extends _$AppDatabase { + AppDatabase({required QueryExecutor connection}) : super(connection); + + AppDatabase.connect() : super(openConnection()); + + @override + int get schemaVersion => 3; + + @override + MigrationStrategy get migration { + return MigrationStrategy( + onCreate: (Migrator m) async { + await m.createAll(); + await _prePopulateGeoAssets(); + }, + onUpgrade: stepByStep( + // add type column to profile entries table + // make url column nullable + from1To2: (m, schema) async { + await m.alterTable( + TableMigration( + schema.profileEntries, + columnTransformer: { + schema.profileEntries.type: const Constant("remote"), + }, + newColumns: [schema.profileEntries.type], + ), + ); + }, + from2To3: (m, schema) async { + await m.createTable(schema.geoAssetEntries); + await _prePopulateGeoAssets(); + }, + ), + ); + } + + Future _prePopulateGeoAssets() async { + await transaction(() async { + final geoAssets = defaultGeoAssets.map((e) => e.toEntry()); + for (final geoAsset in geoAssets) { + await into(geoAssetEntries).insert(geoAsset); + } + }); + } +} diff --git a/lib/core/database/connection/database_connection.dart b/lib/core/database/connection/database_connection.dart new file mode 100644 index 00000000..f05450e5 --- /dev/null +++ b/lib/core/database/connection/database_connection.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:hiddify/services/files_editor_service.dart'; +import 'package:path/path.dart' as p; + +LazyDatabase openConnection() { + return LazyDatabase(() async { + final dbDir = await FilesEditorService.getDatabaseDirectory(); + final file = File(p.join(dbDir.path, 'db.sqlite')); + return NativeDatabase.createInBackground(file); + }); +} diff --git a/lib/data/local/type_converters.dart b/lib/core/database/converters/duration_converter.dart similarity index 100% rename from lib/data/local/type_converters.dart rename to lib/core/database/converters/duration_converter.dart diff --git a/lib/core/database/database_provider.dart b/lib/core/database/database_provider.dart new file mode 100644 index 00000000..fb388e20 --- /dev/null +++ b/lib/core/database/database_provider.dart @@ -0,0 +1,7 @@ +import 'package:hiddify/core/database/app_database.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'database_provider.g.dart'; + +@Riverpod(keepAlive: true) +AppDatabase appDatabase(AppDatabaseRef ref) => AppDatabase.connect(); diff --git a/lib/data/local/schema_versions.dart b/lib/core/database/schema_versions.dart similarity index 63% rename from lib/data/local/schema_versions.dart rename to lib/core/database/schema_versions.dart index 7a743f02..da7a4d2b 100644 --- a/lib/data/local/schema_versions.dart +++ b/lib/core/database/schema_versions.dart @@ -111,8 +111,96 @@ i1.GeneratedColumn _column_11(String aliasedName) => i1.GeneratedColumn _column_12(String aliasedName) => i1.GeneratedColumn('support_url', aliasedName, true, type: i1.DriftSqlType.string); + +final class _S3 extends i0.VersionedSchema { + _S3({required super.database}) : super(version: 3); + @override + late final List entities = [ + profileEntries, + geoAssetEntries, + ]; + late final Shape0 profileEntries = Shape0( + source: i0.VersionedTable( + entityName: 'profile_entries', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + _column_9, + _column_10, + _column_11, + _column_12, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape1 geoAssetEntries = Shape1( + source: i0.VersionedTable( + entityName: 'geo_asset_entries', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'PRIMARY KEY(id)', + 'UNIQUE(name, provider_name)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_13, + _column_14, + _column_15, + ], + attachedDatabase: database, + ), + alias: null); +} + +class Shape1 extends i0.VersionedTable { + Shape1({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get active => + columnsByName['active']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get providerName => + columnsByName['provider_name']! as i1.GeneratedColumn; + i1.GeneratedColumn get version => + columnsByName['version']! as i1.GeneratedColumn; + i1.GeneratedColumn get lastCheck => + columnsByName['last_check']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_13(String aliasedName) => + i1.GeneratedColumn('provider_name', aliasedName, false, + additionalChecks: i1.GeneratedColumn.checkTextLength( + minTextLength: 1, + ), + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_14(String aliasedName) => + i1.GeneratedColumn('version', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_15(String aliasedName) => + i1.GeneratedColumn('last_check', aliasedName, true, + type: i1.DriftSqlType.dateTime); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, _S2 schema) from1To2, + required Future Function(i1.Migrator m, _S3 schema) from2To3, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -121,6 +209,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from1To2(migrator, schema); return 2; + case 2: + final schema = _S3(database: database); + final migrator = i1.Migrator(database, schema); + await from2To3(migrator, schema); + return 3; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -129,8 +222,10 @@ i0.MigrationStepWithVersion migrationSteps({ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, _S2 schema) from1To2, + required Future Function(i1.Migrator m, _S3 schema) from2To3, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, + from2To3: from2To3, )); diff --git a/lib/data/local/schemas/drift_schema_v1.json b/lib/core/database/schemas/drift_schema_v1.json similarity index 100% rename from lib/data/local/schemas/drift_schema_v1.json rename to lib/core/database/schemas/drift_schema_v1.json diff --git a/lib/data/local/schemas/drift_schema_v2.json b/lib/core/database/schemas/drift_schema_v2.json similarity index 100% rename from lib/data/local/schemas/drift_schema_v2.json rename to lib/core/database/schemas/drift_schema_v2.json diff --git a/lib/core/database/schemas/drift_schema_v3.json b/lib/core/database/schemas/drift_schema_v3.json new file mode 100644 index 00000000..3cfc5fb9 --- /dev/null +++ b/lib/core/database/schemas/drift_schema_v3.json @@ -0,0 +1,286 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.1.0" + }, + "options": { + "store_date_time_values_as_text": true + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "profile_entries", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ProfileType.values)", + "dart_type_name": "ProfileType" + } + }, + { + "name": "active", + "getter_name": "active", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"active\" IN (0, 1))", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "allowed-lengths": { + "min": 1, + "max": null + } + } + ] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_update", + "getter_name": "lastUpdate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "update_interval", + "getter_name": "updateInterval", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "DurationTypeConverter()", + "dart_type_name": "Duration" + } + }, + { + "name": "upload", + "getter_name": "upload", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "download", + "getter_name": "download", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "total", + "getter_name": "total", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "expire", + "getter_name": "expire", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "web_page_url", + "getter_name": "webPageUrl", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "support_url", + "getter_name": "supportUrl", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "geo_asset_entries", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(GeoAssetType.values)", + "dart_type_name": "GeoAssetType" + } + }, + { + "name": "active", + "getter_name": "active", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"active\" IN (0, 1))", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "allowed-lengths": { + "min": 1, + "max": null + } + } + ] + }, + { + "name": "provider_name", + "getter_name": "providerName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "allowed-lengths": { + "min": 1, + "max": null + } + } + ] + }, + { + "name": "version", + "getter_name": "version", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_check", + "getter_name": "lastCheck", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ], + "unique_keys": [ + [ + "name", + "provider_name" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/lib/data/local/tables.dart b/lib/core/database/tables/database_tables.dart similarity index 51% rename from lib/data/local/tables.dart rename to lib/core/database/tables/database_tables.dart index 17bba834..469f33de 100644 --- a/lib/data/local/tables.dart +++ b/lib/core/database/tables/database_tables.dart @@ -1,6 +1,7 @@ import 'package:drift/drift.dart'; -import 'package:hiddify/data/local/type_converters.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:hiddify/core/database/converters/duration_converter.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; @DataClassName('ProfileEntry') class ProfileEntries extends Table { @@ -22,3 +23,22 @@ class ProfileEntries extends Table { @override Set get primaryKey => {id}; } + +@DataClassName('GeoAssetEntry') +class GeoAssetEntries extends Table { + TextColumn get id => text()(); + TextColumn get type => textEnum()(); + BoolColumn get active => boolean()(); + TextColumn get name => text().withLength(min: 1)(); + TextColumn get providerName => text().withLength(min: 1)(); + TextColumn get version => text().nullable()(); + DateTimeColumn get lastCheck => dateTime().nullable()(); + + @override + Set get primaryKey => {id}; + + @override + List> get uniqueKeys => [ + {name, providerName}, + ]; +} diff --git a/lib/core/http_client/http_client_provider.dart b/lib/core/http_client/http_client_provider.dart new file mode 100644 index 00000000..0798a9d4 --- /dev/null +++ b/lib/core/http_client/http_client_provider.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:hiddify/core/app_info/app_info_provider.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; +import 'package:native_dio_adapter/native_dio_adapter.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'http_client_provider.g.dart'; + +@Riverpod(keepAlive: true) +Dio httpClient(HttpClientRef ref) { + final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 15), + sendTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: { + "User-Agent": ref.watch(appInfoProvider).requireValue.userAgent, + }, + ), + ); + final debug = ref.read(debugModeNotifierProvider); + if (debug && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) { + dio.httpClientAdapter = NativeAdapter(); + } + return dio; +} diff --git a/lib/core/localization/locale_extensions.dart b/lib/core/localization/locale_extensions.dart new file mode 100644 index 00000000..e42d81e8 --- /dev/null +++ b/lib/core/localization/locale_extensions.dart @@ -0,0 +1,13 @@ +import 'package:flutter_localized_locales/flutter_localized_locales.dart'; +import 'package:hiddify/gen/fonts.gen.dart'; +import 'package:hiddify/gen/translations.g.dart'; + +extension AppLocaleX on AppLocale { + String get preferredFontFamily => + this == AppLocale.fa ? FontFamily.shabnam : ""; + + String get localeName => + LocaleNamesLocalizationsDelegate + .nativeLocaleNames[flutterLocale.toString()] ?? + name; +} diff --git a/lib/core/localization/locale_preferences.dart b/lib/core/localization/locale_preferences.dart new file mode 100644 index 00000000..28da4eff --- /dev/null +++ b/lib/core/localization/locale_preferences.dart @@ -0,0 +1,28 @@ +import 'package:hiddify/core/preferences/preferences_provider.dart'; +import 'package:hiddify/gen/translations.g.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'locale_preferences.g.dart'; + +@Riverpod(keepAlive: true) +class LocalePreferences extends _$LocalePreferences { + @override + AppLocale build() { + final persisted = + ref.watch(sharedPreferencesProvider).requireValue.getString("locale"); + if (persisted == null) return AppLocaleUtils.findDeviceLocale(); + // keep backward compatibility with chinese after changing zh to zh_CN + if (persisted == "zh") { + return AppLocale.zhCn; + } + return AppLocale.values.byName(persisted); + } + + Future changeLocale(AppLocale value) async { + state = value; + await ref + .read(sharedPreferencesProvider) + .requireValue + .setString("locale", value.name); + } +} diff --git a/lib/core/localization/translations.dart b/lib/core/localization/translations.dart new file mode 100644 index 00000000..461ffab2 --- /dev/null +++ b/lib/core/localization/translations.dart @@ -0,0 +1,11 @@ +import 'package:hiddify/core/localization/locale_preferences.dart'; +import 'package:hiddify/gen/translations.g.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +export 'package:hiddify/gen/translations.g.dart'; + +part 'translations.g.dart'; + +@Riverpod(keepAlive: true) +TranslationsEn translations(TranslationsRef ref) => + ref.watch(localePreferencesProvider).build(); diff --git a/lib/core/model/app_info_entity.dart b/lib/core/model/app_info_entity.dart new file mode 100644 index 00000000..c239d7fa --- /dev/null +++ b/lib/core/model/app_info_entity.dart @@ -0,0 +1,32 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/model/environment.dart'; + +part 'app_info_entity.freezed.dart'; + +@freezed +class AppInfoEntity with _$AppInfoEntity { + const AppInfoEntity._(); + + const factory AppInfoEntity({ + required String name, + required String version, + required String buildNumber, + required Release release, + required String operatingSystem, + required String operatingSystemVersion, + required Environment environment, + }) = _AppInfoEntity; + + String get userAgent => + "HiddifyNext/$version ($operatingSystem) like ClashMeta v2ray sing-box"; + + String get presentVersion => environment == Environment.prod + ? version + : "$version ${environment.name}"; + + /// formats app info for sharing + String format() => ''' +$name v$version ($buildNumber) [${environment.name}] +${release.name} release +$operatingSystem [$operatingSystemVersion]'''; +} diff --git a/lib/domain/constants.dart b/lib/core/model/constants.dart similarity index 63% rename from lib/domain/constants.dart rename to lib/core/model/constants.dart index da4e62dd..e2ffb59f 100644 --- a/lib/domain/constants.dart +++ b/lib/core/model/constants.dart @@ -1,9 +1,5 @@ abstract class Constants { static const appName = "Hiddify Next"; - static const geoipFileName = "geoip.db"; - static const geositeFileName = "geosite.db"; - static const configsFolderName = "configs"; - static const localHost = "127.0.0.1"; static const githubUrl = "https://github.com/hiddify/hiddify-next"; static const githubReleasesApiUrl = "https://api.github.com/repos/hiddify/hiddify-next/releases"; @@ -15,10 +11,3 @@ abstract class Constants { static const privacyPolicyUrl = "https://hiddify.com/en/privacy-policy/"; static const termsAndConditionsUrl = "https://hiddify.com/terms/"; } - -abstract class Defaults { - static const clashApiPort = 9090; - static const mixedPort = 2334; - static const connectionTestUrl = "https://www.gstatic.com/generate_204"; - static const concurrentTestCount = 5; -} diff --git a/lib/core/model/directories.dart b/lib/core/model/directories.dart new file mode 100644 index 00000000..b940b75d --- /dev/null +++ b/lib/core/model/directories.dart @@ -0,0 +1,7 @@ +import 'dart:io'; + +typedef Directories = ({ + Directory baseDir, + Directory workingDir, + Directory tempDir +}); diff --git a/lib/domain/environment.dart b/lib/core/model/environment.dart similarity index 100% rename from lib/domain/environment.dart rename to lib/core/model/environment.dart diff --git a/lib/domain/failures.dart b/lib/core/model/failures.dart similarity index 97% rename from lib/domain/failures.dart rename to lib/core/model/failures.dart index 1640ac61..1f28b6d9 100644 --- a/lib/domain/failures.dart +++ b/lib/core/model/failures.dart @@ -1,5 +1,5 @@ import 'package:dio/dio.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/localization/translations.dart'; typedef PresentableError = ({String type, String? message}); diff --git a/lib/core/model/region.dart b/lib/core/model/region.dart new file mode 100644 index 00000000..4b4a65eb --- /dev/null +++ b/lib/core/model/region.dart @@ -0,0 +1,15 @@ +import 'package:hiddify/core/localization/translations.dart'; + +enum Region { + ir, + cn, + ru, + other; + + String present(TranslationsEn t) => switch (this) { + ir => t.settings.general.regions.ir, + cn => t.settings.general.regions.cn, + ru => t.settings.general.regions.ru, + other => t.settings.general.regions.other, + }; +} diff --git a/lib/core/notification/in_app_notification_controller.dart b/lib/core/notification/in_app_notification_controller.dart new file mode 100644 index 00000000..7ef2113c --- /dev/null +++ b/lib/core/notification/in_app_notification_controller.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/core/model/failures.dart'; +import 'package:hiddify/features/common/adaptive_root_scaffold.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:toastification/toastification.dart'; + +part 'in_app_notification_controller.g.dart'; + +@Riverpod(keepAlive: true) +InAppNotificationController inAppNotificationController( + InAppNotificationControllerRef ref, +) { + return InAppNotificationController(); +} + +enum NotificationType { + info, + error, + success, +} + +class InAppNotificationController with AppLogger { + void showToast( + BuildContext context, + String message, { + NotificationType type = NotificationType.info, + Duration duration = const Duration(seconds: 3), + }) { + toastification.show( + context: context, + title: message, + type: type._toastificationType, + alignment: Alignment.bottomLeft, + autoCloseDuration: duration, + style: ToastificationStyle.fillColored, + pauseOnHover: true, + showProgressBar: false, + dragToClose: true, + closeOnClick: true, + closeButtonShowType: CloseButtonShowType.onHover, + ); + } + + void showErrorToast(String message) { + final context = RootScaffold.stateKey.currentContext; + if (context == null) { + loggy.warning("context is null"); + return; + } + showToast( + context, + message, + type: NotificationType.error, + duration: const Duration(seconds: 5), + ); + } + + void showSuccessToast(String message) { + final context = RootScaffold.stateKey.currentContext; + if (context == null) { + loggy.warning("context is null"); + return; + } + showToast( + context, + message, + type: NotificationType.success, + ); + } + + void showInfoToast(String message) { + final context = RootScaffold.stateKey.currentContext; + if (context == null) { + loggy.warning("context is null"); + return; + } + showToast(context, message); + } + + Future showErrorDialog(PresentableError error) async { + final context = RootScaffold.stateKey.currentContext; + if (context == null) { + loggy.warning("context is null"); + return; + } + CustomAlertDialog.fromErr(error).show(context); + } +} + +extension NotificationTypeX on NotificationType { + ToastificationType get _toastificationType => switch (this) { + NotificationType.success => ToastificationType.success, + NotificationType.error => ToastificationType.error, + NotificationType.info => ToastificationType.info, + }; +} diff --git a/lib/core/prefs/general_prefs.dart b/lib/core/preferences/general_preferences.dart similarity index 76% rename from lib/core/prefs/general_prefs.dart rename to lib/core/preferences/general_preferences.dart index 656ffb41..af0a8dbe 100644 --- a/lib/core/prefs/general_prefs.dart +++ b/lib/core/preferences/general_preferences.dart @@ -1,19 +1,22 @@ import 'package:flutter/foundation.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/environment.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/core/app_info/app_info_provider.dart'; +import 'package:hiddify/core/model/environment.dart'; +import 'package:hiddify/core/model/region.dart'; +import 'package:hiddify/core/preferences/preferences_provider.dart'; +import 'package:hiddify/features/per_app_proxy/model/per_app_proxy_mode.dart'; import 'package:hiddify/utils/pref_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'general_prefs.g.dart'; +part 'general_preferences.g.dart'; + +// TODO refactor bool _debugIntroPage = false; @Riverpod(keepAlive: true) class IntroCompleted extends _$IntroCompleted { late final _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "intro_completed", false, ); @@ -33,7 +36,7 @@ class IntroCompleted extends _$IntroCompleted { @Riverpod(keepAlive: true) class RegionNotifier extends _$RegionNotifier { late final _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "region", Region.other, mapFrom: Region.values.byName, @@ -51,8 +54,11 @@ class RegionNotifier extends _$RegionNotifier { @Riverpod(keepAlive: true) class SilentStartNotifier extends _$SilentStartNotifier { - late final _pref = - Pref(ref.watch(sharedPreferencesProvider), "silent_start", false); + late final _pref = Pref( + ref.watch(sharedPreferencesProvider).requireValue, + "silent_start", + false, + ); @override bool build() => _pref.getValue(); @@ -66,7 +72,7 @@ class SilentStartNotifier extends _$SilentStartNotifier { @Riverpod(keepAlive: true) class EnableAnalytics extends _$EnableAnalytics { late final _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "enable_analytics", true, ); @@ -83,7 +89,7 @@ class EnableAnalytics extends _$EnableAnalytics { @Riverpod(keepAlive: true) class DisableMemoryLimit extends _$DisableMemoryLimit { late final _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "disable_memory_limit", false, ); @@ -100,9 +106,9 @@ class DisableMemoryLimit extends _$DisableMemoryLimit { @Riverpod(keepAlive: true) class DebugModeNotifier extends _$DebugModeNotifier { late final _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "debug_mode", - ref.read(envProvider) == Environment.dev, + ref.read(environmentProvider) == Environment.dev, ); @override @@ -117,7 +123,7 @@ class DebugModeNotifier extends _$DebugModeNotifier { @Riverpod(keepAlive: true) class PerAppProxyModeNotifier extends _$PerAppProxyModeNotifier { late final _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "per_app_proxy_mode", PerAppProxyMode.off, mapFrom: PerAppProxyMode.values.byName, @@ -136,13 +142,13 @@ class PerAppProxyModeNotifier extends _$PerAppProxyModeNotifier { @Riverpod(keepAlive: true) class PerAppProxyList extends _$PerAppProxyList { late final _include = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "per_app_proxy_include_list", [], ); late final _exclude = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "per_app_proxy_exclude_list", [], ); @@ -165,7 +171,7 @@ class PerAppProxyList extends _$PerAppProxyList { @riverpod class MarkNewProfileActive extends _$MarkNewProfileActive { late final _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "mark_new_profile_active", true, ); diff --git a/lib/core/preferences/preferences_provider.dart b/lib/core/preferences/preferences_provider.dart new file mode 100644 index 00000000..533f6bd8 --- /dev/null +++ b/lib/core/preferences/preferences_provider.dart @@ -0,0 +1,8 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'preferences_provider.g.dart'; + +@Riverpod(keepAlive: true) +Future sharedPreferences(SharedPreferencesRef ref) async => + SharedPreferences.getInstance(); diff --git a/lib/core/prefs/service_prefs.dart b/lib/core/preferences/service_preferences.dart similarity index 63% rename from lib/core/prefs/service_prefs.dart rename to lib/core/preferences/service_preferences.dart index d9cc2cbb..847c614f 100644 --- a/lib/core/prefs/service_prefs.dart +++ b/lib/core/preferences/service_preferences.dart @@ -1,14 +1,17 @@ -import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/core/preferences/preferences_provider.dart'; import 'package:hiddify/utils/pref_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'service_prefs.g.dart'; +part 'service_preferences.g.dart'; @Riverpod(keepAlive: true) class StartedByUser extends _$StartedByUser with AppLogger { - late final _pref = - Pref(ref.watch(sharedPreferencesProvider), "started_by_user", false); + late final _pref = Pref( + ref.watch(sharedPreferencesProvider).requireValue, + "started_by_user", + false, + ); @override bool build() => _pref.getValue(); diff --git a/lib/core/prefs/locale_prefs.dart b/lib/core/prefs/locale_prefs.dart deleted file mode 100644 index 0f294743..00000000 --- a/lib/core/prefs/locale_prefs.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/gen/fonts.gen.dart'; -import 'package:hiddify/gen/translations.g.dart'; -import 'package:hiddify/utils/pref_notifier.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -export 'package:hiddify/gen/translations.g.dart'; - -part 'locale_prefs.g.dart'; - -@Riverpod(keepAlive: true) -class LocaleNotifier extends _$LocaleNotifier { - late final _pref = Pref( - ref.watch(sharedPreferencesProvider), - "locale", - AppLocaleUtils.findDeviceLocale(), - mapFrom: AppLocale.values.byName, - mapTo: (value) => value.name, - ); - - @override - AppLocale build() => _pref.getValue(); - - Future update(AppLocale value) { - state = value; - return _pref.update(value); - } -} - -extension AppLocaleX on AppLocale { - String get preferredFontFamily => - this == AppLocale.fa ? FontFamily.shabnam : ""; -} diff --git a/lib/core/prefs/prefs.dart b/lib/core/prefs/prefs.dart deleted file mode 100644 index 2d8fa8fc..00000000 --- a/lib/core/prefs/prefs.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'app_theme.dart'; -export 'general_prefs.dart'; -export 'locale_prefs.dart'; -export 'theme_prefs.dart'; diff --git a/lib/core/prefs/theme_prefs.dart b/lib/core/prefs/theme_prefs.dart deleted file mode 100644 index a9f21b3b..00000000 --- a/lib/core/prefs/theme_prefs.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:hiddify/core/prefs/app_theme.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/utils/pref_notifier.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'theme_prefs.g.dart'; - -@Riverpod(keepAlive: true) -class ThemeModeNotifier extends _$ThemeModeNotifier { - late final _pref = Pref( - ref.watch(sharedPreferencesProvider), - "theme_mode", - AppThemeMode.system, - mapFrom: AppThemeMode.values.byName, - mapTo: (value) => value.name, - ); - - @override - AppThemeMode build() => _pref.getValue(); - - Future update(AppThemeMode value) { - state = value; - return _pref.update(value); - } -} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 5de8f211..44e14eac 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hiddify/core/router/routes.dart'; import 'package:hiddify/services/deep_link_service.dart'; import 'package:hiddify/utils/utils.dart'; @@ -38,7 +38,9 @@ GoRouter router(RouterRef ref) { navigatorKey: rootNavigatorKey, initialLocation: initialLocation, debugLogDiagnostics: true, - routes: useMobileRouter ? mobileRoutes : desktopRoutes, + routes: [ + if (useMobileRouter) $mobileWrapperRoute else $desktopWrapperRoute, + ], refreshListenable: notifier, redirect: notifier.redirect, observers: [ @@ -51,7 +53,7 @@ int getCurrentIndex(BuildContext context) { final String location = GoRouterState.of(context).uri.path; if (location == const HomeRoute().location) return 0; if (location.startsWith(const ProxiesRoute().location)) return 1; - if (location.startsWith(const LogsRoute().location)) return 2; + if (location.startsWith(const LogsOverviewRoute().location)) return 2; if (location.startsWith(const SettingsRoute().location)) return 3; if (location.startsWith(const AboutRoute().location)) return 4; return 0; @@ -64,7 +66,7 @@ void switchTab(int index, BuildContext context) { case 1: const ProxiesRoute().go(context); case 2: - const LogsRoute().go(context); + const LogsOverviewRoute().go(context); case 3: const SettingsRoute().go(context); case 4: @@ -90,6 +92,7 @@ class RouterListenable extends _$RouterListenable }); } +// ignore: avoid_build_context_in_providers String? redirect(BuildContext context, GoRouterState state) { // if (this.state.isLoading || this.state.hasError) return null; diff --git a/lib/core/router/routes.dart b/lib/core/router/routes.dart index 5b9acd92..d5ec6869 100644 --- a/lib/core/router/routes.dart +++ b/lib/core/router/routes.dart @@ -1,16 +1,380 @@ -import 'package:hiddify/core/router/routes/desktop_routes.dart' as desktop; -import 'package:hiddify/core/router/routes/mobile_routes.dart' as mobile; -import 'package:hiddify/core/router/routes/shared_routes.dart' as shared; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hiddify/core/router/app_router.dart'; +import 'package:hiddify/features/common/adaptive_root_scaffold.dart'; +import 'package:hiddify/features/config_option/overview/config_options_page.dart'; +import 'package:hiddify/features/geo_asset/overview/geo_assets_overview_page.dart'; +import 'package:hiddify/features/home/widget/home_page.dart'; +import 'package:hiddify/features/intro/widget/intro_page.dart'; +import 'package:hiddify/features/log/overview/logs_overview_page.dart'; +import 'package:hiddify/features/per_app_proxy/overview/per_app_proxy_page.dart'; +import 'package:hiddify/features/profile/add/add_profile_modal.dart'; +import 'package:hiddify/features/profile/details/profile_details_page.dart'; +import 'package:hiddify/features/profile/overview/profiles_overview_page.dart'; +import 'package:hiddify/features/proxy/overview/proxies_overview_page.dart'; +import 'package:hiddify/features/settings/about/about_page.dart'; +import 'package:hiddify/features/settings/overview/settings_overview_page.dart'; +import 'package:hiddify/utils/utils.dart'; -export 'routes/mobile_routes.dart'; -export 'routes/shared_routes.dart' hide $appRoutes; +part 'routes.g.dart'; -final mobileRoutes = [ - ...shared.$appRoutes, - ...mobile.$appRoutes, -]; +GlobalKey? _dynamicRootKey = + useMobileRouter ? rootNavigatorKey : null; -final desktopRoutes = [ - ...shared.$appRoutes, - ...desktop.$appRoutes, -]; +@TypedShellRoute( + routes: [ + TypedGoRoute(path: "/intro", name: IntroRoute.name), + TypedGoRoute( + path: "/", + name: HomeRoute.name, + routes: [ + TypedGoRoute( + path: "add", + name: AddProfileRoute.name, + ), + TypedGoRoute( + path: "profiles", + name: ProfilesOverviewRoute.name, + ), + TypedGoRoute( + path: "profiles/new", + name: NewProfileRoute.name, + ), + TypedGoRoute( + path: "profiles/:id", + name: ProfileDetailsRoute.name, + ), + TypedGoRoute( + path: "logs", + name: LogsOverviewRoute.name, + ), + TypedGoRoute( + path: "settings", + name: SettingsRoute.name, + routes: [ + TypedGoRoute( + path: "config-options", + name: ConfigOptionsRoute.name, + ), + TypedGoRoute( + path: "per-app-proxy", + name: PerAppProxyRoute.name, + ), + TypedGoRoute( + path: "routing-assets", + name: GeoAssetsRoute.name, + ), + ], + ), + TypedGoRoute( + path: "about", + name: AboutRoute.name, + ), + ], + ), + TypedGoRoute( + path: "/proxies", + name: ProxiesRoute.name, + ), + ], +) +class MobileWrapperRoute extends ShellRouteData { + const MobileWrapperRoute(); + + @override + Widget builder(BuildContext context, GoRouterState state, Widget navigator) { + return AdaptiveRootScaffold(navigator); + } +} + +@TypedShellRoute( + routes: [ + TypedGoRoute(path: "/intro", name: IntroRoute.name), + TypedGoRoute( + path: "/", + name: HomeRoute.name, + routes: [ + TypedGoRoute( + path: "add", + name: AddProfileRoute.name, + ), + TypedGoRoute( + path: "profiles", + name: ProfilesOverviewRoute.name, + ), + TypedGoRoute( + path: "profiles/new", + name: NewProfileRoute.name, + ), + TypedGoRoute( + path: "profiles/:id", + name: ProfileDetailsRoute.name, + ), + ], + ), + TypedGoRoute( + path: "/proxies", + name: ProxiesRoute.name, + ), + TypedGoRoute( + path: "/logs", + name: LogsOverviewRoute.name, + ), + TypedGoRoute( + path: "/settings", + name: SettingsRoute.name, + routes: [ + TypedGoRoute( + path: "config-options", + name: ConfigOptionsRoute.name, + ), + TypedGoRoute( + path: "routing-assets", + name: GeoAssetsRoute.name, + ), + ], + ), + TypedGoRoute( + path: "/about", + name: AboutRoute.name, + ), + ], +) +class DesktopWrapperRoute extends ShellRouteData { + const DesktopWrapperRoute(); + + @override + Widget builder(BuildContext context, GoRouterState state, Widget navigator) { + return AdaptiveRootScaffold(navigator); + } +} + +class IntroRoute extends GoRouteData { + const IntroRoute(); + static const name = "Intro"; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: IntroPage(), + ); + } +} + +class HomeRoute extends GoRouteData { + const HomeRoute(); + static const name = "Home"; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return const NoTransitionPage( + name: name, + child: HomePage(), + ); + } +} + +class ProxiesRoute extends GoRouteData { + const ProxiesRoute(); + static const name = "Proxies"; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return const NoTransitionPage( + name: name, + child: ProxiesOverviewPage(), + ); + } +} + +class AddProfileRoute extends GoRouteData { + const AddProfileRoute({this.url}); + + final String? url; + + static const name = "Add Profile"; + + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return BottomSheetPage( + fixed: true, + name: name, + builder: (controller) => AddProfileModal( + url: url, + scrollController: controller, + ), + ); + } +} + +class ProfilesOverviewRoute extends GoRouteData { + const ProfilesOverviewRoute(); + static const name = "Profiles"; + + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return BottomSheetPage( + name: name, + builder: (controller) => + ProfilesOverviewModal(scrollController: controller), + ); + } +} + +class NewProfileRoute extends GoRouteData { + const NewProfileRoute(); + static const name = "New Profile"; + + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: ProfileDetailsPage("new"), + ); + } +} + +class ProfileDetailsRoute extends GoRouteData { + const ProfileDetailsRoute(this.id); + final String id; + static const name = "Profile Details"; + + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return MaterialPage( + fullscreenDialog: true, + name: name, + child: ProfileDetailsPage(id), + ); + } +} + +class LogsOverviewRoute extends GoRouteData { + const LogsOverviewRoute(); + static const name = "Logs"; + + static final GlobalKey? $parentNavigatorKey = _dynamicRootKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + if (useMobileRouter) { + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: LogsOverviewPage(), + ); + } + return const NoTransitionPage(name: name, child: LogsOverviewPage()); + } +} + +class SettingsRoute extends GoRouteData { + const SettingsRoute(); + static const name = "Settings"; + + static final GlobalKey? $parentNavigatorKey = _dynamicRootKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + if (useMobileRouter) { + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: SettingsOverviewPage(), + ); + } + return const NoTransitionPage(name: name, child: SettingsOverviewPage()); + } +} + +class ConfigOptionsRoute extends GoRouteData { + const ConfigOptionsRoute(); + static const name = "Config Options"; + + static final GlobalKey? $parentNavigatorKey = _dynamicRootKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + if (useMobileRouter) { + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: ConfigOptionsPage(), + ); + } + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: ConfigOptionsPage(), + ); + } +} + +class PerAppProxyRoute extends GoRouteData { + const PerAppProxyRoute(); + static const name = "Per-app Proxy"; + + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: PerAppProxyPage(), + ); + } +} + +class GeoAssetsRoute extends GoRouteData { + const GeoAssetsRoute(); + static const name = "Routing Assets"; + + static final GlobalKey? $parentNavigatorKey = _dynamicRootKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + if (useMobileRouter) { + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: GeoAssetsOverviewPage(), + ); + } + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: GeoAssetsOverviewPage(), + ); + } +} + +class AboutRoute extends GoRouteData { + const AboutRoute(); + static const name = "About"; + + static final GlobalKey? $parentNavigatorKey = _dynamicRootKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + if (useMobileRouter) { + return const MaterialPage( + fullscreenDialog: true, + name: name, + child: AboutPage(), + ); + } + return const NoTransitionPage(name: name, child: AboutPage()); + } +} diff --git a/lib/core/router/routes/desktop_routes.dart b/lib/core/router/routes/desktop_routes.dart deleted file mode 100644 index 20c1f87d..00000000 --- a/lib/core/router/routes/desktop_routes.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/router/routes/shared_routes.dart'; -import 'package:hiddify/features/about/view/view.dart'; -import 'package:hiddify/features/common/adaptive_root_scaffold.dart'; -import 'package:hiddify/features/logs/view/view.dart'; -import 'package:hiddify/features/settings/view/view.dart'; - -part 'desktop_routes.g.dart'; - -@TypedShellRoute( - routes: [ - TypedGoRoute( - path: HomeRoute.path, - name: HomeRoute.name, - routes: [ - TypedGoRoute( - path: AddProfileRoute.path, - name: AddProfileRoute.name, - ), - TypedGoRoute( - path: ProfilesRoute.path, - name: ProfilesRoute.name, - ), - TypedGoRoute( - path: NewProfileRoute.path, - name: NewProfileRoute.name, - ), - TypedGoRoute( - path: ProfileDetailsRoute.path, - name: ProfileDetailsRoute.name, - ), - ], - ), - TypedGoRoute( - path: ProxiesRoute.path, - name: ProxiesRoute.name, - ), - TypedGoRoute( - path: LogsRoute.path, - name: LogsRoute.name, - ), - TypedGoRoute( - path: SettingsRoute.path, - name: SettingsRoute.name, - routes: [ - TypedGoRoute( - path: ConfigOptionsRoute.path, - name: ConfigOptionsRoute.name, - ), - ], - ), - TypedGoRoute( - path: AboutRoute.path, - name: AboutRoute.name, - ), - ], -) -class DesktopWrapperRoute extends ShellRouteData { - const DesktopWrapperRoute(); - - @override - Widget builder(BuildContext context, GoRouterState state, Widget navigator) { - return AdaptiveRootScaffold(navigator); - } -} - -class LogsRoute extends GoRouteData { - const LogsRoute(); - static const path = '/logs'; - static const name = 'Logs'; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const NoTransitionPage(name: name, child: LogsPage()); - } -} - -class SettingsRoute extends GoRouteData { - const SettingsRoute(); - static const path = '/settings'; - static const name = 'Settings'; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const NoTransitionPage(name: name, child: SettingsPage()); - } -} - -class ConfigOptionsRoute extends GoRouteData { - const ConfigOptionsRoute(); - static const path = 'config-options'; - static const name = 'Config Options'; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - name: name, - child: ConfigOptionsPage(), - ); - } -} - -class AboutRoute extends GoRouteData { - const AboutRoute(); - static const path = '/about'; - static const name = 'About'; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const NoTransitionPage( - name: name, - child: AboutPage(), - ); - } -} diff --git a/lib/core/router/routes/mobile_routes.dart b/lib/core/router/routes/mobile_routes.dart deleted file mode 100644 index 8cfd3ca3..00000000 --- a/lib/core/router/routes/mobile_routes.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/router/app_router.dart'; -import 'package:hiddify/core/router/routes/shared_routes.dart'; -import 'package:hiddify/features/about/view/view.dart'; -import 'package:hiddify/features/common/adaptive_root_scaffold.dart'; -import 'package:hiddify/features/logs/view/view.dart'; -import 'package:hiddify/features/settings/view/view.dart'; - -part 'mobile_routes.g.dart'; - -@TypedShellRoute( - routes: [ - TypedGoRoute( - path: HomeRoute.path, - name: HomeRoute.name, - routes: [ - TypedGoRoute( - path: AddProfileRoute.path, - name: AddProfileRoute.name, - ), - TypedGoRoute( - path: ProfilesRoute.path, - name: ProfilesRoute.name, - ), - TypedGoRoute( - path: NewProfileRoute.path, - name: NewProfileRoute.name, - ), - TypedGoRoute( - path: ProfileDetailsRoute.path, - name: ProfileDetailsRoute.name, - ), - TypedGoRoute( - path: LogsRoute.path, - name: LogsRoute.name, - ), - TypedGoRoute( - path: SettingsRoute.path, - name: SettingsRoute.name, - routes: [ - TypedGoRoute( - path: ConfigOptionsRoute.path, - name: ConfigOptionsRoute.name, - ), - TypedGoRoute( - path: PerAppProxyRoute.path, - name: PerAppProxyRoute.name, - ), - ], - ), - TypedGoRoute( - path: AboutRoute.path, - name: AboutRoute.name, - ), - ], - ), - TypedGoRoute( - path: ProxiesRoute.path, - name: ProxiesRoute.name, - ), - ], -) -class MobileWrapperRoute extends ShellRouteData { - const MobileWrapperRoute(); - - @override - Widget builder(BuildContext context, GoRouterState state, Widget navigator) { - return AdaptiveRootScaffold(navigator); - } -} - -class LogsRoute extends GoRouteData { - const LogsRoute(); - static const path = 'logs'; - static const name = 'Logs'; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - name: name, - child: LogsPage(), - ); - } -} - -class SettingsRoute extends GoRouteData { - const SettingsRoute(); - static const path = 'settings'; - static const name = 'Settings'; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - name: name, - child: SettingsPage(), - ); - } -} - -class ConfigOptionsRoute extends GoRouteData { - const ConfigOptionsRoute(); - static const path = 'config-options'; - static const name = 'Config Options'; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - name: name, - child: ConfigOptionsPage(), - ); - } -} - -class PerAppProxyRoute extends GoRouteData { - const PerAppProxyRoute(); - static const path = 'per-app-proxy'; - static const name = 'Per-app Proxy'; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - name: name, - child: PerAppProxyPage(), - ); - } -} - -class AboutRoute extends GoRouteData { - const AboutRoute(); - static const path = 'about'; - static const name = 'About'; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - name: name, - child: AboutPage(), - ); - } -} diff --git a/lib/core/router/routes/shared_routes.dart b/lib/core/router/routes/shared_routes.dart deleted file mode 100644 index 76410f5f..00000000 --- a/lib/core/router/routes/shared_routes.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/router/app_router.dart'; -import 'package:hiddify/features/home/view/view.dart'; -import 'package:hiddify/features/intro/intro_page.dart'; -import 'package:hiddify/features/profile_detail/view/view.dart'; -import 'package:hiddify/features/profiles/view/view.dart'; -import 'package:hiddify/features/proxies/view/view.dart'; -import 'package:hiddify/utils/utils.dart'; - -part 'shared_routes.g.dart'; - -class HomeRoute extends GoRouteData { - const HomeRoute(); - static const path = '/'; - static const name = 'Home'; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const NoTransitionPage( - name: name, - child: HomePage(), - ); - } -} - -class ProxiesRoute extends GoRouteData { - const ProxiesRoute(); - static const path = '/proxies'; - static const name = 'Proxies'; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const NoTransitionPage( - name: name, - child: ProxiesPage(), - ); - } -} - -class AddProfileRoute extends GoRouteData { - const AddProfileRoute({this.url}); - static const path = 'add'; - static const name = 'Add Profile'; - final String? url; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return BottomSheetPage( - fixed: true, - name: name, - builder: (controller) => AddProfileModal( - url: url, - scrollController: controller, - ), - ); - } -} - -@TypedGoRoute(path: IntroRoute.path, name: IntroRoute.name) -class IntroRoute extends GoRouteData { - const IntroRoute(); - static const path = '/intro'; - static const name = 'Intro'; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - name: name, - child: IntroPage(), - ); - } -} - -class ProfilesRoute extends GoRouteData { - const ProfilesRoute(); - static const path = 'profiles'; - static const name = 'Profiles'; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return BottomSheetPage( - name: name, - builder: (controller) => ProfilesModal(scrollController: controller), - ); - } -} - -class NewProfileRoute extends GoRouteData { - const NewProfileRoute(); - static const path = 'profiles/new'; - static const name = 'New Profile'; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - name: name, - child: ProfileDetailPage("new"), - ); - } -} - -class ProfileDetailsRoute extends GoRouteData { - const ProfileDetailsRoute(this.id); - final String id; - static const path = 'profiles/:id'; - static const name = 'Profile Details'; - - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return MaterialPage( - fullscreenDialog: true, - name: name, - child: ProfileDetailPage(id), - ); - } -} diff --git a/lib/core/prefs/app_theme.dart b/lib/core/theme/app_theme.dart similarity index 77% rename from lib/core/prefs/app_theme.dart rename to lib/core/theme/app_theme.dart index 786be4f5..60bce95b 100644 --- a/lib/core/prefs/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,31 +1,9 @@ +// mostly exact copy of flex color scheme 7.1's fabulous 12 theme import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; -import 'package:hiddify/core/prefs/locale_prefs.dart'; +import 'package:hiddify/core/theme/app_theme_mode.dart'; +import 'package:hiddify/core/theme/theme_extensions.dart'; -enum AppThemeMode { - system, - light, - dark, - black; - - String present(TranslationsEn t) => switch (this) { - system => t.settings.general.themeModes.system, - light => t.settings.general.themeModes.light, - dark => t.settings.general.themeModes.dark, - black => t.settings.general.themeModes.black, - }; - - ThemeMode get flutterThemeMode => switch (this) { - system => ThemeMode.system, - light => ThemeMode.light, - dark => ThemeMode.dark, - black => ThemeMode.dark, - }; - - bool get trueBlack => this == black; -} - -// mostly exact copy of flex color scheme 7.1's fabulous 12 theme class AppTheme { AppTheme( this.mode, @@ -160,42 +138,3 @@ class AppTheme { ); } } - -class ConnectionButtonTheme extends ThemeExtension { - const ConnectionButtonTheme({ - this.idleColor, - this.connectedColor, - }); - - final Color? idleColor; - final Color? connectedColor; - - static const ConnectionButtonTheme light = ConnectionButtonTheme( - idleColor: Color(0xFF4a4d8b), - connectedColor: Color(0xFF44a334), - ); - - @override - ThemeExtension copyWith({ - Color? idleColor, - Color? connectedColor, - }) => - ConnectionButtonTheme( - idleColor: idleColor ?? this.idleColor, - connectedColor: connectedColor ?? this.connectedColor, - ); - - @override - ThemeExtension lerp( - covariant ThemeExtension? other, - double t, - ) { - if (other is! ConnectionButtonTheme) { - return this; - } - return ConnectionButtonTheme( - idleColor: Color.lerp(idleColor, other.idleColor, t), - connectedColor: Color.lerp(connectedColor, other.connectedColor, t), - ); - } -} diff --git a/lib/core/theme/app_theme_mode.dart b/lib/core/theme/app_theme_mode.dart new file mode 100644 index 00000000..5696f70c --- /dev/null +++ b/lib/core/theme/app_theme_mode.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/core/localization/translations.dart'; + +enum AppThemeMode { + system, + light, + dark, + black; + + String present(TranslationsEn t) => switch (this) { + system => t.settings.general.themeModes.system, + light => t.settings.general.themeModes.light, + dark => t.settings.general.themeModes.dark, + black => t.settings.general.themeModes.black, + }; + + ThemeMode get flutterThemeMode => switch (this) { + system => ThemeMode.system, + light => ThemeMode.light, + dark => ThemeMode.dark, + black => ThemeMode.dark, + }; + + bool get trueBlack => this == black; +} diff --git a/lib/core/theme/theme_extensions.dart b/lib/core/theme/theme_extensions.dart new file mode 100644 index 00000000..7a7895c2 --- /dev/null +++ b/lib/core/theme/theme_extensions.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class ConnectionButtonTheme extends ThemeExtension { + const ConnectionButtonTheme({ + this.idleColor, + this.connectedColor, + }); + + final Color? idleColor; + final Color? connectedColor; + + static const ConnectionButtonTheme light = ConnectionButtonTheme( + idleColor: Color(0xFF4a4d8b), + connectedColor: Color(0xFF44a334), + ); + + @override + ThemeExtension copyWith({ + Color? idleColor, + Color? connectedColor, + }) => + ConnectionButtonTheme( + idleColor: idleColor ?? this.idleColor, + connectedColor: connectedColor ?? this.connectedColor, + ); + + @override + ThemeExtension lerp( + covariant ThemeExtension? other, + double t, + ) { + if (other is! ConnectionButtonTheme) { + return this; + } + return ConnectionButtonTheme( + idleColor: Color.lerp(idleColor, other.idleColor, t), + connectedColor: Color.lerp(connectedColor, other.connectedColor, t), + ); + } +} diff --git a/lib/core/theme/theme_preferences.dart b/lib/core/theme/theme_preferences.dart new file mode 100644 index 00000000..29fd4d61 --- /dev/null +++ b/lib/core/theme/theme_preferences.dart @@ -0,0 +1,26 @@ +import 'package:hiddify/core/preferences/preferences_provider.dart'; +import 'package:hiddify/core/theme/app_theme_mode.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'theme_preferences.g.dart'; + +@Riverpod(keepAlive: true) +class ThemePreferences extends _$ThemePreferences { + @override + AppThemeMode build() { + final persisted = ref + .watch(sharedPreferencesProvider) + .requireValue + .getString("theme_mode"); + if (persisted == null) return AppThemeMode.system; + return AppThemeMode.values.byName(persisted); + } + + Future changeThemeMode(AppThemeMode value) async { + state = value; + await ref + .read(sharedPreferencesProvider) + .requireValue + .setString("theme_mode", value.name); + } +} diff --git a/lib/data/repository/exception_handlers.dart b/lib/core/utils/exception_handler.dart similarity index 69% rename from lib/data/repository/exception_handlers.dart rename to lib/core/utils/exception_handler.dart index 3805b584..34845072 100644 --- a/lib/data/repository/exception_handlers.dart +++ b/lib/core/utils/exception_handler.dart @@ -30,3 +30,19 @@ extension StreamExceptionHandler on Stream { ); } } + +extension TaskEitherExceptionHandler on TaskEither { + TaskEither handleExceptions( + F Function(Object error, StackTrace stackTrace) onError, + ) { + return TaskEither( + () async { + try { + return await run(); + } catch (error, stackTrace) { + return Left(onError(error, stackTrace)); + } + }, + ); + } +} diff --git a/lib/utils/ffi_utils.dart b/lib/core/utils/ffi_utils.dart similarity index 100% rename from lib/utils/ffi_utils.dart rename to lib/core/utils/ffi_utils.dart diff --git a/lib/core/utils/json_converters.dart b/lib/core/utils/json_converters.dart new file mode 100644 index 00000000..290f6da8 --- /dev/null +++ b/lib/core/utils/json_converters.dart @@ -0,0 +1,11 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +class IntervalInSecondsConverter implements JsonConverter { + const IntervalInSecondsConverter(); + + @override + Duration fromJson(int json) => Duration(seconds: json); + + @override + int toJson(Duration object) => object.inSeconds; +} diff --git a/lib/core/widget/custom_alert_dialog.dart b/lib/core/widget/custom_alert_dialog.dart new file mode 100644 index 00000000..c3ca3f27 --- /dev/null +++ b/lib/core/widget/custom_alert_dialog.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/core/model/failures.dart'; + +class CustomAlertDialog extends StatelessWidget { + const CustomAlertDialog({ + super.key, + this.title, + required this.message, + }); + + final String? title; + final String message; + + factory CustomAlertDialog.fromError(PresentableError error) => + CustomAlertDialog( + title: error.message == null ? null : error.type, + message: error.message ?? error.type, + ); + + Future show(BuildContext context) async { + await showDialog( + context: context, + useRootNavigator: true, + builder: (context) => this, + ); + } + + @override + Widget build(BuildContext context) { + final localizations = MaterialLocalizations.of(context); + + return AlertDialog( + title: title != null ? Text(title!) : null, + content: SingleChildScrollView( + child: SizedBox( + width: 468, + child: Text(message), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(localizations.okButtonLabel), + ), + ], + ); + } +} diff --git a/lib/data/api/clash_api.dart b/lib/data/api/clash_api.dart deleted file mode 100644 index c73397c9..00000000 --- a/lib/data/api/clash_api.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -class ClashApi with InfraLogger { - ClashApi(int port) : address = "${Constants.localHost}:$port"; - - final String address; - - late final _clashDio = Dio( - BaseOptions( - baseUrl: "http://$address", - connectTimeout: const Duration(seconds: 3), - receiveTimeout: const Duration(seconds: 10), - sendTimeout: const Duration(seconds: 3), - ), - ); - - TaskEither> getProxies() { - return TaskEither( - () async { - final response = await _clashDio.get("/proxies"); - if (response.statusCode != 200 || response.data == null) { - return left(response.statusMessage ?? ""); - } - final proxies = ((jsonDecode(response.data! as String) - as Map)["proxies"] as Map) - .entries - .map( - (e) { - final proxyMap = (e.value as Map) - ..putIfAbsent('name', () => e.key); - return ClashProxy.fromJson(proxyMap); - }, - ); - return right(proxies.toList()); - }, - ); - } - - TaskEither changeProxy(String selectorName, String proxyName) { - return TaskEither( - () async { - final response = await _clashDio.put( - "/proxies/$selectorName", - data: {"name": proxyName}, - ); - if (response.statusCode != HttpStatus.noContent) { - return left(response.statusMessage ?? ""); - } - return right(unit); - }, - ); - } - - TaskEither getProxyDelay( - String name, - String url, { - Duration timeout = const Duration(seconds: 10), - }) { - return TaskEither( - () async { - final response = await _clashDio.get( - "/proxies/$name/delay", - queryParameters: { - "timeout": timeout.inMilliseconds, - "url": url, - }, - ); - if (response.statusCode != 200 || response.data == null) { - return left(response.statusMessage ?? ""); - } - return right(response.data!["delay"] as int); - }, - ); - } - - TaskEither getConfigs() { - return TaskEither( - () async { - final response = await _clashDio.get("/configs"); - if (response.statusCode != 200 || response.data == null) { - return left(response.statusMessage ?? ""); - } - final config = - ClashConfig.fromJson(response.data as Map); - return right(config); - }, - ); - } - - TaskEither updateConfigs(String path) { - return TaskEither.of(unit); - } - - TaskEither patchConfigs(ClashConfig config) { - return TaskEither( - () async { - final response = await _clashDio.patch( - "/configs", - data: config.toJson(), - ); - if (response.statusCode != HttpStatus.noContent) { - return left(response.statusMessage ?? ""); - } - return right(unit); - }, - ); - } - - Stream watchLogs(LogLevel level) { - return const Stream.empty(); - } - - Stream watchTraffic() { - final channel = WebSocketChannel.connect( - Uri.parse("ws://$address/traffic"), - ); - return channel.stream.map( - (event) { - return ClashTraffic.fromJson( - jsonDecode(event as String) as Map, - ); - }, - ); - } - - TaskEither getTraffic() { - return TaskEither( - () async { - final response = await _clashDio.get>("/traffic"); - if (response.statusCode != 200 || response.data == null) { - return left(response.statusMessage ?? ""); - } - return right(ClashTraffic.fromJson(response.data!)); - }, - ); - } -} diff --git a/lib/data/data_providers.dart b/lib/data/data_providers.dart deleted file mode 100644 index 7a40c5df..00000000 --- a/lib/data/data_providers.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/general_prefs.dart'; -import 'package:hiddify/data/api/clash_api.dart'; -import 'package:hiddify/data/local/dao/dao.dart'; -import 'package:hiddify/data/local/database.dart'; -import 'package:hiddify/data/repository/app_repository_impl.dart'; -import 'package:hiddify/data/repository/config_options_store.dart'; -import 'package:hiddify/data/repository/repository.dart'; -import 'package:hiddify/domain/app/app.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/domain/core_facade.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/services/service_providers.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -part 'data_providers.g.dart'; - -@Riverpod(keepAlive: true) -AppDatabase appDatabase(AppDatabaseRef ref) => AppDatabase.connect(); - -@Riverpod(keepAlive: true) -SharedPreferences sharedPreferences(SharedPreferencesRef ref) => - throw UnimplementedError('sharedPreferences must be overridden'); - -@Riverpod(keepAlive: true) -Dio dio(DioRef ref) => Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 15), - sendTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 15), - headers: { - "User-Agent": ref.watch(appInfoProvider).userAgent, - }, - ), - ); - -@Riverpod(keepAlive: true) -ProfilesDao profilesDao(ProfilesDaoRef ref) => ProfilesDao( - ref.watch(appDatabaseProvider), - ); - -@Riverpod(keepAlive: true) -ProfilesRepository profilesRepository(ProfilesRepositoryRef ref) => - ProfilesRepositoryImpl( - profilesDao: ref.watch(profilesDaoProvider), - filesEditor: ref.watch(filesEditorServiceProvider), - singbox: ref.watch(coreFacadeProvider), - dio: ref.watch(dioProvider), - ); - -@Riverpod(keepAlive: true) -AppRepository appRepository(AppRepositoryRef ref) => - AppRepositoryImpl(ref.watch(dioProvider)); - -@Riverpod(keepAlive: true) -ClashApi clashApi(ClashApiRef ref) => ClashApi(Defaults.clashApiPort); - -@Riverpod(keepAlive: true) -CoreFacade coreFacade(CoreFacadeRef ref) => CoreFacadeImpl( - ref.watch(singboxServiceProvider), - ref.watch(filesEditorServiceProvider), - ref.watch(platformServicesProvider), - ref.watch(clashApiProvider), - ref.read(debugModeNotifierProvider), - () => ref.read(configOptionsProvider), - ); diff --git a/lib/data/local/dao/dao.dart b/lib/data/local/dao/dao.dart deleted file mode 100644 index 1d0c3ad7..00000000 --- a/lib/data/local/dao/dao.dart +++ /dev/null @@ -1 +0,0 @@ -export 'profiles_dao.dart'; diff --git a/lib/data/local/data_mappers.dart b/lib/data/local/data_mappers.dart deleted file mode 100644 index 0646a749..00000000 --- a/lib/data/local/data_mappers.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:hiddify/data/local/database.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; - -extension ProfileMapper on Profile { - ProfileEntriesCompanion toCompanion() { - return switch (this) { - RemoteProfile(:final url, :final options, :final subInfo) => - ProfileEntriesCompanion.insert( - id: id, - type: ProfileType.remote, - active: active, - name: name, - url: Value(url), - lastUpdate: lastUpdate, - updateInterval: Value(options?.updateInterval), - upload: Value(subInfo?.upload), - download: Value(subInfo?.download), - total: Value(subInfo?.total), - expire: Value(subInfo?.expire), - webPageUrl: Value(subInfo?.webPageUrl), - supportUrl: Value(subInfo?.supportUrl), - ), - LocalProfile() => ProfileEntriesCompanion.insert( - id: id, - type: ProfileType.local, - active: active, - name: name, - lastUpdate: lastUpdate, - ), - }; - } - - static Profile fromEntry(ProfileEntry e) { - ProfileOptions? options; - if (e.updateInterval != null) { - options = ProfileOptions(updateInterval: e.updateInterval!); - } - - SubscriptionInfo? subInfo; - if (e.upload != null && - e.download != null && - e.total != null && - e.expire != null) { - subInfo = SubscriptionInfo( - upload: e.upload!, - download: e.download!, - total: e.total!, - expire: e.expire!, - webPageUrl: e.webPageUrl, - supportUrl: e.supportUrl, - ); - } - - return switch (e.type) { - ProfileType.remote => RemoteProfile( - id: e.id, - active: e.active, - name: e.name, - url: e.url!, - lastUpdate: e.lastUpdate, - options: options, - subInfo: subInfo, - ), - ProfileType.local => LocalProfile( - id: e.id, - active: e.active, - name: e.name, - lastUpdate: e.lastUpdate, - ), - }; - } -} diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart deleted file mode 100644 index a837c1ef..00000000 --- a/lib/data/local/database.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:io'; - -import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; -import 'package:hiddify/data/local/dao/dao.dart'; -import 'package:hiddify/data/local/schema_versions.dart'; -import 'package:hiddify/data/local/tables.dart'; -import 'package:hiddify/data/local/type_converters.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/services/files_editor_service.dart'; -import 'package:path/path.dart' as p; - -part 'database.g.dart'; - -@DriftDatabase(tables: [ProfileEntries], daos: [ProfilesDao]) -class AppDatabase extends _$AppDatabase { - AppDatabase({required QueryExecutor connection}) : super(connection); - - AppDatabase.connect() : super(_openConnection()); - - @override - int get schemaVersion => 2; - - @override - MigrationStrategy get migration { - return MigrationStrategy( - onCreate: (Migrator m) async { - await m.createAll(); - }, - onUpgrade: stepByStep( - // add type column to profile entries table - // make url column nullable - from1To2: (m, schema) async { - await m.alterTable( - TableMigration( - schema.profileEntries, - columnTransformer: { - schema.profileEntries.type: const Constant("remote"), - }, - newColumns: [schema.profileEntries.type], - ), - ); - }, - ), - ); - } -} - -LazyDatabase _openConnection() { - return LazyDatabase(() async { - final dbDir = await FilesEditorService.getDatabaseDirectory(); - final file = File(p.join(dbDir.path, 'db.sqlite')); - return NativeDatabase.createInBackground(file); - }); -} diff --git a/lib/data/repository/app_repository_impl.dart b/lib/data/repository/app_repository_impl.dart deleted file mode 100644 index f353da0e..00000000 --- a/lib/data/repository/app_repository_impl.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/repository/exception_handlers.dart'; -import 'package:hiddify/domain/app/app.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/domain/environment.dart'; -import 'package:hiddify/utils/custom_loggers.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -class AppRepositoryImpl - with ExceptionHandler, InfraLogger - implements AppRepository { - AppRepositoryImpl(this.dio); - - final Dio dio; - - static Future getAppInfo(Environment environment) async { - final packageInfo = await PackageInfo.fromPlatform(); - return AppInfo( - name: packageInfo.appName, - version: packageInfo.version, - buildNumber: packageInfo.buildNumber, - release: Release.read(), - operatingSystem: Platform.operatingSystem, - operatingSystemVersion: Platform.operatingSystemVersion, - environment: environment, - ); - } - - // TODO add market-specific update checking - @override - TaskEither getLatestVersion({ - bool includePreReleases = false, - Release release = Release.general, - }) { - return exceptionHandler( - () async { - if (!release.allowCustomUpdateChecker) { - throw Exception("custom update checkers are not supported"); - } - final response = await dio.get(Constants.githubReleasesApiUrl); - if (response.statusCode != 200 || response.data == null) { - loggy.warning("failed to fetch latest version info"); - return left(const AppFailure.unexpected()); - } - - final releases = response.data! - .map((e) => RemoteVersionInfo.fromJson(e as Map)); - late RemoteVersionInfo latest; - if (includePreReleases) { - latest = releases.first; - } else { - latest = releases.firstWhere((e) => e.preRelease == false); - } - return right(latest); - }, - AppFailure.unexpected, - ); - } -} diff --git a/lib/data/repository/config_options_store.dart b/lib/data/repository/config_options_store.dart deleted file mode 100644 index 55c7455c..00000000 --- a/lib/data/repository/config_options_store.dart +++ /dev/null @@ -1,165 +0,0 @@ -// ignore_for_file: avoid_manual_providers_as_generated_provider_dependency -import 'package:flutter/foundation.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/utils/pref_notifier.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'config_options_store.g.dart'; - -bool _debugConfigBuilder = false; -final _default = ConfigOptions.initial; - -@Riverpod(keepAlive: true) -class ServiceModeStore extends _$ServiceModeStore { - late final _pref = Pref( - ref.watch(sharedPreferencesProvider), - "service-mode", - ServiceMode.defaultMode, - mapFrom: ServiceMode.values.byName, - mapTo: (value) => value.name, - ); - - @override - ServiceMode build() => _pref.getValue(); - - Future update(ServiceMode value) { - state = value; - return _pref.update(value); - } -} - -final logLevelStore = PrefNotifier.provider( - "log-level", - _default.logLevel, - mapFrom: LogLevel.values.byName, - mapTo: (value) => value.name, -); -final resolveDestinationStore = - PrefNotifier.provider("resolve-destination", _default.resolveDestination); -final ipv6ModeStore = PrefNotifier.provider( - "ipv6-mode", - _default.ipv6Mode, - mapFrom: IPv6Mode.values.byName, - mapTo: (value) => value.name, -); -final remoteDnsAddressStore = - PrefNotifier.provider("remote-dns-address", _default.remoteDnsAddress); -final remoteDnsDomainStrategyStore = PrefNotifier.provider( - "remote-domain-dns-strategy", - _default.remoteDnsDomainStrategy, - mapFrom: DomainStrategy.values.byName, - mapTo: (value) => value.name, -); -final directDnsAddressStore = - PrefNotifier.provider("direct-dns-address", _default.directDnsAddress); -final directDnsDomainStrategyStore = PrefNotifier.provider( - "direct-domain-dns-strategy", - _default.directDnsDomainStrategy, - mapFrom: DomainStrategy.values.byName, - mapTo: (value) => value.name, -); -final mixedPortStore = PrefNotifier.provider("mixed-port", _default.mixedPort); -final localDnsPortStore = - PrefNotifier.provider("localDns-port", _default.localDnsPort); -final tunImplementationStore = PrefNotifier.provider( - "tun-implementation", - _default.tunImplementation, - mapFrom: TunImplementation.values.byName, - mapTo: (value) => value.name, -); -final mtuStore = PrefNotifier.provider("mtu", _default.mtu); -final connectionTestUrlStore = - PrefNotifier.provider("connection-test-url", _default.connectionTestUrl); -final urlTestIntervalStore = PrefNotifier.provider( - "url-test-interval", - _default.urlTestInterval, - mapFrom: (value) => Duration(seconds: value), - mapTo: (value) => value.inSeconds, -); -final enableClashApiStore = - PrefNotifier.provider("enable-clash-api", _default.enableClashApi); -final clashApiPortStore = - PrefNotifier.provider("clash-api-port", _default.clashApiPort); -// final enableTunStore = PrefNotifier.provider("enable-tun", _default.enableTun); -// final setSystemProxyStore = -// PrefNotifier.provider("set-system-proxy", _default.setSystemProxy); -final strictRouteStore = - PrefNotifier.provider("strict-route", _default.strictRoute); -final bypassLanStore = PrefNotifier.provider("bypass-lan", _default.bypassLan); -final enableFakeDnsStore = - PrefNotifier.provider("enable-fake-dns", _default.enableFakeDns); - -// HACK temporary -@riverpod -List rules(RulesRef ref) => switch (ref.watch(regionNotifierProvider)) { - Region.ir => [ - const Rule( - id: "id", - name: "name", - enabled: true, - domains: "domain:.ir", - ip: "geoip:ir", - outbound: RuleOutbound.bypass, - ), - ], - Region.cn => [ - const Rule( - id: "id", - name: "name", - enabled: true, - domains: "domain:.cn,geosite:cn", - ip: "geoip:cn", - outbound: RuleOutbound.bypass, - ), - ], - Region.ru => [ - const Rule( - id: "id", - name: "name", - enabled: true, - domains: "domain:.ru", - ip: "geoip:ru", - outbound: RuleOutbound.bypass, - ), - ], - _ => [], - }; - -@riverpod -ConfigOptions configPreferences(ConfigPreferencesRef ref) { - return ConfigOptions( - executeConfigAsIs: kDebugMode && _debugConfigBuilder, - logLevel: ref.watch(logLevelStore), - resolveDestination: ref.watch(resolveDestinationStore), - ipv6Mode: ref.watch(ipv6ModeStore), - remoteDnsAddress: ref.watch(remoteDnsAddressStore), - remoteDnsDomainStrategy: ref.watch(remoteDnsDomainStrategyStore), - directDnsAddress: ref.watch(directDnsAddressStore), - directDnsDomainStrategy: ref.watch(directDnsDomainStrategyStore), - mixedPort: ref.watch(mixedPortStore), - localDnsPort: ref.watch(localDnsPortStore), - tunImplementation: ref.watch(tunImplementationStore), - mtu: ref.watch(mtuStore), - strictRoute: ref.watch(strictRouteStore), - connectionTestUrl: ref.watch(connectionTestUrlStore), - urlTestInterval: ref.watch(urlTestIntervalStore), - enableClashApi: ref.watch(enableClashApiStore), - clashApiPort: ref.watch(clashApiPortStore), - // enableTun: ref.watch(enableTunStore), - // setSystemProxy: ref.watch(setSystemProxyStore), - bypassLan: ref.watch(bypassLanStore), - enableFakeDns: ref.watch(enableFakeDnsStore), - rules: ref.watch(rulesProvider), - ); -} - -@riverpod -ConfigOptions configOptions(ConfigOptionsRef ref) { - final serviceMode = ref.watch(serviceModeStoreProvider); - return ref.watch(configPreferencesProvider).copyWith( - enableTun: serviceMode == ServiceMode.tun, - setSystemProxy: serviceMode == ServiceMode.systemProxy, - ); -} diff --git a/lib/data/repository/core_facade_impl.dart b/lib/data/repository/core_facade_impl.dart deleted file mode 100644 index c779e61c..00000000 --- a/lib/data/repository/core_facade_impl.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'dart:convert'; - -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/api/clash_api.dart'; -import 'package:hiddify/data/repository/exception_handlers.dart'; -import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/domain/connectivity/connection_status.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/domain/core_facade.dart'; -import 'package:hiddify/domain/core_service_failure.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/services/files_editor_service.dart'; -import 'package:hiddify/services/platform_services.dart'; -import 'package:hiddify/services/singbox/singbox_service.dart'; -import 'package:hiddify/utils/utils.dart'; - -class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { - CoreFacadeImpl( - this.singbox, - this.filesEditor, - this.platformServices, - this.clash, - this.debug, - this.configOptions, - ); - - final SingboxService singbox; - final FilesEditorService filesEditor; - final PlatformServices platformServices; - final ClashApi clash; - final bool debug; - final ConfigOptions Function() configOptions; - - bool _initialized = false; - - @override - TaskEither setup() { - if (_initialized) return TaskEither.of(unit); - return exceptionHandler( - () { - loggy.debug("setting up singbox"); - return singbox - .setup( - filesEditor.dirs.baseDir.path, - filesEditor.dirs.workingDir.path, - filesEditor.dirs.tempDir.path, - debug, - ) - .map((r) { - loggy.debug("setup complete"); - _initialized = true; - return r; - }) - .mapLeft(CoreServiceFailure.other) - .run(); - }, - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither parseConfig( - String path, - String tempPath, - bool debug, - ) { - return exceptionHandler( - () { - return singbox - .parseConfig(path, tempPath, debug) - .mapLeft(CoreServiceFailure.invalidConfig) - .run(); - }, - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither changeConfigOptions( - ConfigOptions options, - ) { - return exceptionHandler( - () { - return singbox - .changeConfigOptions(options) - .mapLeft(CoreServiceFailure.invalidConfigOptions) - .run(); - }, - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither start( - String fileName, - bool disableMemoryLimit, - ) { - return exceptionHandler( - () async { - final configPath = filesEditor.configPath(fileName); - final options = configOptions(); - loggy.info( - "config options: ${options.format()}\nMemory Limit: ${!disableMemoryLimit}", - ); - - if (options.enableTun) { - final hasPrivilege = await platformServices.hasPrivilege(); - if (!hasPrivilege) { - loggy.warning("missing privileges for tun mode"); - return left(const CoreMissingPrivilege()); - } - } - - return setup() - .andThen(() => changeConfigOptions(options)) - .andThen( - () => singbox - .start(configPath, disableMemoryLimit) - .mapLeft(CoreServiceFailure.start), - ) - .run(); - }, - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither stop() { - return exceptionHandler( - () => singbox.stop().mapLeft(CoreServiceFailure.other).run(), - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither restart( - String fileName, - bool disableMemoryLimit, - ) { - return exceptionHandler( - () { - final configPath = filesEditor.configPath(fileName); - return changeConfigOptions(configOptions()) - .andThen( - () => singbox - .restart(configPath, disableMemoryLimit) - .mapLeft(CoreServiceFailure.start), - ) - .run(); - }, - CoreServiceFailure.unexpected, - ); - } - - @override - Stream>> watchOutbounds() { - return singbox.watchOutbounds().map((event) { - return (jsonDecode(event) as List).map((e) { - return OutboundGroup.fromJson(e as Map); - }).toList(); - }).handleExceptions( - (error, stackTrace) { - loggy.error("error watching outbounds", error, stackTrace); - return CoreServiceFailure.unexpected(error, stackTrace); - }, - ); - } - - @override - TaskEither selectOutbound( - String groupTag, - String outboundTag, - ) { - return exceptionHandler( - () => singbox - .selectOutbound(groupTag, outboundTag) - .mapLeft(CoreServiceFailure.other) - .run(), - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither urlTest(String groupTag) { - return exceptionHandler( - () => singbox.urlTest(groupTag).mapLeft(CoreServiceFailure.other).run(), - CoreServiceFailure.unexpected, - ); - } - - @override - Stream> watchCoreStatus() { - return singbox.watchStats().map((event) { - final json = jsonDecode(event); - return CoreStatus.fromJson(json as Map); - }).handleExceptions( - (error, stackTrace) { - loggy.warning("error watching status", error, stackTrace); - return CoreServiceFailure.unexpected(error, stackTrace); - }, - ); - } - - @override - Stream>> watchLogs() { - return singbox.watchLogs(filesEditor.coreLogsFile.path).handleExceptions( - (error, stackTrace) { - loggy.warning("error watching logs", error, stackTrace); - return CoreServiceFailure.unexpected(error, stackTrace); - }, - ); - } - - @override - TaskEither clearLogs() { - return exceptionHandler( - () => singbox.clearLogs().mapLeft(CoreServiceFailure.other).run(), - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither getConfigs() { - return exceptionHandler( - () async => clash.getConfigs().mapLeft(CoreServiceFailure.other).run(), - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither patchOverrides(ClashConfig overrides) { - return exceptionHandler( - () async => - clash.patchConfigs(overrides).mapLeft(CoreServiceFailure.other).run(), - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither> getProxies() { - return exceptionHandler( - () async => clash.getProxies().mapLeft(CoreServiceFailure.other).run(), - CoreServiceFailure.unexpected, - ); - } - - @override - TaskEither changeProxy( - String selectorName, - String proxyName, - ) { - return exceptionHandler( - () async => clash - .changeProxy(selectorName, proxyName) - .mapLeft(CoreServiceFailure.other) - .run(), - CoreServiceFailure.unexpected, - ); - } - - @override - Stream> watchTraffic() { - return clash.watchTraffic().handleExceptions(CoreServiceFailure.unexpected); - } - - @override - TaskEither testDelay( - String proxyName, { - String testUrl = Defaults.connectionTestUrl, - }) { - return exceptionHandler( - () async { - final result = clash - .getProxyDelay(proxyName, testUrl) - .mapLeft(CoreServiceFailure.other) - .run(); - return result; - }, - CoreServiceFailure.unexpected, - ); - } - - @override - Stream watchConnectionStatus() => - singbox.watchConnectionStatus(); -} diff --git a/lib/data/repository/profiles_repository_impl.dart b/lib/data/repository/profiles_repository_impl.dart deleted file mode 100644 index cc05cd30..00000000 --- a/lib/data/repository/profiles_repository_impl.dart +++ /dev/null @@ -1,323 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/local/dao/dao.dart'; -import 'package:hiddify/data/repository/exception_handlers.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/services/files_editor_service.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:meta/meta.dart'; -import 'package:retry/retry.dart'; -import 'package:uuid/uuid.dart'; - -class ProfilesRepositoryImpl - with ExceptionHandler, InfraLogger - implements ProfilesRepository { - ProfilesRepositoryImpl({ - required this.profilesDao, - required this.filesEditor, - required this.singbox, - required this.dio, - }); - - final ProfilesDao profilesDao; - final FilesEditorService filesEditor; - final SingboxFacade singbox; - final Dio dio; - - @override - TaskEither get(String id) { - return TaskEither.tryCatch( - () => profilesDao.getById(id), - ProfileUnexpectedFailure.new, - ); - } - - @override - Stream> watchActiveProfile() { - return profilesDao.watchActiveProfile().handleExceptions( - (error, stackTrace) { - loggy.error("error watching active profile", error, stackTrace); - return ProfileUnexpectedFailure(error, stackTrace); - }, - ); - } - - @override - Stream> watchHasAnyProfile() { - return profilesDao - .watchProfileCount() - .map((event) => event != 0) - .handleExceptions(ProfileUnexpectedFailure.new); - } - - @override - Stream>> watchAll({ - ProfilesSort sort = ProfilesSort.lastUpdate, - SortMode mode = SortMode.ascending, - }) { - return profilesDao - .watchAll(sort: sort, mode: mode) - .handleExceptions(ProfileUnexpectedFailure.new); - } - - @override - TaskEither addByUrl( - String url, { - bool markAsActive = false, - }) { - return exceptionHandler( - () async { - final existingProfile = await profilesDao.getProfileByUrl(url); - if (existingProfile case RemoteProfile()) { - loggy.info("profile with same url already exists, updating"); - final baseProfile = markAsActive - ? existingProfile.copyWith(active: true) - : existingProfile; - return update(baseProfile).run(); - } - - final profileId = const Uuid().v4(); - return fetch(url, profileId) - .flatMap( - (profile) => TaskEither( - () async { - await profilesDao.create( - profile.copyWith( - id: profileId, - active: markAsActive, - ), - ); - return right(unit); - }, - ), - ) - .run(); - }, - (error, stackTrace) { - loggy.warning("error adding profile by url", error, stackTrace); - return ProfileUnexpectedFailure(error, stackTrace); - }, - ); - } - - @override - TaskEither addByContent( - String content, { - required String name, - bool markAsActive = false, - }) { - return exceptionHandler( - () async { - final profileId = const Uuid().v4(); - final tempPath = filesEditor.tempConfigPath(profileId); - final path = filesEditor.configPath(profileId); - try { - await File(tempPath).writeAsString(content); - final parseResult = - await singbox.parseConfig(path, tempPath, false).run(); - return parseResult.fold( - (err) async { - loggy.warning("error parsing config", err); - return left(ProfileFailure.invalidConfig(err.msg)); - }, - (_) async { - final profile = LocalProfile( - id: profileId, - active: markAsActive, - name: name, - lastUpdate: DateTime.now(), - ); - await profilesDao.create(profile); - return right(unit); - }, - ); - } finally { - if (File(tempPath).existsSync()) File(tempPath).deleteSync(); - } - }, - (error, stackTrace) { - loggy.warning("error adding profile by content", error, stackTrace); - return ProfileUnexpectedFailure(error, stackTrace); - }, - ); - } - - @override - TaskEither add(RemoteProfile baseProfile) { - return exceptionHandler( - () async { - return fetch(baseProfile.url, baseProfile.id) - .flatMap( - (remoteProfile) => TaskEither(() async { - await profilesDao.create( - baseProfile.copyWith( - subInfo: remoteProfile.subInfo, - lastUpdate: DateTime.now(), - ), - ); - return right(unit); - }), - ) - .run(); - }, - (error, stackTrace) { - loggy.warning("error adding profile", error, stackTrace); - return ProfileUnexpectedFailure(error, stackTrace); - }, - ); - } - - @override - TaskEither update(RemoteProfile baseProfile) { - return exceptionHandler( - () async { - loggy.debug( - "updating profile [${baseProfile.name} (${baseProfile.id})]", - ); - return fetch(baseProfile.url, baseProfile.id) - .flatMap( - (remoteProfile) => TaskEither(() async { - await profilesDao.edit( - baseProfile.copyWith( - subInfo: remoteProfile.subInfo, - lastUpdate: DateTime.now(), - ), - ); - return right(unit); - }), - ) - .run(); - }, - (error, stackTrace) { - loggy.warning("error updating profile", error, stackTrace); - return ProfileUnexpectedFailure(error, stackTrace); - }, - ); - } - - @override - TaskEither edit(Profile profile) { - return exceptionHandler( - () async { - loggy.debug( - "editing profile [${profile.name} (${profile.id})]", - ); - await profilesDao.edit(profile); - return right(unit); - }, - (error, stackTrace) { - loggy.warning("error editing profile", error, stackTrace); - return ProfileUnexpectedFailure(error, stackTrace); - }, - ); - } - - @override - TaskEither setAsActive(String id) { - return TaskEither.tryCatch( - () async { - await profilesDao.setAsActive(id); - return unit; - }, - ProfileUnexpectedFailure.new, - ); - } - - @override - TaskEither delete(String id) { - return TaskEither.tryCatch( - () async { - await profilesDao.removeById(id); - await filesEditor.deleteConfig(id); - return unit; - }, - ProfileUnexpectedFailure.new, - ); - } - - final _subInfoHeaders = [ - 'profile-title', - 'content-disposition', - 'subscription-userinfo', - 'profile-update-interval', - 'support-url', - 'profile-web-page-url', - ]; - - @visibleForTesting - TaskEither fetch( - String url, - String fileName, - ) { - return TaskEither( - () async { - final tempPath = filesEditor.tempConfigPath(fileName); - final path = filesEditor.configPath(fileName); - try { - final response = await retry( - () async => dio.download(url.trim(), tempPath), - maxAttempts: 3, - ); - final headers = - await _populateHeaders(response.headers.map, tempPath); - final parseResult = - await singbox.parseConfig(path, tempPath, false).run(); - return parseResult.fold( - (err) async { - loggy.warning("error parsing config", err); - return left(ProfileFailure.invalidConfig(err.msg)); - }, - (_) async { - final profile = Profile.fromResponse(url, headers); - return right(profile); - }, - ); - } finally { - if (File(tempPath).existsSync()) File(tempPath).deleteSync(); - } - }, - ); - } - - Future>> _populateHeaders( - Map> headers, - String path, - ) async { - var headersFound = 0; - for (final key in _subInfoHeaders) { - if (headers.containsKey(key)) headersFound++; - } - if (headersFound >= 4) return headers; - - loggy.debug( - "only [$headersFound] headers found, checking file content for possible information", - ); - var content = await File(path).readAsString(); - content = safeDecodeBase64(content); - final lines = content.split("\n"); - final linesToProcess = lines.length < 10 ? lines.length : 10; - for (int i = 0; i < linesToProcess; i++) { - final line = lines[i]; - if (line.startsWith("#") || line.startsWith("//")) { - final index = line.indexOf(':'); - if (index == -1) continue; - final key = line - .substring(0, index) - .replaceFirst(RegExp("^#|//"), "") - .trim() - .toLowerCase(); - final value = line.substring(index + 1).trim(); - if (!headers.keys.contains(key) && - _subInfoHeaders.contains(key) && - value.isNotEmpty) { - headers[key] = [value]; - } - } - } - return headers; - } -} diff --git a/lib/data/repository/repository.dart b/lib/data/repository/repository.dart deleted file mode 100644 index 4f454cc4..00000000 --- a/lib/data/repository/repository.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'core_facade_impl.dart'; -export 'profiles_repository_impl.dart'; diff --git a/lib/domain/app/app.dart b/lib/domain/app/app.dart deleted file mode 100644 index 1853b271..00000000 --- a/lib/domain/app/app.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'app_failure.dart'; -export 'app_info.dart'; -export 'app_repository.dart'; diff --git a/lib/domain/app/app_failure.dart b/lib/domain/app/app_failure.dart deleted file mode 100644 index b340cbbb..00000000 --- a/lib/domain/app/app_failure.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/failures.dart'; - -part 'app_failure.freezed.dart'; - -@freezed -sealed class AppFailure with _$AppFailure, Failure { - const AppFailure._(); - - @With() - const factory AppFailure.unexpected([ - Object? error, - StackTrace? stackTrace, - ]) = UpdateUnexpectedFailure; - - @override - ({String type, String? message}) present(TranslationsEn t) { - return switch (this) { - UpdateUnexpectedFailure() => (type: t.failure.unexpected, message: null), - }; - } -} diff --git a/lib/domain/app/app_info.dart b/lib/domain/app/app_info.dart deleted file mode 100644 index 1e02660a..00000000 --- a/lib/domain/app/app_info.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/environment.dart'; - -part 'app_info.freezed.dart'; -part 'app_info.g.dart'; - -@freezed -class AppInfo with _$AppInfo { - const AppInfo._(); - - const factory AppInfo({ - required String name, - required String version, - required String buildNumber, - required Release release, - required String operatingSystem, - required String operatingSystemVersion, - required Environment environment, - }) = _AppInfo; - - String get userAgent => "HiddifyNext/$version ($operatingSystem)"; - - String get presentVersion => environment == Environment.prod - ? version - : "$version ${environment.name}"; - - /// formats app info for sharing - String format() => ''' -$name v$version ($buildNumber) [${environment.name}] -${release.name} release -$operatingSystem [$operatingSystemVersion]'''; - - factory AppInfo.fromJson(Map json) => - _$AppInfoFromJson(json); -} - -// TODO ignore drafts -@Freezed() -class RemoteVersionInfo with _$RemoteVersionInfo { - const RemoteVersionInfo._(); - - const factory RemoteVersionInfo({ - required String version, - required String buildNumber, - required String releaseTag, - required bool preRelease, - required String url, - required DateTime publishedAt, - required Environment flavor, - }) = _RemoteVersionInfo; - - String get presentVersion => - flavor == Environment.prod ? version : "$version ${flavor.name}"; - - // ignore: prefer_constructors_over_static_methods - static RemoteVersionInfo fromJson(Map json) { - final fullTag = json['tag_name'] as String; - final fullVersion = fullTag.removePrefix("v").split("-").first.split("+"); - var version = fullVersion.first; - var buildNumber = fullVersion.elementAtOrElse(1, (index) => ""); - var flavor = Environment.prod; - for (final env in Environment.values) { - final suffix = ".${env.name}"; - if (version.endsWith(suffix)) { - version = version.removeSuffix(suffix); - flavor = env; - break; - } else if (buildNumber.endsWith(suffix)) { - buildNumber = buildNumber.removeSuffix(suffix); - flavor = env; - break; - } - } - final preRelease = json["prerelease"] as bool; - final publishedAt = DateTime.parse(json["published_at"] as String); - return RemoteVersionInfo( - version: version, - buildNumber: buildNumber, - releaseTag: fullTag, - preRelease: preRelease, - url: json["html_url"] as String, - publishedAt: publishedAt, - flavor: flavor, - ); - } -} diff --git a/lib/domain/app/app_repository.dart b/lib/domain/app/app_repository.dart deleted file mode 100644 index e51bcb35..00000000 --- a/lib/domain/app/app_repository.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/app/app_failure.dart'; -import 'package:hiddify/domain/app/app_info.dart'; - -abstract interface class AppRepository { - TaskEither getLatestVersion({ - bool includePreReleases = false, - }); -} diff --git a/lib/domain/clash/clash.dart b/lib/domain/clash/clash.dart deleted file mode 100644 index 4db9fff2..00000000 --- a/lib/domain/clash/clash.dart +++ /dev/null @@ -1,6 +0,0 @@ -export 'clash_config.dart'; -export 'clash_enums.dart'; -export 'clash_facade.dart'; -export 'clash_log.dart'; -export 'clash_proxy.dart'; -export 'clash_traffic.dart'; diff --git a/lib/domain/clash/clash_config.dart b/lib/domain/clash/clash_config.dart deleted file mode 100644 index 5e515ea4..00000000 --- a/lib/domain/clash/clash_config.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:fpdart/fpdart.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/clash/clash_enums.dart'; - -part 'clash_config.freezed.dart'; -part 'clash_config.g.dart'; - -@freezed -class ClashConfig with _$ClashConfig { - const ClashConfig._(); - - @JsonSerializable(includeIfNull: false, fieldRename: FieldRename.kebab) - const factory ClashConfig({ - @JsonKey(name: 'port') int? httpPort, - int? socksPort, - int? redirPort, - int? tproxyPort, - int? mixedPort, - List? authentication, - bool? allowLan, - String? bindAddress, - TunnelMode? mode, - LogLevel? logLevel, - bool? ipv6, - }) = _ClashConfig; - - ClashConfig patch(ClashConfigPatch patch) { - return copyWith( - httpPort: (patch.httpPort ?? optionOf(httpPort)).toNullable(), - socksPort: (patch.socksPort ?? optionOf(socksPort)).toNullable(), - redirPort: (patch.redirPort ?? optionOf(redirPort)).toNullable(), - tproxyPort: (patch.tproxyPort ?? optionOf(tproxyPort)).toNullable(), - mixedPort: (patch.mixedPort ?? optionOf(mixedPort)).toNullable(), - authentication: - (patch.authentication ?? optionOf(authentication)).toNullable(), - allowLan: (patch.allowLan ?? optionOf(allowLan)).toNullable(), - bindAddress: (patch.bindAddress ?? optionOf(bindAddress)).toNullable(), - mode: (patch.mode ?? optionOf(mode)).toNullable(), - logLevel: (patch.logLevel ?? optionOf(logLevel)).toNullable(), - ipv6: (patch.ipv6 ?? optionOf(ipv6)).toNullable(), - ); - } - - factory ClashConfig.fromJson(Map json) => - _$ClashConfigFromJson(json); -} - -@freezed -class ClashConfigPatch with _$ClashConfigPatch { - const ClashConfigPatch._(); - - @JsonSerializable(includeIfNull: false) - const factory ClashConfigPatch({ - Option? httpPort, - Option? socksPort, - Option? redirPort, - Option? tproxyPort, - Option? mixedPort, - Option>? authentication, - Option? allowLan, - Option? bindAddress, - Option? mode, - Option? logLevel, - Option? ipv6, - }) = _ClashConfigPatch; -} diff --git a/lib/domain/clash/clash_enums.dart b/lib/domain/clash/clash_enums.dart deleted file mode 100644 index f8ad5af3..00000000 --- a/lib/domain/clash/clash_enums.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; - -enum TunnelMode { - rule, - global, - direct; -} - -enum LogLevel { - info, - warning, - error, - debug, - silent; - - Color get color => switch (this) { - info => Colors.lightGreen, - warning => Colors.orangeAccent, - error => Colors.redAccent, - debug => Colors.lightBlue, - _ => Colors.white, - }; -} - -enum ProxyType { - direct("Direct"), - reject("Reject"), - compatible("Compatible"), - pass("Pass"), - shadowSocks("ShadowSocks"), - shadowSocksR("ShadowSocksR"), - snell("Snell"), - socks5("Socks5"), - http("Http"), - vmess("Vmess"), - vless("Vless"), - trojan("Trojan"), - hysteria("Hysteria"), - wireGuard("WireGuard"), - tuic("Tuic"), - ssh("SSH"), - relay("Relay"), - selector("Selector"), - fallback("Fallback"), - urlTest("URLTest", "urltest"), - loadBalance("LoadBalance"), - unknown("Unknown"); - - const ProxyType(this.label, [this._key]); - - final String? _key; - final String label; - - String get key => _key ?? name; - - static List groupValues = [ - selector, - fallback, - urlTest, - loadBalance, - ]; -} diff --git a/lib/domain/clash/clash_facade.dart b/lib/domain/clash/clash_facade.dart deleted file mode 100644 index 2a2443ee..00000000 --- a/lib/domain/clash/clash_facade.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/domain/core_service_failure.dart'; - -abstract class ClashFacade { - TaskEither getConfigs(); - - TaskEither patchOverrides(ClashConfig overrides); - - TaskEither> getProxies(); - - TaskEither changeProxy( - String selectorName, - String proxyName, - ); - - TaskEither testDelay( - String proxyName, { - String testUrl = Defaults.connectionTestUrl, - }); - - Stream> watchTraffic(); -} diff --git a/lib/domain/clash/clash_log.dart b/lib/domain/clash/clash_log.dart deleted file mode 100644 index c016e6ba..00000000 --- a/lib/domain/clash/clash_log.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/clash/clash_enums.dart'; - -part 'clash_log.freezed.dart'; -part 'clash_log.g.dart'; - -@freezed -class ClashLog with _$ClashLog { - const ClashLog._(); - - const factory ClashLog({ - @JsonKey(name: 'type') required LogLevel level, - @JsonKey(name: 'payload') required String message, - @JsonKey(defaultValue: DateTime.now) required DateTime time, - }) = _ClashLog; - - String get timeStamp => - "${time.month}-${time.day} ${time.hour}:${time.minute}:${time.second}"; - - factory ClashLog.fromJson(Map json) => - _$ClashLogFromJson(json); -} diff --git a/lib/domain/clash/clash_proxy.dart b/lib/domain/clash/clash_proxy.dart deleted file mode 100644 index 28e5b9ad..00000000 --- a/lib/domain/clash/clash_proxy.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/clash/clash_enums.dart'; - -part 'clash_proxy.freezed.dart'; -part 'clash_proxy.g.dart'; - -// TODO: test and improve -@Freezed(fromJson: true) -sealed class ClashProxy with _$ClashProxy { - const ClashProxy._(); - - const factory ClashProxy.group({ - required String name, - @JsonKey(fromJson: _typeFromJson) required ProxyType type, - required List all, - required String now, - @Default(false) bool udp, - List? history, - @JsonKey(includeFromJson: false, includeToJson: false) int? delay, - }) = ClashProxyGroup; - - const factory ClashProxy.item({ - required String name, - @JsonKey(fromJson: _typeFromJson) required ProxyType type, - @Default(false) bool udp, - List? history, - @JsonKey(includeFromJson: false, includeToJson: false) int? delay, - }) = ClashProxyItem; - - factory ClashProxy.fromJson(Map json) { - final isGroup = json.containsKey('all') || - json.containsKey('now') || - ProxyType.groupValues.any( - (e) => e.label == json.getOrElse('type', () => null), - ); - if (isGroup) { - return ClashProxyGroup.fromJson(json); - } else { - return ClashProxyItem.fromJson(json); - } - } -} - -ProxyType _typeFromJson(dynamic type) => - ProxyType.values - .firstOrNullWhere((e) => e.key == (type as String?)?.toLowerCase()) ?? - ProxyType.unknown; - -@freezed -class ClashHistory with _$ClashHistory { - const ClashHistory._(); - - const factory ClashHistory({ - required String time, - required int delay, - }) = _ClashHistory; - - factory ClashHistory.fromJson(Map json) => - _$ClashHistoryFromJson(json); -} diff --git a/lib/domain/clash/clash_traffic.dart b/lib/domain/clash/clash_traffic.dart deleted file mode 100644 index 8602bc26..00000000 --- a/lib/domain/clash/clash_traffic.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'clash_traffic.freezed.dart'; -part 'clash_traffic.g.dart'; - -@freezed -class ClashTraffic with _$ClashTraffic { - const ClashTraffic._(); - - const factory ClashTraffic({ - @JsonKey(name: 'up') required int upload, - @JsonKey(name: 'down') required int download, - }) = _ClashTraffic; - - factory ClashTraffic.fromJson(Map json) => - _$ClashTrafficFromJson(json); -} diff --git a/lib/domain/connectivity/connectivity.dart b/lib/domain/connectivity/connectivity.dart deleted file mode 100644 index 8f2bb7cc..00000000 --- a/lib/domain/connectivity/connectivity.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'connection_failure.dart'; -export 'connection_status.dart'; diff --git a/lib/domain/core_facade.dart b/lib/domain/core_facade.dart deleted file mode 100644 index a3ada4aa..00000000 --- a/lib/domain/core_facade.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; - -abstract interface class CoreFacade implements SingboxFacade, ClashFacade {} diff --git a/lib/domain/core_service_failure.dart b/lib/domain/core_service_failure.dart deleted file mode 100644 index 8e2041dd..00000000 --- a/lib/domain/core_service_failure.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/failures.dart'; - -part 'core_service_failure.freezed.dart'; - -@freezed -sealed class CoreServiceFailure with _$CoreServiceFailure, Failure { - const CoreServiceFailure._(); - - @With() - const factory CoreServiceFailure.unexpected( - Object? error, - StackTrace? stackTrace, - ) = UnexpectedCoreServiceFailure; - - @With() - const factory CoreServiceFailure.serviceNotRunning([String? message]) = - CoreServiceNotRunning; - - @With() - const factory CoreServiceFailure.missingPrivilege() = CoreMissingPrivilege; - - const factory CoreServiceFailure.invalidConfigOptions([ - String? message, - ]) = InvalidConfigOptions; - - @With() - const factory CoreServiceFailure.invalidConfig([ - String? message, - ]) = InvalidConfig; - - const factory CoreServiceFailure.create([ - String? message, - ]) = CoreServiceCreateFailure; - - const factory CoreServiceFailure.start([ - String? message, - ]) = CoreServiceStartFailure; - - const factory CoreServiceFailure.other([ - String? message, - ]) = CoreServiceOtherFailure; - - String? get msg => switch (this) { - UnexpectedCoreServiceFailure() => null, - CoreServiceNotRunning(:final message) => message, - CoreMissingPrivilege() => null, - InvalidConfigOptions(:final message) => message, - InvalidConfig(:final message) => message, - CoreServiceCreateFailure(:final message) => message, - CoreServiceStartFailure(:final message) => message, - CoreServiceOtherFailure(:final message) => message, - }; - - @override - ({String type, String? message}) present(TranslationsEn t) { - return switch (this) { - UnexpectedCoreServiceFailure() => ( - type: t.failure.singbox.unexpected, - message: null, - ), - CoreServiceNotRunning(:final message) => ( - type: t.failure.singbox.serviceNotRunning, - message: message - ), - CoreMissingPrivilege() => ( - type: t.failure.singbox.missingPrivilege, - message: t.failure.singbox.missingPrivilegeMsg, - ), - InvalidConfigOptions(:final message) => ( - type: t.failure.singbox.invalidConfigOptions, - message: message - ), - InvalidConfig(:final message) => ( - type: t.failure.singbox.invalidConfig, - message: message - ), - CoreServiceCreateFailure(:final message) => ( - type: t.failure.singbox.create, - message: message - ), - CoreServiceStartFailure(:final message) => ( - type: t.failure.singbox.start, - message: message - ), - CoreServiceOtherFailure(:final message) => ( - type: t.failure.singbox.unexpected, - message: message - ), - }; - } -} diff --git a/lib/domain/enums.dart b/lib/domain/enums.dart deleted file mode 100644 index e5ce3ee4..00000000 --- a/lib/domain/enums.dart +++ /dev/null @@ -1 +0,0 @@ -enum SortMode { ascending, descending } diff --git a/lib/domain/profiles/profile.dart b/lib/domain/profiles/profile.dart deleted file mode 100644 index c1f826e3..00000000 --- a/lib/domain/profiles/profile.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'dart:convert'; - -import 'package:dartx/dartx.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:loggy/loggy.dart'; -import 'package:uuid/uuid.dart'; - -part 'profile.freezed.dart'; -part 'profile.g.dart'; - -final _loggy = Loggy('Profile'); - -enum ProfileType { remote, local } - -@freezed -sealed class Profile with _$Profile { - const Profile._(); - - const factory Profile.remote({ - required String id, - required bool active, - required String name, - required String url, - required DateTime lastUpdate, - ProfileOptions? options, - SubscriptionInfo? subInfo, - }) = RemoteProfile; - - const factory Profile.local({ - required String id, - required bool active, - required String name, - required DateTime lastUpdate, - }) = LocalProfile; - - // ignore: prefer_constructors_over_static_methods - static RemoteProfile fromResponse( - String url, - Map> headers, - ) { - _loggy.debug("Profile Headers: $headers"); - - final titleHeader = headers['profile-title']?.single; - var title = ''; - if (titleHeader != null) { - if (titleHeader.startsWith("base64:")) { - // TODO handle errors - title = - utf8.decode(base64.decode(titleHeader.replaceFirst("base64:", ""))); - } else { - title = titleHeader; - } - } - - if (title.isEmpty) { - final contentDisposition = headers['content-disposition']?.single; - if (contentDisposition != null) { - final RegExp regExp = RegExp('filename="([^"]*)"'); - final match = regExp.firstMatch(contentDisposition); - if (match != null && match.groupCount >= 1) { - title = match.group(1) ?? ''; - } - } - } - if (title.isEmpty) { - final part = url.split("#").lastOrNull; - if (part != null) { - title = part; - } - } - if (title.isEmpty) { - final part = url.split("/").lastOrNull; - if (part != null) { - final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*"); - title = part.replaceFirst(pattern, ""); - } - } - - final updateIntervalHeader = headers['profile-update-interval']?.single; - ProfileOptions? options; - if (updateIntervalHeader != null) { - final updateInterval = Duration(hours: int.parse(updateIntervalHeader)); - options = ProfileOptions(updateInterval: updateInterval); - } - - final subscriptionInfoHeader = headers['subscription-userinfo']?.single; - SubscriptionInfo? subInfo; - if (subscriptionInfoHeader != null) { - subInfo = SubscriptionInfo.fromResponseHeader(subscriptionInfoHeader); - } - - final webPageUrlHeader = headers['profile-web-page-url']?.single; - final supportUrlHeader = headers['support-url']?.single; - if (subInfo != null) { - subInfo = subInfo.copyWith( - webPageUrl: isUrl(webPageUrlHeader ?? "") ? webPageUrlHeader : null, - supportUrl: isUrl(supportUrlHeader ?? "") ? supportUrlHeader : null, - ); - } - - return RemoteProfile( - id: const Uuid().v4(), - active: false, - name: title.isBlank ? "Remote Profile" : title, - url: url, - lastUpdate: DateTime.now(), - options: options, - subInfo: subInfo, - ); - } - - factory Profile.fromJson(Map json) => - _$ProfileFromJson(json); -} - -@freezed -class ProfileOptions with _$ProfileOptions { - const factory ProfileOptions({ - required Duration updateInterval, - }) = _ProfileOptions; - - factory ProfileOptions.fromJson(Map json) => - _$ProfileOptionsFromJson(json); -} - -@freezed -class SubscriptionInfo with _$SubscriptionInfo { - const SubscriptionInfo._(); - - const factory SubscriptionInfo({ - required int upload, - required int download, - @JsonKey(fromJson: _fromJsonTotal, defaultValue: 9223372036854775807) - required int total, - @JsonKey(fromJson: _dateTimeFromSecondsSinceEpoch) required DateTime expire, - String? webPageUrl, - String? supportUrl, - }) = _SubscriptionInfo; - - bool get isExpired => expire <= DateTime.now(); - - int get consumption => upload + download; - - double get ratio => (consumption / total).clamp(0, 1); - - Duration get remaining => expire.difference(DateTime.now()); - - factory SubscriptionInfo.fromResponseHeader(String header) { - final values = header.split(';'); - final map = { - for (final v in values) - v.split('=').first.trim(): - num.tryParse(v.split('=').second.trim())?.toInt(), - }; - _loggy.debug("Subscription Info: $map"); - return SubscriptionInfo.fromJson(map); - } - - factory SubscriptionInfo.fromJson(Map json) => - _$SubscriptionInfoFromJson(json); -} - -int _fromJsonTotal(dynamic total) { - final totalInt = total as int? ?? -1; - return totalInt > 0 ? totalInt : 9223372036854775807; -} - -DateTime _dateTimeFromSecondsSinceEpoch(dynamic expire) { - final expireInt = expire as int? ?? -1; - return DateTime.fromMillisecondsSinceEpoch( - (expireInt > 0 ? expireInt : 92233720368) * 1000, - ); -} diff --git a/lib/domain/profiles/profiles.dart b/lib/domain/profiles/profiles.dart deleted file mode 100644 index fb63afe8..00000000 --- a/lib/domain/profiles/profiles.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'profile.dart'; -export 'profile_enums.dart'; -export 'profiles_failure.dart'; -export 'profiles_repository.dart'; diff --git a/lib/domain/profiles/profiles_repository.dart b/lib/domain/profiles/profiles_repository.dart deleted file mode 100644 index 7476d2a2..00000000 --- a/lib/domain/profiles/profiles_repository.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; - -abstract class ProfilesRepository { - TaskEither get(String id); - - Stream> watchActiveProfile(); - - Stream> watchHasAnyProfile(); - - Stream>> watchAll({ - ProfilesSort sort = ProfilesSort.lastUpdate, - SortMode mode = SortMode.ascending, - }); - - TaskEither addByUrl( - String url, { - bool markAsActive = false, - }); - - TaskEither addByContent( - String content, { - required String name, - bool markAsActive = false, - }); - - TaskEither add(RemoteProfile baseProfile); - - TaskEither update(RemoteProfile baseProfile); - - TaskEither edit(Profile profile); - - TaskEither setAsActive(String id); - - TaskEither delete(String id); -} diff --git a/lib/domain/singbox/box_log.dart b/lib/domain/singbox/box_log.dart deleted file mode 100644 index 84ee25c1..00000000 --- a/lib/domain/singbox/box_log.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:flutter/material.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:tint/tint.dart'; - -part 'box_log.freezed.dart'; - -enum LogLevel { - trace, - debug, - info, - warn, - error, - fatal, - panic; - - static List get choices => values.takeFirst(4); - - Color? get color => switch (this) { - trace => Colors.lightBlueAccent, - debug => Colors.grey, - info => Colors.lightGreen, - warn => Colors.orange, - error => Colors.redAccent, - fatal => Colors.red, - panic => Colors.red, - }; -} - -@freezed -class BoxLog with _$BoxLog { - const factory BoxLog({ - LogLevel? level, - DateTime? time, - required String message, - }) = _BoxLog; - - factory BoxLog.parse(String log) { - log = log.strip(); - DateTime? time; - if (log.length > 25) { - time = DateTime.tryParse(log.substring(6, 25)); - } - if (time != null) { - log = log.substring(26); - } - final level = LogLevel.values.firstOrNullWhere( - (e) { - if (log.startsWith(e.name.toUpperCase())) { - log = log.removePrefix(e.name.toUpperCase()); - return true; - } - return false; - }, - ); - return BoxLog( - level: level, - time: time, - message: log.trim(), - ); - } -} diff --git a/lib/domain/singbox/config_options.dart b/lib/domain/singbox/config_options.dart deleted file mode 100644 index c7fcbe1b..00000000 --- a/lib/domain/singbox/config_options.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:convert'; - -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/singbox/box_log.dart'; -import 'package:hiddify/domain/singbox/rules.dart'; - -part 'config_options.freezed.dart'; -part 'config_options.g.dart'; - -@freezed -class ConfigOptions with _$ConfigOptions { - const ConfigOptions._(); - - @JsonSerializable(fieldRename: FieldRename.kebab) - const factory ConfigOptions({ - @Default(false) bool executeConfigAsIs, - @Default(LogLevel.warn) LogLevel logLevel, - @Default(false) bool resolveDestination, - @Default(IPv6Mode.disable) IPv6Mode ipv6Mode, - @Default("tcp://8.8.8.8") String remoteDnsAddress, - @Default(DomainStrategy.auto) DomainStrategy remoteDnsDomainStrategy, - @Default("local") String directDnsAddress, - @Default(DomainStrategy.auto) DomainStrategy directDnsDomainStrategy, - @Default(2334) int mixedPort, - @Default(6450) int localDnsPort, - @Default(TunImplementation.mixed) TunImplementation tunImplementation, - @Default(9000) int mtu, - @Default(true) bool strictRoute, - @Default("http://cp.cloudflare.com/") String connectionTestUrl, - @IntervalConverter() - @Default(Duration(minutes: 10)) - Duration urlTestInterval, - @Default(true) bool enableClashApi, - @Default(6756) int clashApiPort, - @Default(false) bool enableTun, - @Default(false) bool setSystemProxy, - @Default(false) bool bypassLan, - @Default(false) bool enableFakeDns, - List? rules, - }) = _ConfigOptions; - - static ConfigOptions initial = const ConfigOptions(); - - String format() { - const encoder = JsonEncoder.withIndent(' '); - return encoder.convert(toJson()); - } - - factory ConfigOptions.fromJson(Map json) => - _$ConfigOptionsFromJson(json); -} - -@JsonEnum(valueField: 'key') -enum IPv6Mode { - disable("ipv4_only"), - enable("prefer_ipv4"), - prefer("prefer_ipv6"), - only("ipv6_only"); - - const IPv6Mode(this.key); - - final String key; - - String present(TranslationsEn t) => switch (this) { - disable => t.settings.config.ipv6Modes.disable, - enable => t.settings.config.ipv6Modes.enable, - prefer => t.settings.config.ipv6Modes.prefer, - only => t.settings.config.ipv6Modes.only, - }; -} - -@JsonEnum(valueField: 'key') -enum DomainStrategy { - auto(""), - preferIpv6("prefer_ipv6"), - preferIpv4("prefer_ipv4"), - ipv4Only("ipv4_only"), - ipv6Only("ipv6_only"); - - const DomainStrategy(this.key); - - final String key; - - String get displayName => switch (this) { - auto => "auto", - _ => key, - }; -} - -enum TunImplementation { - mixed, - system, - gVisor; -} - -class IntervalConverter implements JsonConverter { - const IntervalConverter(); - - @override - Duration fromJson(String json) => - Duration(minutes: int.parse(json.replaceAll("m", ""))); - - @override - String toJson(Duration object) => "${object.inMinutes}m"; -} diff --git a/lib/domain/singbox/core_status.dart b/lib/domain/singbox/core_status.dart deleted file mode 100644 index 979e57df..00000000 --- a/lib/domain/singbox/core_status.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'core_status.freezed.dart'; -part 'core_status.g.dart'; - -@freezed -class CoreStatus with _$CoreStatus { - const CoreStatus._(); - - @JsonSerializable(fieldRename: FieldRename.kebab) - const factory CoreStatus({ - required int connectionsIn, - required int connectionsOut, - required int uplink, - required int downlink, - required int uplinkTotal, - required int downlinkTotal, - }) = _CoreStatus; - - factory CoreStatus.empty() => const CoreStatus( - connectionsIn: 0, - connectionsOut: 0, - uplink: 0, - downlink: 0, - uplinkTotal: 0, - downlinkTotal: 0, - ); - - factory CoreStatus.fromJson(Map json) => - _$CoreStatusFromJson(json); -} diff --git a/lib/domain/singbox/outbounds.dart b/lib/domain/singbox/outbounds.dart deleted file mode 100644 index e99d0b11..00000000 --- a/lib/domain/singbox/outbounds.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/singbox/proxy_type.dart'; - -part 'outbounds.freezed.dart'; -part 'outbounds.g.dart'; - -@freezed -class OutboundGroup with _$OutboundGroup { - @JsonSerializable(fieldRename: FieldRename.kebab) - const factory OutboundGroup({ - required String tag, - @JsonKey(fromJson: _typeFromJson) required ProxyType type, - required String selected, - @Default([]) List items, - }) = _OutboundGroup; - - factory OutboundGroup.fromJson(Map json) => - _$OutboundGroupFromJson(json); -} - -@freezed -class OutboundGroupItem with _$OutboundGroupItem { - const OutboundGroupItem._(); - - @JsonSerializable(fieldRename: FieldRename.kebab) - const factory OutboundGroupItem({ - required String tag, - @JsonKey(fromJson: _typeFromJson) required ProxyType type, - required int urlTestDelay, - String? selectedTag, - }) = _OutboundGroupItem; - - factory OutboundGroupItem.fromJson(Map json) => - _$OutboundGroupItemFromJson(json); -} - -ProxyType _typeFromJson(dynamic type) => - ProxyType.values - .firstOrNullWhere((e) => e.key == (type as String?)?.toLowerCase()) ?? - ProxyType.unknown; - -String sanitizedTag(String tag) => - tag.replaceFirst(RegExp(r"\§[^]*"), "").trimRight(); diff --git a/lib/domain/singbox/rules.dart b/lib/domain/singbox/rules.dart deleted file mode 100644 index 96c53c9e..00000000 --- a/lib/domain/singbox/rules.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/prefs/locale_prefs.dart'; - -part 'rules.freezed.dart'; -part 'rules.g.dart'; - -@freezed -class Rule with _$Rule { - @JsonSerializable(fieldRename: FieldRename.kebab) - const factory Rule({ - required String id, - required String name, - @Default(false) bool enabled, - String? domains, - String? ip, - String? port, - String? protocol, - @Default(RuleNetwork.tcpAndUdp) RuleNetwork network, - @Default(RuleOutbound.proxy) RuleOutbound outbound, - }) = _Rule; - - factory Rule.fromJson(Map json) => _$RuleFromJson(json); -} - -enum RuleOutbound { proxy, bypass, block } - -@JsonEnum(valueField: 'key') -enum RuleNetwork { - tcpAndUdp(""), - tcp("tcp"), - udp("udp"); - - const RuleNetwork(this.key); - - final String? key; -} - -enum PerAppProxyMode { - off, - include, - exclude; - - bool get enabled => this != off; - - ({String title, String message}) present(TranslationsEn t) => switch (this) { - off => ( - title: t.settings.network.perAppProxyModes.off, - message: t.settings.network.perAppProxyModes.offMsg, - ), - include => ( - title: t.settings.network.perAppProxyModes.include, - message: t.settings.network.perAppProxyModes.includeMsg, - ), - exclude => ( - title: t.settings.network.perAppProxyModes.exclude, - message: t.settings.network.perAppProxyModes.excludeMsg, - ), - }; -} - -enum Region { - ir, - cn, - ru, - other; - - String present(TranslationsEn t) => switch (this) { - ir => t.settings.general.regions.ir, - cn => t.settings.general.regions.cn, - ru => t.settings.general.regions.ru, - other => t.settings.general.regions.other, - }; -} diff --git a/lib/domain/singbox/service_mode.dart b/lib/domain/singbox/service_mode.dart deleted file mode 100644 index 34481e2d..00000000 --- a/lib/domain/singbox/service_mode.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:hiddify/core/prefs/locale_prefs.dart'; -import 'package:hiddify/utils/platform_utils.dart'; - -enum ServiceMode { - proxy, - systemProxy, - tun; - - static ServiceMode get defaultMode => - PlatformUtils.isDesktop ? systemProxy : tun; - - static List get choices { - if (PlatformUtils.isDesktop) { - return values; - } - return [proxy, tun]; - } - - String present(TranslationsEn t) => switch (this) { - proxy => t.settings.config.serviceModes.proxy, - systemProxy => t.settings.config.serviceModes.systemProxy, - tun => t.settings.config.serviceModes.tun, - }; -} diff --git a/lib/domain/singbox/singbox.dart b/lib/domain/singbox/singbox.dart deleted file mode 100644 index 0ca7decc..00000000 --- a/lib/domain/singbox/singbox.dart +++ /dev/null @@ -1,8 +0,0 @@ -export 'box_log.dart'; -export 'config_options.dart'; -export 'core_status.dart'; -export 'outbounds.dart'; -export 'proxy_type.dart'; -export 'rules.dart'; -export 'service_mode.dart'; -export 'singbox_facade.dart'; diff --git a/lib/domain/singbox/singbox_facade.dart b/lib/domain/singbox/singbox_facade.dart deleted file mode 100644 index 7b63ee9d..00000000 --- a/lib/domain/singbox/singbox_facade.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/domain/core_service_failure.dart'; -import 'package:hiddify/domain/singbox/config_options.dart'; -import 'package:hiddify/domain/singbox/core_status.dart'; -import 'package:hiddify/domain/singbox/outbounds.dart'; - -abstract interface class SingboxFacade { - TaskEither setup(); - - TaskEither parseConfig( - String path, - String tempPath, - bool debug, - ); - - TaskEither changeConfigOptions( - ConfigOptions options, - ); - - TaskEither start( - String fileName, - bool disableMemoryLimit, - ); - - TaskEither stop(); - - TaskEither restart( - String fileName, - bool disableMemoryLimit, - ); - - Stream>> watchOutbounds(); - - TaskEither selectOutbound( - String groupTag, - String outboundTag, - ); - - TaskEither urlTest(String groupTag); - - Stream watchConnectionStatus(); - - Stream> watchCoreStatus(); - - Stream>> watchLogs(); - - TaskEither clearLogs(); -} diff --git a/lib/features/about/view/view.dart b/lib/features/about/view/view.dart deleted file mode 100644 index 8f120a21..00000000 --- a/lib/features/about/view/view.dart +++ /dev/null @@ -1 +0,0 @@ -export 'about_page.dart'; diff --git a/lib/core/app/app_view.dart b/lib/features/app/widget/app.dart similarity index 65% rename from lib/core/app/app_view.dart rename to lib/features/app/widget/app.dart index f4e11bd3..0ab58bbe 100644 --- a/lib/core/app/app_view.dart +++ b/lib/features/app/widget/app.dart @@ -2,11 +2,14 @@ import 'package:accessibility_tools/accessibility_tools.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/localization/locale_extensions.dart'; +import 'package:hiddify/core/localization/locale_preferences.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/constants.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/features/common/app_update_notifier.dart'; +import 'package:hiddify/core/theme/app_theme.dart'; +import 'package:hiddify/core/theme/theme_preferences.dart'; +import 'package:hiddify/features/app_update/notifier/app_update_notifier.dart'; import 'package:hiddify/features/common/common_controllers.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -14,14 +17,15 @@ import 'package:upgrader/upgrader.dart'; bool _debugAccessibility = false; -class AppView extends HookConsumerWidget with PresLogger { - const AppView({super.key}); +class App extends HookConsumerWidget with PresLogger { + const App({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); - final locale = ref.watch(localeNotifierProvider).flutterLocale; - final theme = ref.watch(themeProvider); + final locale = ref.watch(localePreferencesProvider); + final themeMode = ref.watch(themePreferencesProvider); + final theme = AppTheme(themeMode, locale.preferredFontFamily); ref.watch(commonControllersProvider); @@ -29,11 +33,11 @@ class AppView extends HookConsumerWidget with PresLogger { return MaterialApp.router( routerConfig: router, - locale: locale, + locale: locale.flutterLocale, supportedLocales: AppLocaleUtils.supportedLocales, localizationsDelegates: GlobalMaterialLocalizations.delegates, debugShowCheckedModeBanner: false, - themeMode: theme.mode.flutterThemeMode, + themeMode: themeMode.flutterThemeMode, theme: theme.light(), darkTheme: theme.dark(), title: Constants.appName, diff --git a/lib/features/app_update/data/app_update_data_providers.dart b/lib/features/app_update/data/app_update_data_providers.dart new file mode 100644 index 00000000..834c4c10 --- /dev/null +++ b/lib/features/app_update/data/app_update_data_providers.dart @@ -0,0 +1,12 @@ +import 'package:hiddify/core/http_client/http_client_provider.dart'; +import 'package:hiddify/features/app_update/data/app_update_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'app_update_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +AppUpdateRepository appUpdateRepository( + AppUpdateRepositoryRef ref, +) { + return AppUpdateRepositoryImpl(dio: ref.watch(httpClientProvider)); +} diff --git a/lib/features/app_update/data/app_update_repository.dart b/lib/features/app_update/data/app_update_repository.dart new file mode 100644 index 00000000..6242b271 --- /dev/null +++ b/lib/features/app_update/data/app_update_repository.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/model/constants.dart'; +import 'package:hiddify/core/model/environment.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/app_update/data/github_release_parser.dart'; +import 'package:hiddify/features/app_update/model/app_update_failure.dart'; +import 'package:hiddify/features/app_update/model/remote_version_entity.dart'; +import 'package:hiddify/utils/utils.dart'; + +abstract interface class AppUpdateRepository { + TaskEither getLatestVersion({ + bool includePreReleases = false, + Release release = Release.general, + }); +} + +class AppUpdateRepositoryImpl + with ExceptionHandler, InfraLogger + implements AppUpdateRepository { + AppUpdateRepositoryImpl({required this.dio}); + + final Dio dio; + + @override + TaskEither getLatestVersion({ + bool includePreReleases = false, + Release release = Release.general, + }) { + return exceptionHandler( + () async { + if (!release.allowCustomUpdateChecker) { + throw Exception("custom update checkers are not supported"); + } + final response = await dio.get(Constants.githubReleasesApiUrl); + if (response.statusCode != 200 || response.data == null) { + loggy.warning("failed to fetch latest version info"); + return left(const AppUpdateFailure.unexpected()); + } + + final releases = response.data!.map( + (e) => GithubReleaseParser.parse(e as Map), + ); + late RemoteVersionEntity latest; + if (includePreReleases) { + latest = releases.first; + } else { + latest = releases.firstWhere((e) => e.preRelease == false); + } + return right(latest); + }, + AppUpdateFailure.unexpected, + ); + } +} diff --git a/lib/features/app_update/data/github_release_parser.dart b/lib/features/app_update/data/github_release_parser.dart new file mode 100644 index 00000000..2dd07d9d --- /dev/null +++ b/lib/features/app_update/data/github_release_parser.dart @@ -0,0 +1,36 @@ +import 'package:dartx/dartx.dart'; +import 'package:hiddify/core/model/environment.dart'; +import 'package:hiddify/features/app_update/model/remote_version_entity.dart'; + +abstract class GithubReleaseParser { + static RemoteVersionEntity parse(Map json) { + final fullTag = json['tag_name'] as String; + final fullVersion = fullTag.removePrefix("v").split("-").first.split("+"); + var version = fullVersion.first; + var buildNumber = fullVersion.elementAtOrElse(1, (index) => ""); + var flavor = Environment.prod; + for (final env in Environment.values) { + final suffix = ".${env.name}"; + if (version.endsWith(suffix)) { + version = version.removeSuffix(suffix); + flavor = env; + break; + } else if (buildNumber.endsWith(suffix)) { + buildNumber = buildNumber.removeSuffix(suffix); + flavor = env; + break; + } + } + final preRelease = json["prerelease"] as bool; + final publishedAt = DateTime.parse(json["published_at"] as String); + return RemoteVersionEntity( + version: version, + buildNumber: buildNumber, + releaseTag: fullTag, + preRelease: preRelease, + url: json["html_url"] as String, + publishedAt: publishedAt, + flavor: flavor, + ); + } +} diff --git a/lib/features/app_update/model/app_update_failure.dart b/lib/features/app_update/model/app_update_failure.dart new file mode 100644 index 00000000..f83f3382 --- /dev/null +++ b/lib/features/app_update/model/app_update_failure.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; + +part 'app_update_failure.freezed.dart'; + +@freezed +sealed class AppUpdateFailure with _$AppUpdateFailure, Failure { + const AppUpdateFailure._(); + + @With() + const factory AppUpdateFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = AppUpdateUnexpectedFailure; + + @override + ({String type, String? message}) present(TranslationsEn t) { + return switch (this) { + AppUpdateUnexpectedFailure() => ( + type: t.failure.unexpected, + message: null, + ), + }; + } +} diff --git a/lib/features/app_update/model/remote_version_entity.dart b/lib/features/app_update/model/remote_version_entity.dart new file mode 100644 index 00000000..c57434d1 --- /dev/null +++ b/lib/features/app_update/model/remote_version_entity.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/model/environment.dart'; + +part 'remote_version_entity.freezed.dart'; + +@Freezed() +class RemoteVersionEntity with _$RemoteVersionEntity { + const RemoteVersionEntity._(); + + const factory RemoteVersionEntity({ + required String version, + required String buildNumber, + required String releaseTag, + required bool preRelease, + required String url, + required DateTime publishedAt, + required Environment flavor, + }) = _RemoteVersionEntity; + + String get presentVersion => + flavor == Environment.prod ? version : "$version ${flavor.name}"; +} diff --git a/lib/features/app_update/notifier/app_update_notifier.dart b/lib/features/app_update/notifier/app_update_notifier.dart new file mode 100644 index 00000000..357b95bb --- /dev/null +++ b/lib/features/app_update/notifier/app_update_notifier.dart @@ -0,0 +1,87 @@ +import 'package:flutter/foundation.dart'; +import 'package:hiddify/core/app_info/app_info_provider.dart'; +import 'package:hiddify/core/localization/locale_preferences.dart'; +import 'package:hiddify/core/model/constants.dart'; +import 'package:hiddify/core/preferences/preferences_provider.dart'; +import 'package:hiddify/features/app_update/data/app_update_data_providers.dart'; +import 'package:hiddify/features/app_update/model/app_update_failure.dart'; +import 'package:hiddify/features/app_update/model/remote_version_entity.dart'; +import 'package:hiddify/features/app_update/notifier/app_update_state.dart'; +import 'package:hiddify/utils/pref_notifier.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:upgrader/upgrader.dart'; +import 'package:version/version.dart'; + +part 'app_update_notifier.g.dart'; + +const _debugUpgrader = true; + +@riverpod +Upgrader upgrader(UpgraderRef ref) => Upgrader( + appcastConfig: AppcastConfiguration(url: Constants.appCastUrl), + debugLogging: _debugUpgrader && kDebugMode, + durationUntilAlertAgain: const Duration(hours: 12), + messages: UpgraderMessages( + code: ref.watch(localePreferencesProvider).languageCode, + ), + ); + +@Riverpod(keepAlive: true) +class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger { + @override + AppUpdateState build() => const AppUpdateState.initial(); + + Pref get _ignoreReleasePref => Pref( + ref.read(sharedPreferencesProvider).requireValue, + 'ignored_release_version', + null, + ); + + Future check() async { + loggy.debug("checking for update"); + state = const AppUpdateState.checking(); + final appInfo = ref.watch(appInfoProvider).requireValue; + if (!appInfo.release.allowCustomUpdateChecker) { + loggy.debug( + "custom update checkers are not allowed for [${appInfo.release.name}] release", + ); + return state = const AppUpdateState.disabled(); + } + return ref.watch(appUpdateRepositoryProvider).getLatestVersion().match( + (err) { + loggy.warning("failed to get latest version", err); + return state = AppUpdateState.error(err); + }, + (remote) { + try { + final latestVersion = Version.parse(remote.version); + final currentVersion = Version.parse(appInfo.version); + if (latestVersion > currentVersion) { + if (remote.version == _ignoreReleasePref.getValue()) { + loggy.debug("ignored release [${remote.version}]"); + return state = AppUpdateStateIgnored(remote); + } + loggy.debug("new version available: $remote"); + return state = AppUpdateState.available(remote); + } + loggy.info( + "already using latest version[$currentVersion], remote: [${remote.version}]", + ); + return state = const AppUpdateState.notAvailable(); + } catch (error, stackTrace) { + loggy.warning("error parsing versions", error, stackTrace); + return state = AppUpdateState.error( + AppUpdateFailure.unexpected(error, stackTrace), + ); + } + }, + ).run(); + } + + Future ignoreRelease(RemoteVersionEntity version) async { + loggy.debug("ignoring release [${version.version}]"); + await _ignoreReleasePref.update(version.version); + state = AppUpdateStateIgnored(version); + } +} diff --git a/lib/features/app_update/notifier/app_update_state.dart b/lib/features/app_update/notifier/app_update_state.dart new file mode 100644 index 00000000..fe00d9ec --- /dev/null +++ b/lib/features/app_update/notifier/app_update_state.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/features/app_update/model/app_update_failure.dart'; +import 'package:hiddify/features/app_update/model/remote_version_entity.dart'; + +part 'app_update_state.freezed.dart'; + +@freezed +class AppUpdateState with _$AppUpdateState { + const factory AppUpdateState.initial() = AppUpdateStateInitial; + const factory AppUpdateState.disabled() = AppUpdateStateDisabled; + const factory AppUpdateState.checking() = AppUpdateStateChecking; + const factory AppUpdateState.error(AppUpdateFailure error) = + AppUpdateStateError; + const factory AppUpdateState.available(RemoteVersionEntity versionInfo) = + AppUpdateStateAvailable; + const factory AppUpdateState.ignored(RemoteVersionEntity versionInfo) = + AppUpdateStateIgnored; + const factory AppUpdateState.notAvailable() = AppUpdateStateNotAvailable; +} diff --git a/lib/features/common/new_version_dialog.dart b/lib/features/app_update/widget/new_version_dialog.dart similarity index 91% rename from lib/features/common/new_version_dialog.dart rename to lib/features/app_update/widget/new_version_dialog.dart index 197b32c4..e636fd8c 100644 --- a/lib/features/common/new_version_dialog.dart +++ b/lib/features/app_update/widget/new_version_dialog.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/domain/app/app.dart'; -import 'package:hiddify/features/common/app_update_notifier.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/features/app_update/model/remote_version_entity.dart'; +import 'package:hiddify/features/app_update/notifier/app_update_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -// TODO add release notes class NewVersionDialog extends HookConsumerWidget with PresLogger { NewVersionDialog( this.currentVersion, @@ -16,7 +15,7 @@ class NewVersionDialog extends HookConsumerWidget with PresLogger { }) : super(key: _dialogKey); final String currentVersion; - final RemoteVersionInfo newVersion; + final RemoteVersionEntity newVersion; final bool canIgnore; static final _dialogKey = GlobalKey(debugLabel: 'new version dialog'); diff --git a/lib/features/common/active_profile/active_profile_notifier.dart b/lib/features/common/active_profile/active_profile_notifier.dart deleted file mode 100644 index af11965d..00000000 --- a/lib/features/common/active_profile/active_profile_notifier.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'active_profile_notifier.g.dart'; - -@Riverpod(keepAlive: true) -class ActiveProfile extends _$ActiveProfile with AppLogger { - @override - Stream build() { - loggy.debug("watching active profile"); - return ref - .watch(profilesRepositoryProvider) - .watchActiveProfile() - .map((event) => event.getOrElse((l) => throw l)); - } -} diff --git a/lib/features/common/active_profile/has_any_profile_notifier.dart b/lib/features/common/active_profile/has_any_profile_notifier.dart deleted file mode 100644 index 8ac28b21..00000000 --- a/lib/features/common/active_profile/has_any_profile_notifier.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:hiddify/data/data_providers.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'has_any_profile_notifier.g.dart'; - -@Riverpod(keepAlive: true) -Stream hasAnyProfile( - HasAnyProfileRef ref, -) { - return ref - .watch(profilesRepositoryProvider) - .watchHasAnyProfile() - .map((event) => event.getOrElse((l) => throw l)); -} diff --git a/lib/features/common/adaptive_root_scaffold.dart b/lib/features/common/adaptive_root_scaffold.dart index 9b3936b5..91d15336 100644 --- a/lib/features/common/adaptive_root_scaffold.dart +++ b/lib/features/common/adaptive_root_scaffold.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; -import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/features/common/side_bar_stats_overview.dart'; +import 'package:hiddify/features/stats/widget/side_bar_stats_overview.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; abstract interface class RootScaffold { @@ -148,23 +148,6 @@ class _CustomAdaptiveScaffold extends HookConsumerWidget { ), }, ), - bottomNavigation: useBottomSheet || - Breakpoints.smallMobile.isActive(context) - ? SlotLayout( - config: { - Breakpoints.small: SlotLayout.from( - key: const Key('bottomNavigation'), - builder: (_) => - AdaptiveScaffold.standardBottomNavigationBar( - currentIndex: selectedWithOffset(bottomDestinationRange), - destinations: destinationsSlice(bottomDestinationRange), - onDestinationSelected: (index) => - selectWithOffset(index, bottomDestinationRange), - ), - ), - }, - ) - : null, body: SlotLayout( config: { Breakpoints.standard: SlotLayout.from( @@ -176,6 +159,15 @@ class _CustomAdaptiveScaffold extends HookConsumerWidget { }, ), ), + // AdaptiveLayout bottom sheet has accessibility issues + bottomNavigationBar: useBottomSheet && Breakpoints.small.isActive(context) + ? NavigationBar( + selectedIndex: selectedWithOffset(bottomDestinationRange) ?? 0, + destinations: destinationsSlice(bottomDestinationRange), + onDestinationSelected: (index) => + selectWithOffset(index, bottomDestinationRange), + ) + : null, ); } } diff --git a/lib/features/common/app_update_notifier.dart b/lib/features/common/app_update_notifier.dart deleted file mode 100644 index 669c718a..00000000 --- a/lib/features/common/app_update_notifier.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/app/app.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/utils/pref_notifier.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:upgrader/upgrader.dart'; -import 'package:version/version.dart'; - -part 'app_update_notifier.freezed.dart'; -part 'app_update_notifier.g.dart'; - -const _debugUpgrader = true; - -@riverpod -Upgrader upgrader(UpgraderRef ref) => Upgrader( - appcastConfig: AppcastConfiguration(url: Constants.appCastUrl), - debugLogging: _debugUpgrader && kDebugMode, - durationUntilAlertAgain: const Duration(hours: 12), - messages: UpgraderMessages( - code: ref.watch(localeNotifierProvider).languageCode, - ), - ); - -@freezed -class AppUpdateState with _$AppUpdateState { - const factory AppUpdateState.initial() = AppUpdateStateInitial; - const factory AppUpdateState.disabled() = AppUpdateStateDisabled; - const factory AppUpdateState.checking() = AppUpdateStateChecking; - const factory AppUpdateState.error(AppFailure error) = AppUpdateStateError; - const factory AppUpdateState.available(RemoteVersionInfo versionInfo) = - AppUpdateStateAvailable; - const factory AppUpdateState.ignored(RemoteVersionInfo versionInfo) = - AppUpdateStateIgnored; - const factory AppUpdateState.notAvailable() = AppUpdateStateNotAvailable; -} - -@Riverpod(keepAlive: true) -class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger { - @override - AppUpdateState build() { - // _schedule(); - return const AppUpdateState.initial(); - } - - Pref get _ignoreReleasePref => Pref( - ref.read(sharedPreferencesProvider), - 'ignored_release_version', - null, - ); - - Future check() async { - loggy.debug("checking for update"); - state = const AppUpdateState.checking(); - final appInfo = ref.watch(appInfoProvider); - // TODO use market-specific update checkers - if (!appInfo.release.allowCustomUpdateChecker) { - loggy.debug( - "custom update checkers are not allowed for [${appInfo.release.name}] release", - ); - return state = const AppUpdateState.disabled(); - } - return ref.watch(appRepositoryProvider).getLatestVersion().match( - (err) { - loggy.warning("failed to get latest version", err); - return state = AppUpdateState.error(err); - }, - (remote) { - try { - final latestVersion = Version.parse(remote.version); - final currentVersion = Version.parse(appInfo.version); - if (latestVersion > currentVersion) { - if (remote.version == _ignoreReleasePref.getValue()) { - loggy.debug("ignored release [${remote.version}]"); - return state = AppUpdateStateIgnored(remote); - } - loggy.debug("new version available: $remote"); - return state = AppUpdateState.available(remote); - } - loggy.info( - "already using latest version[$currentVersion], remote: [${remote.version}]", - ); - return state = const AppUpdateState.notAvailable(); - } catch (error, stackTrace) { - loggy.warning("error parsing versions", error, stackTrace); - return state = AppUpdateState.error( - AppFailure.unexpected(error, stackTrace), - ); - } - }, - ).run(); - } - - Future ignoreRelease(RemoteVersionInfo versionInfo) async { - loggy.debug("ignoring release [${versionInfo.version}]"); - await _ignoreReleasePref.update(versionInfo.version); - state = AppUpdateStateIgnored(versionInfo); - } - - // Future _schedule() async { - // loggy.debug("scheduling app update checker"); - // return ref.read(cronServiceProvider).schedule( - // key: 'app_update', - // duration: const Duration(hours: 8), - // callback: () async { - // await Future.delayed(const Duration(seconds: 5)); - // final updateState = await check(); - // final context = rootNavigatorKey.currentContext; - // if (context != null && context.mounted) { - // if (updateState - // case AppUpdateStateAvailable(:final versionInfo)) { - // await NewVersionDialog( - // ref.read(appInfoProvider).presentVersion, - // versionInfo, - // ).show(context); - // } - // } - // }, - // ); - // } -} diff --git a/lib/features/common/common_controllers.dart b/lib/features/common/common_controllers.dart index 3854b879..b0e63882 100644 --- a/lib/features/common/common_controllers.dart +++ b/lib/features/common/common_controllers.dart @@ -1,9 +1,8 @@ -import 'package:hiddify/core/prefs/general_prefs.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hiddify/features/common/window/window_controller.dart'; -import 'package:hiddify/features/profiles/notifier/notifier.dart'; +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; +import 'package:hiddify/features/profile/notifier/profiles_update_notifier.dart'; import 'package:hiddify/features/system_tray/system_tray_controller.dart'; -import 'package:hiddify/services/service_providers.dart'; import 'package:hiddify/utils/platform_utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -17,18 +16,13 @@ void commonControllers(CommonControllersRef ref) { introCompletedProvider, (_, completed) async { if (completed) { - await ref.read(cronServiceProvider).startScheduler(); + await ref.read(foregroundProfilesUpdateNotifierProvider.future); } }, fireImmediately: true, ); ref.listen( - connectivityControllerProvider, - (previous, next) {}, - fireImmediately: true, - ); - ref.listen( - profilesUpdateNotifierProvider, + connectionNotifierProvider, (previous, next) {}, fireImmediately: true, ); diff --git a/lib/features/common/general_pref_tiles.dart b/lib/features/common/general_pref_tiles.dart index 7114bafd..1ac1c373 100644 --- a/lib/features/common/general_pref_tiles.dart +++ b/lib/features/common/general_pref_tiles.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/core/localization/locale_extensions.dart'; +import 'package:hiddify/core/localization/locale_preferences.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/region.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class LocalePrefTile extends HookConsumerWidget { @@ -13,14 +14,11 @@ class LocalePrefTile extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final locale = ref.watch(localeNotifierProvider); + final locale = ref.watch(localePreferencesProvider); return ListTile( title: Text(t.settings.general.locale), - subtitle: Text( - LocaleNamesLocalizationsDelegate.nativeLocaleNames[locale.name] ?? - locale.name, - ), + subtitle: Text(locale.localeName), leading: const Icon(Icons.language), onTap: () async { final selectedLocale = await showDialog( @@ -31,11 +29,7 @@ class LocalePrefTile extends HookConsumerWidget { children: AppLocale.values .map( (e) => RadioListTile( - title: Text( - LocaleNamesLocalizationsDelegate - .nativeLocaleNames[e.name] ?? - e.name, - ), + title: Text(e.localeName), value: e, groupValue: locale, onChanged: (e) => context.pop(e), @@ -47,8 +41,8 @@ class LocalePrefTile extends HookConsumerWidget { ); if (selectedLocale != null) { await ref - .read(localeNotifierProvider.notifier) - .update(selectedLocale); + .read(localePreferencesProvider.notifier) + .changeLocale(selectedLocale); } }, ); diff --git a/lib/features/common/nested_app_bar.dart b/lib/features/common/nested_app_bar.dart index 0d872532..e9281f03 100644 --- a/lib/features/common/nested_app_bar.dart +++ b/lib/features/common/nested_app_bar.dart @@ -7,7 +7,7 @@ bool showDrawerButton(BuildContext context) { if (!useMobileRouter) return true; final String location = GoRouterState.of(context).uri.path; if (location == const HomeRoute().location || - location == const ProfilesRoute().location) return true; + location == const ProfilesOverviewRoute().location) return true; if (location.startsWith(const ProxiesRoute().location)) return true; return false; } diff --git a/lib/features/common/qr_code_dialog.dart b/lib/features/common/qr_code_dialog.dart new file mode 100644 index 00000000..cc6da442 --- /dev/null +++ b/lib/features/common/qr_code_dialog.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +class QrCodeDialog extends StatelessWidget { + const QrCodeDialog( + this.data, { + super.key, + this.message, + this.width = 268, + this.backgroundColor = Colors.white, + }); + + final String data; + final String? message; + final double width; + final Color backgroundColor; + + Future show(BuildContext context) async { + await showDialog(context: context, builder: (context) => this); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: width, + child: QrImageView( + data: data, + backgroundColor: backgroundColor, + ), + ), + if (message != null) + SizedBox( + width: width, + child: Material( + color: theme.colorScheme.background, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + message!, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: theme.colorScheme.onBackground), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/common/qr_code_scanner_screen.dart b/lib/features/common/qr_code_scanner_screen.dart index d52175ca..152df8a0 100644 --- a/lib/features/common/qr_code_scanner_screen.dart +++ b/lib/features/common/qr_code_scanner_screen.dart @@ -1,5 +1,7 @@ +import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -18,15 +20,17 @@ class QRCodeScannerScreen extends HookConsumerWidget with PresLogger { @override Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + final controller = useMemoized( - () => MobileScannerController( - detectionTimeoutMs: 500, - formats: [BarcodeFormat.qrCode], - ), + () => MobileScannerController(detectionTimeoutMs: 500), ); useEffect(() => controller.dispose, []); + final size = MediaQuery.sizeOf(context); + final overlaySize = (size.shortestSide - 12).coerceAtMost(248); + return Scaffold( extendBodyBehindAppBar: true, appBar: AppBar( @@ -48,6 +52,7 @@ class QRCodeScannerScreen extends HookConsumerWidget with PresLogger { } }, ), + tooltip: t.profile.add.qrScanner.torchSemanticLabel, onPressed: () => controller.toggleTorch(), ), IconButton( @@ -62,21 +67,110 @@ class QRCodeScannerScreen extends HookConsumerWidget with PresLogger { } }, ), + tooltip: t.profile.add.qrScanner.facingSemanticLabel, onPressed: () => controller.switchCamera(), ), ], ), - body: MobileScanner( - controller: controller, - onDetect: (capture) { - final data = capture.barcodes.first; - if (context.mounted && data.type == BarcodeType.url) { - loggy.debug('captured raw: [${data.rawValue}]'); - loggy.debug('captured url: [${data.url?.url}]'); - Navigator.of(context, rootNavigator: true).pop(data.url?.url); - } - }, + body: Stack( + children: [ + MobileScanner( + controller: controller, + onDetect: (capture) { + final data = capture.barcodes.first; + if (context.mounted && data.type == BarcodeType.url) { + loggy.debug('captured raw: [${data.rawValue}]'); + loggy.debug('captured url: [${data.url?.url}]'); + Navigator.of(context, rootNavigator: true).pop(data.url?.url); + } + }, + errorBuilder: (_, error, __) { + final message = switch (error.errorCode) { + MobileScannerErrorCode.permissionDenied => + t.profile.add.qrScanner.permissionDeniedError, + _ => t.profile.add.qrScanner.unexpectedError, + }; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Icon(Icons.error, color: Colors.white), + ), + Text(message), + Text(error.errorDetails?.message ?? ''), + ], + ), + ); + }, + ), + CustomPaint( + painter: ScannerOverlay( + Rect.fromCenter( + center: size.center(Offset.zero), + width: overlaySize, + height: overlaySize, + ), + ), + ), + ], ), ); } } + +class ScannerOverlay extends CustomPainter { + ScannerOverlay(this.scanWindow); + + final Rect scanWindow; + final double borderRadius = 12.0; + + @override + void paint(Canvas canvas, Size size) { + final backgroundPath = Path()..addRect(Rect.largest); + final cutoutPath = Path() + ..addRRect( + RRect.fromRectAndCorners( + scanWindow, + topLeft: Radius.circular(borderRadius), + topRight: Radius.circular(borderRadius), + bottomLeft: Radius.circular(borderRadius), + bottomRight: Radius.circular(borderRadius), + ), + ); + + final backgroundPaint = Paint() + ..color = Colors.black.withOpacity(0.5) + ..style = PaintingStyle.fill + ..blendMode = BlendMode.dstOut; + + final backgroundWithCutout = Path.combine( + PathOperation.difference, + backgroundPath, + cutoutPath, + ); + + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0; + + final borderRect = RRect.fromRectAndCorners( + scanWindow, + topLeft: Radius.circular(borderRadius), + topRight: Radius.circular(borderRadius), + bottomLeft: Radius.circular(borderRadius), + bottomRight: Radius.circular(borderRadius), + ); + + canvas.drawPath(backgroundWithCutout, backgroundPaint); + canvas.drawRRect(borderRect, borderPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/features/common/stats_provider.dart b/lib/features/common/stats_provider.dart deleted file mode 100644 index 5ddbd43b..00000000 --- a/lib/features/common/stats_provider.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'stats_provider.g.dart'; - -@riverpod -class Stats extends _$Stats with AppLogger { - @override - Stream build() async* { - final serviceRunning = await ref.watch(serviceRunningProvider.future); - if (serviceRunning) { - yield* ref - .watch(coreFacadeProvider) - .watchCoreStatus() - .map((event) => event.getOrElse((_) => CoreStatus.empty())); - } else { - yield* Stream.value(CoreStatus.empty()); - } - } -} diff --git a/lib/features/common/window/window_controller.dart b/lib/features/common/window/window_controller.dart index edd375b6..bc8884dd 100644 --- a/lib/features/common/window/window_controller.dart +++ b/lib/features/common/window/window_controller.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/core/prefs/service_prefs.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; +import 'package:hiddify/core/preferences/service_preferences.dart'; +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:window_manager/window_manager.dart'; @@ -16,10 +16,10 @@ class WindowController extends _$WindowController Future build() async { await windowManager.ensureInitialized(); const size = Size(868, 668); - const minumumSize = Size(368, 568); + const minimumSize = Size(368, 568); const windowOptions = WindowOptions( size: size, - minimumSize: minumumSize, + minimumSize: minimumSize, center: true, ); await windowManager.setPreventClose(true); @@ -31,13 +31,11 @@ class WindowController extends _$WindowController await windowManager.hide(); } await Future.delayed( - const Duration(seconds: 1), + const Duration(seconds: 3), () async { if (ref.read(startedByUserProvider)) { loggy.debug("previously started by user, trying to connect"); - return ref - .read(connectivityControllerProvider.notifier) - .mayConnect(); + return ref.read(connectionNotifierProvider.notifier).mayConnect(); } }, ); diff --git a/lib/features/config_option/data/config_option_data_providers.dart b/lib/features/config_option/data/config_option_data_providers.dart new file mode 100644 index 00000000..c9b5f9b4 --- /dev/null +++ b/lib/features/config_option/data/config_option_data_providers.dart @@ -0,0 +1,17 @@ +import 'package:hiddify/core/preferences/preferences_provider.dart'; +import 'package:hiddify/features/config_option/data/config_option_repository.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'config_option_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +ConfigOptionRepository configOptionRepository( + ConfigOptionRepositoryRef ref, +) { + return ConfigOptionRepositoryImpl( + preferences: ref.watch(sharedPreferencesProvider).requireValue, + geoAssetRepository: ref.watch(geoAssetRepositoryProvider).requireValue, + geoAssetPathResolver: ref.watch(geoAssetPathResolverProvider), + ); +} diff --git a/lib/features/config_option/data/config_option_repository.dart b/lib/features/config_option/data/config_option_repository.dart new file mode 100644 index 00000000..f302b1ce --- /dev/null +++ b/lib/features/config_option/data/config_option_repository.dart @@ -0,0 +1,172 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/model/region.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/config_option/model/config_option_entity.dart'; +import 'package:hiddify/features/config_option/model/config_option_failure.dart'; +import 'package:hiddify/features/config_option/model/config_option_patch.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_repository.dart'; +import 'package:hiddify/singbox/model/singbox_config_enum.dart'; +import 'package:hiddify/singbox/model/singbox_config_option.dart'; +import 'package:hiddify/singbox/model/singbox_rule.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract interface class ConfigOptionRepository { + TaskEither + getFullSingboxConfigOption(); + TaskEither getConfigOption(); + TaskEither updateConfigOption( + ConfigOptionPatch patch, + ); +} + +class ConfigOptionRepositoryImpl + with ExceptionHandler, InfraLogger + implements ConfigOptionRepository { + ConfigOptionRepositoryImpl({ + required this.preferences, + required this.geoAssetRepository, + required this.geoAssetPathResolver, + }); + + final SharedPreferences preferences; + final GeoAssetRepository geoAssetRepository; + final GeoAssetPathResolver geoAssetPathResolver; + + @override + TaskEither + getFullSingboxConfigOption() { + return exceptionHandler( + () async { + final region = + Region.values.byName(preferences.getString("region") ?? "other"); + final rules = switch (region) { + Region.ir => [ + const SingboxRule( + domains: "domain:.ir,geosite:ir", + ip: "geoip:ir", + outbound: RuleOutbound.bypass, + ), + ], + Region.cn => [ + const SingboxRule( + domains: "domain:.cn,geosite:cn", + ip: "geoip:cn", + outbound: RuleOutbound.bypass, + ), + ], + Region.ru => [ + const SingboxRule( + domains: "domain:.ru", + ip: "geoip:ru", + outbound: RuleOutbound.bypass, + ), + ], + _ => [], + }; + + final geoAssets = await geoAssetRepository + .getActivePair() + .getOrElse((l) => throw l) + .run(); + + final persisted = + await getConfigOption().getOrElse((l) => throw l).run(); + final singboxConfigOption = SingboxConfigOption( + executeConfigAsIs: false, + logLevel: persisted.logLevel, + resolveDestination: persisted.resolveDestination, + ipv6Mode: persisted.ipv6Mode, + remoteDnsAddress: persisted.remoteDnsAddress, + remoteDnsDomainStrategy: persisted.remoteDnsDomainStrategy, + directDnsAddress: persisted.directDnsAddress, + directDnsDomainStrategy: persisted.directDnsDomainStrategy, + mixedPort: persisted.mixedPort, + localDnsPort: persisted.localDnsPort, + tunImplementation: persisted.tunImplementation, + mtu: persisted.mtu, + strictRoute: persisted.strictRoute, + connectionTestUrl: persisted.connectionTestUrl, + urlTestInterval: persisted.urlTestInterval, + enableClashApi: persisted.enableClashApi, + clashApiPort: persisted.clashApiPort, + enableTun: persisted.serviceMode == ServiceMode.tun, + setSystemProxy: persisted.serviceMode == ServiceMode.systemProxy, + bypassLan: persisted.bypassLan, + enableFakeDns: persisted.enableFakeDns, + independentDnsCache: persisted.independentDnsCache, + geoipPath: geoAssetPathResolver.relativePath( + geoAssets.geoip.providerName, + geoAssets.geoip.fileName, + ), + geositePath: geoAssetPathResolver.relativePath( + geoAssets.geosite.providerName, + geoAssets.geosite.fileName, + ), + rules: rules, + ); + return right(singboxConfigOption); + }, + ConfigOptionUnexpectedFailure.new, + ); + } + + @override + TaskEither getConfigOption() { + return exceptionHandler( + () async { + final map = ConfigOptionEntity.initial.toJson(); + for (final key in map.keys) { + final persisted = preferences.get(key); + if (persisted != null) { + final defaultValue = map[key]; + if (defaultValue != null && + persisted.runtimeType != defaultValue.runtimeType) { + loggy.warning( + "error getting preference[$key], expected type: [${defaultValue.runtimeType}] - received value: [$persisted](${persisted.runtimeType})", + ); + continue; + } + map[key] = persisted; + } + } + final options = ConfigOptionEntity.fromJson(map); + return right(options); + }, + ConfigOptionUnexpectedFailure.new, + ); + } + + @override + TaskEither updateConfigOption( + ConfigOptionPatch patch, + ) { + return exceptionHandler( + () async { + final map = patch.toJson(); + for (final key in map.keys) { + final value = map[key]; + if (value != null) { + loggy.debug("updating [$key] to [$value]"); + + switch (value) { + case bool _: + await preferences.setBool(key, value); + case String _: + await preferences.setString(key, value); + case int _: + await preferences.setInt(key, value); + case double _: + await preferences.setDouble(key, value); + default: + loggy.warning("unexpected type"); + } + } + } + return right(unit); + }, + ConfigOptionUnexpectedFailure.new, + ); + } +} diff --git a/lib/features/config_option/model/config_option_entity.dart b/lib/features/config_option/model/config_option_entity.dart new file mode 100644 index 00000000..9e95f62e --- /dev/null +++ b/lib/features/config_option/model/config_option_entity.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/utils/json_converters.dart'; +import 'package:hiddify/features/config_option/model/config_option_patch.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; +import 'package:hiddify/singbox/model/singbox_config_enum.dart'; + +part 'config_option_entity.freezed.dart'; +part 'config_option_entity.g.dart'; + +@freezed +class ConfigOptionEntity with _$ConfigOptionEntity { + const ConfigOptionEntity._(); + + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory ConfigOptionEntity({ + required ServiceMode serviceMode, + @Default(LogLevel.warn) LogLevel logLevel, + @Default(false) bool resolveDestination, + @Default(IPv6Mode.disable) IPv6Mode ipv6Mode, + @Default("tcp://8.8.8.8") String remoteDnsAddress, + @Default(DomainStrategy.auto) DomainStrategy remoteDnsDomainStrategy, + @Default("8.8.8.8") String directDnsAddress, + @Default(DomainStrategy.auto) DomainStrategy directDnsDomainStrategy, + @Default(2334) int mixedPort, + @Default(6450) int localDnsPort, + @Default(TunImplementation.mixed) TunImplementation tunImplementation, + @Default(9000) int mtu, + @Default(true) bool strictRoute, + @Default("http://cp.cloudflare.com/") String connectionTestUrl, + @IntervalInSecondsConverter() + @Default(Duration(minutes: 10)) + Duration urlTestInterval, + @Default(true) bool enableClashApi, + @Default(6756) int clashApiPort, + @Default(false) bool bypassLan, + @Default(false) bool enableFakeDns, + @Default(true) bool independentDnsCache, + }) = _ConfigOptionEntity; + + static ConfigOptionEntity initial = ConfigOptionEntity( + serviceMode: ServiceMode.defaultMode, + ); + + String format() { + const encoder = JsonEncoder.withIndent(' '); + return encoder.convert(toJson()); + } + + ConfigOptionEntity patch(ConfigOptionPatch patch) { + return copyWith( + serviceMode: patch.serviceMode ?? serviceMode, + logLevel: patch.logLevel ?? logLevel, + resolveDestination: patch.resolveDestination ?? resolveDestination, + ipv6Mode: patch.ipv6Mode ?? ipv6Mode, + remoteDnsAddress: patch.remoteDnsAddress ?? remoteDnsAddress, + remoteDnsDomainStrategy: + patch.remoteDnsDomainStrategy ?? remoteDnsDomainStrategy, + directDnsAddress: patch.directDnsAddress ?? directDnsAddress, + directDnsDomainStrategy: + patch.directDnsDomainStrategy ?? directDnsDomainStrategy, + mixedPort: patch.mixedPort ?? mixedPort, + localDnsPort: patch.localDnsPort ?? localDnsPort, + tunImplementation: patch.tunImplementation ?? tunImplementation, + mtu: patch.mtu ?? mtu, + strictRoute: patch.strictRoute ?? strictRoute, + connectionTestUrl: patch.connectionTestUrl ?? connectionTestUrl, + urlTestInterval: patch.urlTestInterval ?? urlTestInterval, + enableClashApi: patch.enableClashApi ?? enableClashApi, + clashApiPort: patch.clashApiPort ?? clashApiPort, + bypassLan: patch.bypassLan ?? bypassLan, + enableFakeDns: patch.enableFakeDns ?? enableFakeDns, + independentDnsCache: patch.independentDnsCache ?? independentDnsCache, + ); + } + + factory ConfigOptionEntity.fromJson(Map json) => + _$ConfigOptionEntityFromJson(json); +} diff --git a/lib/features/config_option/model/config_option_failure.dart b/lib/features/config_option/model/config_option_failure.dart new file mode 100644 index 00000000..bc5c9ab5 --- /dev/null +++ b/lib/features/config_option/model/config_option_failure.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; + +part 'config_option_failure.freezed.dart'; + +@freezed +sealed class ConfigOptionFailure with _$ConfigOptionFailure, Failure { + const ConfigOptionFailure._(); + + @With() + const factory ConfigOptionFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = ConfigOptionUnexpectedFailure; + + @override + ({String type, String? message}) present(TranslationsEn t) { + return switch (this) { + ConfigOptionUnexpectedFailure() => ( + type: t.failure.unexpected, + message: null, + ), + }; + } +} diff --git a/lib/features/config_option/model/config_option_patch.dart b/lib/features/config_option/model/config_option_patch.dart new file mode 100644 index 00000000..b19c1dc8 --- /dev/null +++ b/lib/features/config_option/model/config_option_patch.dart @@ -0,0 +1,39 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/utils/json_converters.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; +import 'package:hiddify/singbox/model/singbox_config_enum.dart'; + +part 'config_option_patch.freezed.dart'; +part 'config_option_patch.g.dart'; + +@freezed +class ConfigOptionPatch with _$ConfigOptionPatch { + const ConfigOptionPatch._(); + + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory ConfigOptionPatch({ + ServiceMode? serviceMode, + LogLevel? logLevel, + bool? resolveDestination, + IPv6Mode? ipv6Mode, + String? remoteDnsAddress, + DomainStrategy? remoteDnsDomainStrategy, + String? directDnsAddress, + DomainStrategy? directDnsDomainStrategy, + int? mixedPort, + int? localDnsPort, + TunImplementation? tunImplementation, + int? mtu, + bool? strictRoute, + String? connectionTestUrl, + @IntervalInSecondsConverter() Duration? urlTestInterval, + bool? enableClashApi, + int? clashApiPort, + bool? bypassLan, + bool? enableFakeDns, + bool? independentDnsCache, + }) = _ConfigOptionPatch; + + factory ConfigOptionPatch.fromJson(Map json) => + _$ConfigOptionPatchFromJson(json); +} diff --git a/lib/features/config_option/notifier/config_option_notifier.dart b/lib/features/config_option/notifier/config_option_notifier.dart new file mode 100644 index 00000000..fba59b9c --- /dev/null +++ b/lib/features/config_option/notifier/config_option_notifier.dart @@ -0,0 +1,31 @@ +import 'package:hiddify/features/config_option/data/config_option_data_providers.dart'; +import 'package:hiddify/features/config_option/model/config_option_entity.dart'; +import 'package:hiddify/features/config_option/model/config_option_patch.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'config_option_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class ConfigOptionNotifier extends _$ConfigOptionNotifier with AppLogger { + @override + Future build() { + return ref + .watch(configOptionRepositoryProvider) + .getConfigOption() + .getOrElse((l) { + loggy.error("error getting persisted options $l", l); + throw l; + }).run(); + } + + Future updateOption(ConfigOptionPatch patch) async { + if (state case AsyncData(value: final options)) { + await ref + .read(configOptionRepositoryProvider) + .updateConfigOption(patch) + .map((_) => state = AsyncData(options.patch(patch))) + .run(); + } + } +} diff --git a/lib/features/config_option/overview/config_options_page.dart b/lib/features/config_option/overview/config_options_page.dart new file mode 100644 index 00000000..db639f57 --- /dev/null +++ b/lib/features/config_option/overview/config_options_page.dart @@ -0,0 +1,323 @@ +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gap/gap.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/features/config_option/model/config_option_entity.dart'; +import 'package:hiddify/features/config_option/model/config_option_patch.dart'; +import 'package:hiddify/features/config_option/notifier/config_option_notifier.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; +import 'package:hiddify/features/settings/widgets/sections_widgets.dart'; +import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart'; +import 'package:hiddify/singbox/model/singbox_config_enum.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:humanizer/humanizer.dart'; + +class ConfigOptionsPage extends HookConsumerWidget { + const ConfigOptionsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + + final defaultOptions = ConfigOptionEntity.initial; + final asyncOptions = ref.watch(configOptionNotifierProvider); + + Future changeOption(ConfigOptionPatch patch) async { + await ref.read(configOptionNotifierProvider.notifier).updateOption(patch); + } + + return Scaffold( + appBar: AppBar( + title: Text(t.settings.config.pageTitle), + actions: [ + if (asyncOptions case AsyncData(value: final options)) + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + child: Text(t.general.addToClipboard), + onTap: () { + Clipboard.setData( + ClipboardData(text: options.format()), + ); + }, + ), + ]; + }, + ), + ], + ), + body: switch (asyncOptions) { + AsyncData(value: final options) => ListView( + children: [ + ListTile( + title: Text(t.settings.config.logLevel), + subtitle: Text(options.logLevel.name.toUpperCase()), + onTap: () async { + final logLevel = await SettingsPickerDialog( + title: t.settings.config.logLevel, + selected: options.logLevel, + options: LogLevel.choices, + getTitle: (e) => e.name.toUpperCase(), + resetValue: defaultOptions.logLevel, + ).show(context); + if (logLevel == null) return; + await changeOption(ConfigOptionPatch(logLevel: logLevel)); + }, + ), + const SettingsDivider(), + SettingsSection(t.settings.config.section.route), + // SwitchListTile( + // title: Text(t.settings.config.bypassLan), + // value: options.bypassLan, + // onChanged: ref.read(bypassLanStore.notifier).update, + // ), + SwitchListTile( + title: Text(t.settings.config.resolveDestination), + value: options.resolveDestination, + onChanged: (value) async => + changeOption(ConfigOptionPatch(resolveDestination: value)), + ), + ListTile( + title: Text(t.settings.config.ipv6Mode), + subtitle: Text(options.ipv6Mode.present(t)), + onTap: () async { + final ipv6Mode = await SettingsPickerDialog( + title: t.settings.config.ipv6Mode, + selected: options.ipv6Mode, + options: IPv6Mode.values, + getTitle: (e) => e.present(t), + resetValue: defaultOptions.ipv6Mode, + ).show(context); + if (ipv6Mode == null) return; + await changeOption(ConfigOptionPatch(ipv6Mode: ipv6Mode)); + }, + ), + const SettingsDivider(), + SettingsSection(t.settings.config.section.dns), + ListTile( + title: Text(t.settings.config.remoteDnsAddress), + subtitle: Text(options.remoteDnsAddress), + onTap: () async { + final url = await SettingsInputDialog( + title: t.settings.config.remoteDnsAddress, + initialValue: options.remoteDnsAddress, + resetValue: defaultOptions.remoteDnsAddress, + ).show(context); + if (url == null || url.isEmpty) return; + await changeOption(ConfigOptionPatch(remoteDnsAddress: url)); + }, + ), + ListTile( + title: Text(t.settings.config.remoteDnsDomainStrategy), + subtitle: Text(options.remoteDnsDomainStrategy.displayName), + onTap: () async { + final domainStrategy = await SettingsPickerDialog( + title: t.settings.config.remoteDnsDomainStrategy, + selected: options.remoteDnsDomainStrategy, + options: DomainStrategy.values, + getTitle: (e) => e.displayName, + resetValue: defaultOptions.remoteDnsDomainStrategy, + ).show(context); + if (domainStrategy == null) return; + await changeOption( + ConfigOptionPatch(remoteDnsDomainStrategy: domainStrategy), + ); + }, + ), + ListTile( + title: Text(t.settings.config.directDnsAddress), + subtitle: Text(options.directDnsAddress), + onTap: () async { + final url = await SettingsInputDialog( + title: t.settings.config.directDnsAddress, + initialValue: options.directDnsAddress, + resetValue: defaultOptions.directDnsAddress, + ).show(context); + if (url == null || url.isEmpty) return; + await changeOption(ConfigOptionPatch(directDnsAddress: url)); + }, + ), + ListTile( + title: Text(t.settings.config.directDnsDomainStrategy), + subtitle: Text(options.directDnsDomainStrategy.displayName), + onTap: () async { + final domainStrategy = await SettingsPickerDialog( + title: t.settings.config.directDnsDomainStrategy, + selected: options.directDnsDomainStrategy, + options: DomainStrategy.values, + getTitle: (e) => e.displayName, + resetValue: defaultOptions.directDnsDomainStrategy, + ).show(context); + if (domainStrategy == null) return; + await changeOption( + ConfigOptionPatch(directDnsDomainStrategy: domainStrategy), + ); + }, + ), + // SwitchListTile( + // title: Text(t.settings.config.enableFakeDns), + // value: options.enableFakeDns, + // onChanged: ref.read(enableFakeDnsStore.notifier).update, + // ), + const SettingsDivider(), + SettingsSection(t.settings.config.section.inbound), + // if (PlatformUtils.isDesktop) ...[ + // SwitchListTile( + // title: Text(t.settings.config.enableTun), + // value: options.enableTun, + // onChanged: ref.read(enableTunStore.notifier).update, + // ), + // SwitchListTile( + // title: Text(t.settings.config.setSystemProxy), + // value: options.setSystemProxy, + // onChanged: ref.read(setSystemProxyStore.notifier).update, + // ), + // ], + ListTile( + title: Text(t.settings.config.serviceMode), + subtitle: Text(options.serviceMode.present(t)), + onTap: () async { + final pickedMode = await SettingsPickerDialog( + title: t.settings.config.serviceMode, + selected: options.serviceMode, + options: ServiceMode.choices, + getTitle: (e) => e.present(t), + resetValue: ServiceMode.defaultMode, + ).show(context); + if (pickedMode == null) return; + await changeOption( + ConfigOptionPatch(serviceMode: pickedMode), + ); + }, + ), + SwitchListTile( + title: Text(t.settings.config.strictRoute), + value: options.strictRoute, + onChanged: (value) async => + changeOption(ConfigOptionPatch(strictRoute: value)), + ), + ListTile( + title: Text(t.settings.config.tunImplementation), + subtitle: Text(options.tunImplementation.name), + onTap: () async { + final tunImplementation = await SettingsPickerDialog( + title: t.settings.config.tunImplementation, + selected: options.tunImplementation, + options: TunImplementation.values, + getTitle: (e) => e.name, + resetValue: defaultOptions.tunImplementation, + ).show(context); + if (tunImplementation == null) return; + await changeOption( + ConfigOptionPatch(tunImplementation: tunImplementation), + ); + }, + ), + ListTile( + title: Text(t.settings.config.mixedPort), + subtitle: Text(options.mixedPort.toString()), + onTap: () async { + final mixedPort = await SettingsInputDialog( + title: t.settings.config.mixedPort, + initialValue: options.mixedPort, + resetValue: defaultOptions.mixedPort, + validator: isPort, + mapTo: int.tryParse, + digitsOnly: true, + ).show(context); + if (mixedPort == null) return; + await changeOption(ConfigOptionPatch(mixedPort: mixedPort)); + }, + ), + ListTile( + title: Text(t.settings.config.localDnsPort), + subtitle: Text(options.localDnsPort.toString()), + onTap: () async { + final localDnsPort = await SettingsInputDialog( + title: t.settings.config.localDnsPort, + initialValue: options.localDnsPort, + resetValue: defaultOptions.localDnsPort, + validator: isPort, + mapTo: int.tryParse, + digitsOnly: true, + ).show(context); + if (localDnsPort == null) return; + await changeOption( + ConfigOptionPatch(localDnsPort: localDnsPort), + ); + }, + ), + const SettingsDivider(), + SettingsSection(t.settings.config.section.misc), + ListTile( + title: Text(t.settings.config.connectionTestUrl), + subtitle: Text(options.connectionTestUrl), + onTap: () async { + final url = await SettingsInputDialog( + title: t.settings.config.connectionTestUrl, + initialValue: options.connectionTestUrl, + resetValue: defaultOptions.connectionTestUrl, + ).show(context); + if (url == null || url.isEmpty || !isUrl(url)) return; + await changeOption(ConfigOptionPatch(connectionTestUrl: url)); + }, + ), + ListTile( + title: Text(t.settings.config.urlTestInterval), + subtitle: Text( + options.urlTestInterval + .toApproximateTime(isRelativeToNow: false), + ), + onTap: () async { + final urlTestInterval = await SettingsSliderDialog( + title: t.settings.config.urlTestInterval, + initialValue: options.urlTestInterval.inMinutes + .coerceIn(0, 60) + .toDouble(), + resetValue: + defaultOptions.urlTestInterval.inMinutes.toDouble(), + min: 1, + max: 60, + divisions: 60, + labelGen: (value) => Duration(minutes: value.toInt()) + .toApproximateTime(isRelativeToNow: false), + ).show(context); + if (urlTestInterval == null) return; + await changeOption( + ConfigOptionPatch( + urlTestInterval: + Duration(minutes: urlTestInterval.toInt()), + ), + ); + }, + ), + ListTile( + title: Text(t.settings.config.clashApiPort), + subtitle: Text(options.clashApiPort.toString()), + onTap: () async { + final clashApiPort = await SettingsInputDialog( + title: t.settings.config.clashApiPort, + initialValue: options.clashApiPort, + resetValue: defaultOptions.clashApiPort, + validator: isPort, + mapTo: int.tryParse, + digitsOnly: true, + ).show(context); + if (clashApiPort == null) return; + await changeOption( + ConfigOptionPatch(clashApiPort: clashApiPort), + ); + }, + ), + const Gap(24), + ], + ), + // TODO show appropriate error/loading widgets + _ => const SizedBox(), + }, + ); + } +} diff --git a/lib/features/connection/data/connection_data_providers.dart b/lib/features/connection/data/connection_data_providers.dart new file mode 100644 index 00000000..a33f150d --- /dev/null +++ b/lib/features/connection/data/connection_data_providers.dart @@ -0,0 +1,24 @@ +import 'package:hiddify/features/config_option/data/config_option_data_providers.dart'; +import 'package:hiddify/features/connection/data/connection_platform_source.dart'; +import 'package:hiddify/features/connection/data/connection_repository.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/services/service_providers.dart'; +import 'package:hiddify/singbox/service/singbox_service_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'connection_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +ConnectionRepository connectionRepository( + ConnectionRepositoryRef ref, +) { + return ConnectionRepositoryImpl( + directories: ref.watch(filesEditorServiceProvider).dirs, + configOptionRepository: ref.watch(configOptionRepositoryProvider), + singbox: ref.watch(singboxServiceProvider), + platformSource: ConnectionPlatformSourceImpl(), + profilePathResolver: ref.watch(profilePathResolverProvider), + geoAssetPathResolver: ref.watch(geoAssetPathResolverProvider), + ); +} diff --git a/lib/features/connection/data/connection_platform_source.dart b/lib/features/connection/data/connection_platform_source.dart new file mode 100644 index 00000000..7304abb6 --- /dev/null +++ b/lib/features/connection/data/connection_platform_source.dart @@ -0,0 +1,67 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:hiddify/core/utils/ffi_utils.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:posix/posix.dart'; +import 'package:win32/win32.dart'; + +abstract interface class ConnectionPlatformSource { + Future checkPrivilege(); +} + +class ConnectionPlatformSourceImpl + with InfraLogger + implements ConnectionPlatformSource { + @override + Future checkPrivilege() async { + try { + if (Platform.isWindows) { + bool isElevated = false; + withMemory(sizeOf(), (phToken) { + withMemory(sizeOf(), (pReturnedSize) { + withMemory(sizeOf<_TokenElevation>(), + (pElevation) { + if (OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY, + phToken.cast(), + ) == + 1) { + if (GetTokenInformation( + phToken.value, + TOKEN_INFORMATION_CLASS.TokenElevation, + pElevation, + sizeOf<_TokenElevation>(), + pReturnedSize, + ) == + 1) { + isElevated = pElevation.ref.tokenIsElevated != 0; + } + } + if (phToken.value != 0) { + CloseHandle(phToken.value); + } + }); + }); + }); + return isElevated; + } else if (Platform.isLinux || Platform.isMacOS) { + final euid = geteuid(); + return euid == 0; + } else { + return true; + } + } catch (e) { + loggy.warning("error checking privilege", e); + return true; // return true so core handles it + } + } +} + +sealed class _TokenElevation extends Struct { + /// A nonzero value if the token has elevated privileges; + /// otherwise, a zero value. + @Int32() + external int tokenIsElevated; +} diff --git a/lib/features/connection/data/connection_repository.dart b/lib/features/connection/data/connection_repository.dart new file mode 100644 index 00000000..8561253a --- /dev/null +++ b/lib/features/connection/data/connection_repository.dart @@ -0,0 +1,214 @@ +import 'dart:io'; + +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/model/directories.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/config_option/data/config_option_repository.dart'; +import 'package:hiddify/features/connection/data/connection_platform_source.dart'; +import 'package:hiddify/features/connection/model/connection_failure.dart'; +import 'package:hiddify/features/connection/model/connection_status.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart'; +import 'package:hiddify/features/profile/data/profile_path_resolver.dart'; +import 'package:hiddify/singbox/model/singbox_config_option.dart'; +import 'package:hiddify/singbox/model/singbox_status.dart'; +import 'package:hiddify/singbox/service/singbox_service.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:meta/meta.dart'; + +abstract interface class ConnectionRepository { + Stream watchConnectionStatus(); + TaskEither connect( + String fileName, + bool disableMemoryLimit, + ); + TaskEither disconnect(); + TaskEither reconnect( + String fileName, + bool disableMemoryLimit, + ); +} + +class ConnectionRepositoryImpl + with ExceptionHandler, InfraLogger + implements ConnectionRepository { + ConnectionRepositoryImpl({ + required this.directories, + required this.singbox, + required this.platformSource, + required this.configOptionRepository, + required this.profilePathResolver, + required this.geoAssetPathResolver, + }); + + final Directories directories; + final SingboxService singbox; + final ConnectionPlatformSource platformSource; + final ConfigOptionRepository configOptionRepository; + final ProfilePathResolver profilePathResolver; + final GeoAssetPathResolver geoAssetPathResolver; + + bool _initialized = false; + + @override + Stream watchConnectionStatus() { + return singbox.watchStatus().map( + (event) => switch (event) { + SingboxStopped(:final alert?, :final message) => Disconnected( + switch (alert) { + SingboxAlert.emptyConfiguration => + ConnectionFailure.invalidConfig(message), + SingboxAlert.requestNotificationPermission => + ConnectionFailure.missingNotificationPermission(message), + SingboxAlert.requestVPNPermission => + ConnectionFailure.missingVpnPermission(message), + SingboxAlert.startCommandServer || + SingboxAlert.createService || + SingboxAlert.startService => + ConnectionFailure.unexpected(message), + }, + ), + SingboxStopped() => const Disconnected(), + SingboxStarting() => const Connecting(), + SingboxStarted() => const Connected(), + SingboxStopping() => const Disconnecting(), + }, + ); + } + + @visibleForTesting + TaskEither getConfigOption() { + return TaskEither.Do( + ($) async { + final options = await $( + configOptionRepository + .getFullSingboxConfigOption() + .mapLeft((l) => const InvalidConfigOption()), + ); + + return $( + TaskEither( + () async { + final geoip = geoAssetPathResolver.resolvePath(options.geoipPath); + final geosite = + geoAssetPathResolver.resolvePath(options.geositePath); + if (!await File(geoip).exists() || + !await File(geosite).exists()) { + return left(const ConnectionFailure.missingGeoAssets()); + } + return right(options); + }, + ), + ); + }, + ).handleExceptions(UnexpectedConnectionFailure.new); + } + + @visibleForTesting + TaskEither applyConfigOption( + SingboxConfigOption options, + ) { + return exceptionHandler( + () { + return singbox + .changeOptions(options) + .mapLeft(InvalidConfigOption.new) + .run(); + }, + UnexpectedConnectionFailure.new, + ); + } + + @visibleForTesting + TaskEither setup() { + if (_initialized) return TaskEither.of(unit); + return exceptionHandler( + () { + loggy.debug("setting up singbox"); + return singbox + .setup( + directories, + false, + ) + .map((r) { + loggy.debug("setup complete"); + _initialized = true; + return r; + }) + .mapLeft(UnexpectedConnectionFailure.new) + .run(); + }, + UnexpectedConnectionFailure.new, + ); + } + + @override + TaskEither connect( + String fileName, + bool disableMemoryLimit, + ) { + return TaskEither.Do( + ($) async { + final options = await $(getConfigOption()); + loggy.info( + "config options: ${options.format()}\nMemory Limit: ${!disableMemoryLimit}", + ); + + await $( + TaskEither(() async { + if (options.enableTun) { + final hasPrivilege = await platformSource.checkPrivilege(); + if (!hasPrivilege) { + loggy.warning("missing privileges for tun mode"); + return left(const MissingPrivilege()); + } + } + return right(unit); + }), + ); + await $(setup()); + loggy.debug("after setup"); + await $(applyConfigOption(options)); + loggy.debug("after apply"); + return await $( + singbox + .start( + profilePathResolver.file(fileName).path, + disableMemoryLimit, + ) + .mapLeft(UnexpectedConnectionFailure.new), + ); + }, + ).handleExceptions(UnexpectedConnectionFailure.new); + } + + @override + TaskEither disconnect() { + return exceptionHandler( + () => singbox.stop().mapLeft(UnexpectedConnectionFailure.new).run(), + UnexpectedConnectionFailure.new, + ); + } + + @override + TaskEither reconnect( + String fileName, + bool disableMemoryLimit, + ) { + return exceptionHandler( + () async { + return getConfigOption() + .flatMap((options) => applyConfigOption(options)) + .andThen( + () => singbox + .restart( + profilePathResolver.file(fileName).path, + disableMemoryLimit, + ) + .mapLeft(UnexpectedConnectionFailure.new), + ) + .run(); + }, + UnexpectedConnectionFailure.new, + ); + } +} diff --git a/lib/domain/connectivity/connection_failure.dart b/lib/features/connection/model/connection_failure.dart similarity index 51% rename from lib/domain/connectivity/connection_failure.dart rename to lib/features/connection/model/connection_failure.dart index b8b39f04..4586f2be 100644 --- a/lib/domain/connectivity/connection_failure.dart +++ b/lib/features/connection/model/connection_failure.dart @@ -1,7 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/core_service_failure.dart'; -import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; part 'connection_failure.freezed.dart'; @@ -24,8 +23,21 @@ sealed class ConnectionFailure with _$ConnectionFailure, Failure { String? message, ]) = MissingNotificationPermission; - const factory ConnectionFailure.core(CoreServiceFailure failure) = - CoreConnectionFailure; + @With() + const factory ConnectionFailure.missingPrivilege() = MissingPrivilege; + + @With() + const factory ConnectionFailure.missingGeoAssets() = MissingGeoAssets; + + @With() + const factory ConnectionFailure.invalidConfigOption([ + String? message, + ]) = InvalidConfigOption; + + @With() + const factory ConnectionFailure.invalidConfig([ + String? message, + ]) = InvalidConfig; @override ({String type, String? message}) present(TranslationsEn t) { @@ -42,7 +54,22 @@ sealed class ConnectionFailure with _$ConnectionFailure, Failure { type: t.failure.connectivity.missingNotificationPermission, message: message ), - CoreConnectionFailure(:final failure) => failure.present(t), + MissingPrivilege() => ( + type: t.failure.singbox.missingPrivilege, + message: t.failure.singbox.missingPrivilegeMsg, + ), + MissingGeoAssets() => ( + type: t.failure.singbox.missingGeoAssets, + message: t.failure.singbox.missingGeoAssetsMsg, + ), + InvalidConfigOption(:final message) => ( + type: t.failure.singbox.invalidConfigOptions, + message: message, + ), + InvalidConfig(:final message) => ( + type: t.failure.singbox.invalidConfig, + message: message, + ), }; } } diff --git a/lib/domain/connectivity/connection_status.dart b/lib/features/connection/model/connection_status.dart similarity index 90% rename from lib/domain/connectivity/connection_status.dart rename to lib/features/connection/model/connection_status.dart index df84b363..c6497db1 100644 --- a/lib/domain/connectivity/connection_status.dart +++ b/lib/features/connection/model/connection_status.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/connectivity/connection_failure.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/features/connection/model/connection_failure.dart'; part 'connection_status.freezed.dart'; diff --git a/lib/features/common/connectivity/connectivity_controller.dart b/lib/features/connection/notifier/connection_notifier.dart similarity index 68% rename from lib/features/common/connectivity/connectivity_controller.dart rename to lib/features/connection/notifier/connection_notifier.dart index a8bf79e3..21c22339 100644 --- a/lib/features/common/connectivity/connectivity_controller.dart +++ b/lib/features/connection/notifier/connection_notifier.dart @@ -1,17 +1,17 @@ -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/core/prefs/service_prefs.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/domain/core_facade.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; +import 'package:hiddify/core/preferences/service_preferences.dart'; +import 'package:hiddify/features/connection/data/connection_data_providers.dart'; +import 'package:hiddify/features/connection/data/connection_repository.dart'; +import 'package:hiddify/features/connection/model/connection_status.dart'; +import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rxdart/rxdart.dart'; -part 'connectivity_controller.g.dart'; +part 'connection_notifier.g.dart'; @Riverpod(keepAlive: true) -class ConnectivityController extends _$ConnectivityController with AppLogger { +class ConnectionNotifier extends _$ConnectionNotifier with AppLogger { @override Stream build() { ref.listen( @@ -24,8 +24,8 @@ class ConnectivityController extends _$ConnectivityController with AppLogger { } }, ); - return _core.watchConnectionStatus().doOnData((event) { - if (event case Disconnected(:final connectionFailure?) + return _connectionRepo.watchConnectionStatus().doOnData((event) { + if (event case Disconnected(connectionFailure: final _?) when PlatformUtils.isDesktop) { ref.read(startedByUserProvider.notifier).update(false); } @@ -33,7 +33,8 @@ class ConnectivityController extends _$ConnectivityController with AppLogger { }); } - CoreFacade get _core => ref.watch(coreFacadeProvider); + ConnectionRepository get _connectionRepo => + ref.read(connectionRepositoryProvider); Future mayConnect() async { if (state case AsyncData(:final value)) { @@ -66,8 +67,8 @@ class ConnectivityController extends _$ConnectivityController with AppLogger { } loggy.info("active profile changed, reconnecting"); await ref.read(startedByUserProvider.notifier).update(true); - await _core - .restart(profileId, ref.read(disableMemoryLimitProvider)) + await _connectionRepo + .reconnect(profileId, ref.read(disableMemoryLimitProvider)) .mapLeft((err) { loggy.warning("error reconnecting", err); state = AsyncError(err, StackTrace.current); @@ -88,16 +89,17 @@ class ConnectivityController extends _$ConnectivityController with AppLogger { Future _connect() async { final activeProfile = await ref.read(activeProfileProvider.future); - await _core - .start(activeProfile!.id, ref.read(disableMemoryLimitProvider)) - .mapLeft((err) { - loggy.warning("error connecting $err", err); + await _connectionRepo + .connect(activeProfile!.id, ref.read(disableMemoryLimitProvider)) + .mapLeft((err) async { + loggy.warning("error connecting", err); + await ref.read(startedByUserProvider.notifier).update(false); state = AsyncError(err, StackTrace.current); }).run(); } Future _disconnect() async { - await _core.stop().mapLeft((err) { + await _connectionRepo.disconnect().mapLeft((err) { loggy.warning("error disconnecting", err); state = AsyncError(err, StackTrace.current); }).run(); @@ -107,6 +109,6 @@ class ConnectivityController extends _$ConnectivityController with AppLogger { @Riverpod(keepAlive: true) Future serviceRunning(ServiceRunningRef ref) => ref .watch( - connectivityControllerProvider.selectAsync((data) => data.isConnected), + connectionNotifierProvider.selectAsync((data) => data.isConnected), ) .onError((error, stackTrace) => false); diff --git a/lib/features/geo_asset/data/geo_asset_data_mapper.dart b/lib/features/geo_asset/data/geo_asset_data_mapper.dart new file mode 100644 index 00000000..0f2401e5 --- /dev/null +++ b/lib/features/geo_asset/data/geo_asset_data_mapper.dart @@ -0,0 +1,31 @@ +import 'package:drift/drift.dart'; +import 'package:hiddify/core/database/app_database.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; + +extension GeoAssetEntityMapper on GeoAssetEntity { + GeoAssetEntriesCompanion toEntry() { + return GeoAssetEntriesCompanion.insert( + id: id, + type: type, + active: active, + name: name, + providerName: providerName, + version: Value(version), + lastCheck: Value(lastCheck), + ); + } +} + +extension GeoAssetEntryMapper on GeoAssetEntry { + GeoAssetEntity toEntity() { + return GeoAssetEntity( + id: id, + name: name, + type: type, + active: active, + providerName: providerName, + version: version, + lastCheck: lastCheck, + ); + } +} diff --git a/lib/features/geo_asset/data/geo_asset_data_providers.dart b/lib/features/geo_asset/data/geo_asset_data_providers.dart new file mode 100644 index 00000000..44a8c51f --- /dev/null +++ b/lib/features/geo_asset/data/geo_asset_data_providers.dart @@ -0,0 +1,32 @@ +import 'package:hiddify/core/database/database_provider.dart'; +import 'package:hiddify/core/http_client/http_client_provider.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_data_source.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_repository.dart'; +import 'package:hiddify/services/service_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'geo_asset_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +Future geoAssetRepository(GeoAssetRepositoryRef ref) async { + final repo = GeoAssetRepositoryImpl( + geoAssetDataSource: ref.watch(geoAssetDataSourceProvider), + geoAssetPathResolver: ref.watch(geoAssetPathResolverProvider), + dio: ref.watch(httpClientProvider), + ); + await repo.init().getOrElse((l) => throw l).run(); + return repo; +} + +@Riverpod(keepAlive: true) +GeoAssetDataSource geoAssetDataSource(GeoAssetDataSourceRef ref) { + return GeoAssetsDao(ref.watch(appDatabaseProvider)); +} + +@Riverpod(keepAlive: true) +GeoAssetPathResolver geoAssetPathResolver(GeoAssetPathResolverRef ref) { + return GeoAssetPathResolver( + ref.watch(filesEditorServiceProvider).dirs.workingDir, + ); +} diff --git a/lib/features/geo_asset/data/geo_asset_data_source.dart b/lib/features/geo_asset/data/geo_asset_data_source.dart new file mode 100644 index 00000000..b3c63aac --- /dev/null +++ b/lib/features/geo_asset/data/geo_asset_data_source.dart @@ -0,0 +1,59 @@ +import 'package:drift/drift.dart'; +import 'package:hiddify/core/database/app_database.dart'; +import 'package:hiddify/core/database/tables/database_tables.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +part 'geo_asset_data_source.g.dart'; + +abstract interface class GeoAssetDataSource { + Future insert(GeoAssetEntriesCompanion entry); + Future getActiveAssetByType(GeoAssetType type); + Stream> watchAll(); + Future patch(String id, GeoAssetEntriesCompanion entry); +} + +@DriftAccessor(tables: [GeoAssetEntries]) +class GeoAssetsDao extends DatabaseAccessor + with _$GeoAssetsDaoMixin, InfraLogger + implements GeoAssetDataSource { + GeoAssetsDao(super.db); + + @override + Future insert(GeoAssetEntriesCompanion entry) async { + await into(geoAssetEntries).insert(entry); + } + + @override + Future getActiveAssetByType(GeoAssetType type) async { + return (geoAssetEntries.select() + ..where((tbl) => tbl.active.equals(true)) + ..where((tbl) => tbl.type.equalsValue(type)) + ..limit(1)) + .getSingleOrNull(); + } + + @override + Stream> watchAll() { + return geoAssetEntries.select().watch(); + } + + @override + Future patch(String id, GeoAssetEntriesCompanion entry) async { + await transaction( + () async { + if (entry.active.present && entry.active.value) { + final baseEntry = await (select(geoAssetEntries) + ..where((tbl) => tbl.id.equals(id))) + .getSingle(); + await (update(geoAssetEntries) + ..where((tbl) => tbl.active.equals(true)) + ..where((tbl) => tbl.type.equalsValue(baseEntry.type))) + .write(const GeoAssetEntriesCompanion(active: Value(false))); + } + await (update(geoAssetEntries)..where((tbl) => tbl.id.equals(id))) + .write(entry); + }, + ); + } +} diff --git a/lib/features/geo_asset/data/geo_asset_path_resolver.dart b/lib/features/geo_asset/data/geo_asset_path_resolver.dart new file mode 100644 index 00000000..5dc1d117 --- /dev/null +++ b/lib/features/geo_asset/data/geo_asset_path_resolver.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class GeoAssetPathResolver { + const GeoAssetPathResolver(this._workingDir); + + final Directory _workingDir; + + Directory get directory => Directory(p.join(_workingDir.path, "geo-assets")); + + File file(String providerName, String fileName) { + final prefix = providerName.replaceAll("/", "-").toLowerCase().trim(); + return File( + p.join( + directory.path, + "$prefix${prefix.isEmpty ? "" : "-"}$fileName", + ), + ); + } + + /// geoasset's path relative to working directory + String relativePath(String providerName, String fileName) { + final fullPath = file(providerName, fileName).path; + return p.relative(fullPath, from: _workingDir.path); + } + + String resolvePath(String path) { + return p.absolute(_workingDir.path, path); + } +} diff --git a/lib/features/geo_asset/data/geo_asset_repository.dart b/lib/features/geo_asset/data/geo_asset_repository.dart new file mode 100644 index 00000000..3d39e13f --- /dev/null +++ b/lib/features/geo_asset/data/geo_asset_repository.dart @@ -0,0 +1,232 @@ +import 'dart:io'; + +import 'package:dartx/dartx_io.dart'; +import 'package:dio/dio.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter/services.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/database/app_database.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_data_mapper.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_data_source.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart'; +import 'package:hiddify/features/geo_asset/model/default_geo_assets.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_failure.dart'; +import 'package:hiddify/gen/assets.gen.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:watcher/watcher.dart'; + +abstract interface class GeoAssetRepository { + /// populate bundled geo assets directory with bundled files if needed + TaskEither init(); + TaskEither + getActivePair(); + Stream>> watchAll(); + TaskEither update(GeoAssetEntity geoAsset); + TaskEither markAsActive(GeoAssetEntity geoAsset); + TaskEither addRecommended(); +} + +class GeoAssetRepositoryImpl + with ExceptionHandler, InfraLogger + implements GeoAssetRepository { + GeoAssetRepositoryImpl({ + required this.geoAssetDataSource, + required this.geoAssetPathResolver, + required this.dio, + }); + + final GeoAssetDataSource geoAssetDataSource; + final GeoAssetPathResolver geoAssetPathResolver; + final Dio dio; + + @override + TaskEither init() { + return exceptionHandler( + () async { + loggy.debug("initializing"); + final geoipFile = geoAssetPathResolver.file( + defaultGeoip.providerName, + defaultGeoip.fileName, + ); + final geositeFile = geoAssetPathResolver.file( + defaultGeosite.providerName, + defaultGeosite.fileName, + ); + + final dirExists = await geoAssetPathResolver.directory.exists(); + if (!dirExists) { + await geoAssetPathResolver.directory.create(recursive: true); + } + + if (!dirExists || !await geoipFile.exists()) { + final bundledGeoip = await rootBundle.load(Assets.core.geoip); + await geoipFile.writeAsBytes(bundledGeoip.buffer.asInt8List()); + } + if (!dirExists || !await geositeFile.exists()) { + final bundledGeosite = await rootBundle.load(Assets.core.geosite); + await geositeFile.writeAsBytes(bundledGeosite.buffer.asInt8List()); + } + return right(unit); + }, + GeoAssetUnexpectedFailure.new, + ); + } + + @override + TaskEither + getActivePair() { + return exceptionHandler( + () async { + final geoip = + await geoAssetDataSource.getActiveAssetByType(GeoAssetType.geoip); + final geosite = + await geoAssetDataSource.getActiveAssetByType(GeoAssetType.geosite); + if (geoip == null || geosite == null) { + return left(const GeoAssetFailure.activeAssetNotFound()); + } + return right((geoip: geoip.toEntity(), geosite: geosite.toEntity())); + }, + GeoAssetUnexpectedFailure.new, + ); + } + + @override + Stream>> watchAll() { + final persistedStream = geoAssetDataSource + .watchAll() + .map((event) => event.map((e) => e.toEntity())); + final filesStream = _watchGeoFiles(); + + return Rx.combineLatest2( + persistedStream, + filesStream, + (assets, files) => assets.map( + (e) { + final path = + geoAssetPathResolver.file(e.providerName, e.fileName).path; + final file = files.firstOrNullWhere((e) => e.path == path); + final stat = file?.statSync(); + return (e, stat?.size); + }, + ).toList(), + ).handleExceptions(GeoAssetUnexpectedFailure.new); + } + + Iterable _geoFiles = []; + Stream> _watchGeoFiles() async* { + yield await _readGeoFiles(); + yield* Watcher( + geoAssetPathResolver.directory.path, + pollingDelay: const Duration(seconds: 1), + ).events.asyncMap((event) async { + await _readGeoFiles(); + return _geoFiles; + }); + } + + Future> _readGeoFiles() async { + return _geoFiles = Directory(geoAssetPathResolver.directory.path) + .listSync() + .whereType() + .where((e) => e.extension == '.db'); + } + + @override + TaskEither update(GeoAssetEntity geoAsset) { + return exceptionHandler( + () async { + loggy.debug( + "checking latest release of [${geoAsset.name}] on [${geoAsset.repositoryUrl}]", + ); + final response = await dio.get(geoAsset.repositoryUrl); + if (response.statusCode != 200 || response.data == null) { + return left( + GeoAssetUnexpectedFailure.new( + "invalid response", + StackTrace.current, + ), + ); + } + + final file = + geoAssetPathResolver.file(geoAsset.providerName, geoAsset.name); + final tagName = response.data!['tag_name'] as String; + loggy.debug("latest release of [${geoAsset.name}]: [$tagName]"); + if (tagName == geoAsset.version && await file.exists()) { + await geoAssetDataSource.patch( + geoAsset.id, + GeoAssetEntriesCompanion(lastCheck: Value(DateTime.now())), + ); + return left(const GeoAssetFailure.noUpdateAvailable()); + } + + final assets = (response.data!['assets'] as List) + .whereType>(); + final asset = + assets.firstOrNullWhere((e) => e["name"] == geoAsset.name); + if (asset == null) { + return left( + GeoAssetUnexpectedFailure.new( + "couldn't find [${geoAsset.name}] on [${geoAsset.repositoryUrl}]", + StackTrace.current, + ), + ); + } + + final downloadUrl = asset["browser_download_url"] as String; + loggy.debug("[${geoAsset.name}] download url: [$downloadUrl]"); + final tempPath = "${file.path}.tmp"; + await file.parent.create(recursive: true); + await dio.download(downloadUrl, tempPath); + await File(tempPath).rename(file.path); + + await geoAssetDataSource.patch( + geoAsset.id, + GeoAssetEntriesCompanion( + version: Value(tagName), + lastCheck: Value(DateTime.now()), + ), + ); + + return right(unit); + }, + GeoAssetUnexpectedFailure.new, + ); + } + + @override + TaskEither markAsActive(GeoAssetEntity geoAsset) { + return exceptionHandler( + () async { + await geoAssetDataSource.patch( + geoAsset.id, + const GeoAssetEntriesCompanion(active: Value(true)), + ); + return right(unit); + }, + GeoAssetUnexpectedFailure.new, + ); + } + + @override + TaskEither addRecommended() { + return exceptionHandler( + () async { + final persistedIds = await geoAssetDataSource + .watchAll() + .first + .then((value) => value.map((e) => e.id)); + final missing = + recommendedGeoAssets.where((e) => !persistedIds.contains(e.id)); + for (final geoAsset in missing) { + await geoAssetDataSource.insert(geoAsset.toEntry()); + } + return right(unit); + }, + GeoAssetUnexpectedFailure.new, + ); + } +} diff --git a/lib/features/geo_asset/model/default_geo_assets.dart b/lib/features/geo_asset/model/default_geo_assets.dart new file mode 100644 index 00000000..40b3aaa6 --- /dev/null +++ b/lib/features/geo_asset/model/default_geo_assets.dart @@ -0,0 +1,53 @@ +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; + +/// default geoip asset bundled with the app +const defaultGeoip = GeoAssetEntity( + id: "sing-box-geoip", + name: "geoip.db", + type: GeoAssetType.geoip, + active: true, + providerName: "SagerNet/sing-geoip", +); + +/// default geosite asset bundled with the app +const defaultGeosite = GeoAssetEntity( + id: "sing-box-geosite", + name: "geosite.db", + type: GeoAssetType.geosite, + active: true, + providerName: "SagerNet/sing-geosite", +); + +const defaultGeoAssets = [defaultGeoip, defaultGeosite]; + +const recommendedGeoAssets = [ + ...defaultGeoAssets, + GeoAssetEntity( + id: "chocolate4U-geoip", + name: "geoip.db", + type: GeoAssetType.geoip, + active: false, + providerName: "Chocolate4U/Iran-sing-box-rules", + ), + GeoAssetEntity( + id: "chocolate4U-geosite", + name: "geosite.db", + type: GeoAssetType.geosite, + active: false, + providerName: "Chocolate4U/Iran-sing-box-rules", + ), + GeoAssetEntity( + id: "soffchen-geoip", + name: "geoip.db", + type: GeoAssetType.geoip, + active: false, + providerName: "soffchen/sing-geoip", + ), + GeoAssetEntity( + id: "soffchen-geosite", + name: "geosite.db", + type: GeoAssetType.geosite, + active: false, + providerName: "soffchen/sing-geosite", + ), +]; diff --git a/lib/features/geo_asset/model/geo_asset_entity.dart b/lib/features/geo_asset/model/geo_asset_entity.dart new file mode 100644 index 00000000..44a3f9fd --- /dev/null +++ b/lib/features/geo_asset/model/geo_asset_entity.dart @@ -0,0 +1,27 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'geo_asset_entity.freezed.dart'; + +enum GeoAssetType { geoip, geosite } + +typedef GeoAssetWithFileSize = (GeoAssetEntity geoAsset, int? size); + +@freezed +class GeoAssetEntity with _$GeoAssetEntity { + const GeoAssetEntity._(); + + const factory GeoAssetEntity({ + required String id, + required String name, + required GeoAssetType type, + required bool active, + required String providerName, + String? version, + DateTime? lastCheck, + }) = _GeoAssetEntity; + + String get fileName => name; + + String get repositoryUrl => + "https://api.github.com/repos/$providerName/releases/latest"; +} diff --git a/lib/features/geo_asset/model/geo_asset_failure.dart b/lib/features/geo_asset/model/geo_asset_failure.dart new file mode 100644 index 00000000..882864da --- /dev/null +++ b/lib/features/geo_asset/model/geo_asset_failure.dart @@ -0,0 +1,41 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; + +part 'geo_asset_failure.freezed.dart'; + +@freezed +sealed class GeoAssetFailure with _$GeoAssetFailure, Failure { + const GeoAssetFailure._(); + + @With() + const factory GeoAssetFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = GeoAssetUnexpectedFailure; + + @With() + const factory GeoAssetFailure.noUpdateAvailable() = GeoAssetNoUpdateAvailable; + + @With() + const factory GeoAssetFailure.activeAssetNotFound() = + GeoAssetActiveAssetNotFound; + + @override + ({String type, String? message}) present(TranslationsEn t) { + return switch (this) { + GeoAssetUnexpectedFailure() => ( + type: t.failure.geoAssets.unexpected, + message: null, + ), + GeoAssetNoUpdateAvailable() => ( + type: t.failure.geoAssets.notUpdate, + message: null + ), + GeoAssetActiveAssetNotFound() => ( + type: t.failure.geoAssets.activeNotFound, + message: null, + ), + }; + } +} diff --git a/lib/features/geo_asset/notifier/geo_asset_notifier.dart b/lib/features/geo_asset/notifier/geo_asset_notifier.dart new file mode 100644 index 00000000..94f1ddac --- /dev/null +++ b/lib/features/geo_asset/notifier/geo_asset_notifier.dart @@ -0,0 +1,33 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'geo_asset_notifier.g.dart'; + +@riverpod +class FetchGeoAsset extends _$FetchGeoAsset with AppLogger { + @override + Future build(String id) async { + ref.disposeDelay(const Duration(seconds: 10)); + return null; + } + + Future fetch(GeoAssetEntity geoAsset) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref + .read(geoAssetRepositoryProvider) + .requireValue + .update(geoAsset) + .getOrElse( + (failure) { + loggy.warning("error updating geo asset $failure", failure); + throw failure; + }, + ).run(), + ); + } +} diff --git a/lib/features/geo_asset/overview/geo_assets_overview_notifier.dart b/lib/features/geo_asset/overview/geo_assets_overview_notifier.dart new file mode 100644 index 00000000..d3ead5b9 --- /dev/null +++ b/lib/features/geo_asset/overview/geo_assets_overview_notifier.dart @@ -0,0 +1,43 @@ +import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart'; +import 'package:hiddify/features/geo_asset/data/geo_asset_repository.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'geo_assets_overview_notifier.g.dart'; + +@riverpod +class GeoAssetsOverviewNotifier extends _$GeoAssetsOverviewNotifier + with AppLogger { + @override + Stream> build() { + ref.disposeDelay(const Duration(seconds: 5)); + return ref + .watch(geoAssetRepositoryProvider) + .requireValue + .watchAll() + .map((event) => event.getOrElse((l) => throw l)); + } + + GeoAssetRepository get _geoAssetRepo => + ref.read(geoAssetRepositoryProvider).requireValue; + + Future markAsActive(GeoAssetEntity geoAsset) async { + await _geoAssetRepo.markAsActive(geoAsset).getOrElse( + (f) { + loggy.warning("error marking geo asset as active", f); + throw f; + }, + ).run(); + } + + Future addRecommended() async { + await _geoAssetRepo.addRecommended().getOrElse( + (f) { + loggy.warning("error adding recommended geo assets", f); + throw f; + }, + ).run(); + } +} diff --git a/lib/features/geo_asset/overview/geo_assets_overview_page.dart b/lib/features/geo_asset/overview/geo_assets_overview_page.dart new file mode 100644 index 00000000..6917721c --- /dev/null +++ b/lib/features/geo_asset/overview/geo_assets_overview_page.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/features/geo_asset/overview/geo_assets_overview_notifier.dart'; +import 'package:hiddify/features/geo_asset/widget/geo_asset_tile.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class GeoAssetsOverviewPage extends HookConsumerWidget { + const GeoAssetsOverviewPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + final state = ref.watch(geoAssetsOverviewNotifierProvider); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + title: Text(t.settings.geoAssets.pageTitle), + actions: [ + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + child: Text(t.settings.geoAssets.addRecommended), + onTap: () { + ref + .read(geoAssetsOverviewNotifierProvider.notifier) + .addRecommended(); + }, + ), + ]; + }, + ), + ], + ), + switch (state) { + AsyncData(value: final geoAssets) => SliverList.builder( + itemBuilder: (context, index) { + final geoAsset = geoAssets[index]; + return GeoAssetTile( + geoAsset, + onMarkAsActive: () => ref + .read(geoAssetsOverviewNotifierProvider.notifier) + .markAsActive(geoAsset.$1), + ); + }, + itemCount: geoAssets.length, + ), + _ => const SliverToBoxAdapter(), + }, + ], + ), + ); + } +} diff --git a/lib/features/geo_asset/widget/geo_asset_tile.dart b/lib/features/geo_asset/widget/geo_asset_tile.dart new file mode 100644 index 00000000..61fbbee6 --- /dev/null +++ b/lib/features/geo_asset/widget/geo_asset_tile.dart @@ -0,0 +1,114 @@ +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/features/geo_asset/model/geo_asset_failure.dart'; +import 'package:hiddify/features/geo_asset/notifier/geo_asset_notifier.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:humanizer/humanizer.dart'; + +class GeoAssetTile extends HookConsumerWidget { + GeoAssetTile( + GeoAssetWithFileSize geoAssetWithFileSize, { + super.key, + required this.onMarkAsActive, + }) : geoAsset = geoAssetWithFileSize.$1, + size = geoAssetWithFileSize.$2; + + final GeoAssetEntity geoAsset; + final int? size; + final VoidCallback onMarkAsActive; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + final fetchState = ref.watch(fetchGeoAssetProvider(geoAsset.id)); + final fileMissing = size == null; + + ref.listen( + fetchGeoAssetProvider(geoAsset.id), + (_, next) { + switch (next) { + case AsyncError(:final error): + if (error case GeoAssetNoUpdateAvailable()) { + return CustomToast(t.failure.geoAssets.notUpdate).show(context); + } + CustomAlertDialog.fromErr(t.presentError(error)).show(context); + case AsyncData(value: final _?): + CustomToast.success(t.settings.geoAssets.successMsg).show(context); + } + }, + ); + + return ListTile( + title: Text.rich( + TextSpan( + children: [ + TextSpan(text: geoAsset.name), + if (geoAsset.providerName.isNotBlank) + TextSpan(text: " (${geoAsset.providerName})"), + ], + ), + ), + isThreeLine: true, + subtitle: fetchState.isLoading + ? const LinearProgressIndicator() + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (geoAsset.version.isNotNullOrBlank) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: Text( + t.settings.geoAssets.version(version: geoAsset.version!), + overflow: TextOverflow.ellipsis, + ), + ) + else + const SizedBox(), + Flexible( + child: Text.rich( + TextSpan( + children: [ + if (fileMissing) + TextSpan( + text: t.settings.geoAssets.fileMissing, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ) + else + TextSpan(text: size?.bytes().toString()), + if (geoAsset.lastCheck != null) ...[ + const TextSpan(text: " • "), + TextSpan(text: geoAsset.lastCheck!.format()), + ], + ], + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + selected: geoAsset.active, + onTap: onMarkAsActive, + trailing: PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + enabled: !fetchState.isLoading, + onTap: () => ref + .read(FetchGeoAssetProvider(geoAsset.id).notifier) + .fetch(geoAsset), + child: fileMissing + ? Text(t.settings.geoAssets.download) + : Text(t.settings.geoAssets.update), + ), + ]; + }, + ), + ); + } +} diff --git a/lib/features/home/view/view.dart b/lib/features/home/view/view.dart deleted file mode 100644 index e4ff2696..00000000 --- a/lib/features/home/view/view.dart +++ /dev/null @@ -1 +0,0 @@ -export 'home_page.dart'; diff --git a/lib/features/home/widgets/connection_button.dart b/lib/features/home/widget/connection_button.dart similarity index 84% rename from lib/features/home/widgets/connection_button.dart rename to lib/features/home/widget/connection_button.dart index afef3330..f2ff6489 100644 --- a/lib/features/home/widgets/connection_button.dart +++ b/lib/features/home/widget/connection_button.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; +import 'package:hiddify/core/theme/theme_extensions.dart'; +import 'package:hiddify/features/connection/model/connection_status.dart'; +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; import 'package:hiddify/gen/assets.gen.dart'; import 'package:hiddify/utils/alerts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -17,10 +17,10 @@ class ConnectionButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final connectionStatus = ref.watch(connectivityControllerProvider); + final connectionStatus = ref.watch(connectionNotifierProvider); ref.listen( - connectivityControllerProvider, + connectionNotifierProvider, (_, next) { if (next case AsyncError(:final error)) { CustomAlertDialog.fromErr(t.presentError(error)).show(context); @@ -42,18 +42,16 @@ class ConnectionButton extends HookConsumerWidget { : buttonTheme.idleColor!; return _ConnectionButton( - onTap: () => ref - .read(connectivityControllerProvider.notifier) - .toggleConnection(), + onTap: () => + ref.read(connectionNotifierProvider.notifier).toggleConnection(), enabled: !status.isSwitching, label: status.present(t), buttonColor: connectionLogoColor, ); case AsyncError(): return _ConnectionButton( - onTap: () => ref - .read(connectivityControllerProvider.notifier) - .toggleConnection(), + onTap: () => + ref.read(connectionNotifierProvider.notifier).toggleConnection(), enabled: true, label: const Disconnected().present(t), buttonColor: buttonTheme.idleColor!, diff --git a/lib/features/home/widgets/empty_profiles_home_body.dart b/lib/features/home/widget/empty_profiles_home_body.dart similarity index 91% rename from lib/features/home/widgets/empty_profiles_home_body.dart rename to lib/features/home/widget/empty_profiles_home_body.dart index a633dce9..eed2b370 100644 --- a/lib/features/home/widgets/empty_profiles_home_body.dart +++ b/lib/features/home/widget/empty_profiles_home_body.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/core/router/router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -44,7 +44,7 @@ class EmptyActiveProfileHomeBody extends HookConsumerWidget { Text(t.home.noActiveProfileMsg), const Gap(16), OutlinedButton( - onPressed: () => const ProfilesRoute().push(context), + onPressed: () => const ProfilesOverviewRoute().push(context), child: Text(t.profile.overviewPageTitle), ), ], diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/widget/home_page.dart similarity index 86% rename from lib/features/home/view/home_page.dart rename to lib/features/home/widget/home_page.dart index e3ed99ba..785b0e2b 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/widget/home_page.dart @@ -1,16 +1,16 @@ import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; -import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/app_info/app_info_provider.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; -import 'package:hiddify/features/common/active_profile/has_any_profile_notifier.dart'; import 'package:hiddify/features/common/nested_app_bar.dart'; -import 'package:hiddify/features/common/profile_tile.dart'; -import 'package:hiddify/features/home/widgets/widgets.dart'; +import 'package:hiddify/features/home/widget/connection_button.dart'; +import 'package:hiddify/features/home/widget/empty_profiles_home_body.dart'; +import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; +import 'package:hiddify/features/profile/widget/profile_tile.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; - import 'package:sliver_tools/sliver_tools.dart'; class HomePage extends HookConsumerWidget { @@ -92,7 +92,7 @@ class AppVersionLabel extends HookConsumerWidget { final t = ref.watch(translationsProvider); final theme = Theme.of(context); - final version = ref.watch(appInfoProvider).presentVersion; + final version = ref.watch(appInfoProvider).requireValue.presentVersion; if (version.isBlank) return const SizedBox(); return Semantics( diff --git a/lib/features/home/widgets/widgets.dart b/lib/features/home/widgets/widgets.dart deleted file mode 100644 index b043254e..00000000 --- a/lib/features/home/widgets/widgets.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'connection_button.dart'; -export 'empty_profiles_home_body.dart'; diff --git a/lib/features/intro/intro_page.dart b/lib/features/intro/widget/intro_page.dart similarity index 95% rename from lib/features/intro/intro_page.dart rename to lib/features/intro/widget/intro_page.dart index 53da7370..5732a17d 100644 --- a/lib/features/intro/intro_page.dart +++ b/lib/features/intro/widget/intro_page.dart @@ -2,9 +2,9 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/constants.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/constants.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hiddify/features/common/general_pref_tiles.dart'; import 'package:hiddify/gen/assets.gen.dart'; import 'package:hiddify/utils/utils.dart'; diff --git a/lib/features/log/data/log_data_providers.dart b/lib/features/log/data/log_data_providers.dart new file mode 100644 index 00000000..c84aa7b7 --- /dev/null +++ b/lib/features/log/data/log_data_providers.dart @@ -0,0 +1,24 @@ +import 'package:hiddify/features/log/data/log_path_resolver.dart'; +import 'package:hiddify/features/log/data/log_repository.dart'; +import 'package:hiddify/services/service_providers.dart'; +import 'package:hiddify/singbox/service/singbox_service_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'log_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +Future logRepository(LogRepositoryRef ref) async { + final repo = LogRepositoryImpl( + singbox: ref.watch(singboxServiceProvider), + logPathResolver: ref.watch(logPathResolverProvider), + ); + await repo.init().getOrElse((l) => throw l).run(); + return repo; +} + +@Riverpod(keepAlive: true) +LogPathResolver logPathResolver(LogPathResolverRef ref) { + return LogPathResolver( + ref.watch(filesEditorServiceProvider).dirs.workingDir, + ); +} diff --git a/lib/features/log/data/log_parser.dart b/lib/features/log/data/log_parser.dart new file mode 100644 index 00000000..84b496d1 --- /dev/null +++ b/lib/features/log/data/log_parser.dart @@ -0,0 +1,33 @@ +// ignore_for_file: parameter_assignments + +import 'package:dartx/dartx.dart'; +import 'package:hiddify/features/log/model/log_entity.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; +import 'package:tint/tint.dart'; + +abstract class LogParser { + static LogEntity parseSingbox(String log) { + log = log.strip(); + DateTime? time; + if (log.length > 25) { + time = DateTime.tryParse(log.substring(6, 25)); + } + if (time != null) { + log = log.substring(26); + } + final level = LogLevel.values.firstOrNullWhere( + (e) { + if (log.startsWith(e.name.toUpperCase())) { + log = log.removePrefix(e.name.toUpperCase()); + return true; + } + return false; + }, + ); + return LogEntity( + level: level, + time: time, + message: log.trim(), + ); + } +} diff --git a/lib/features/log/data/log_path_resolver.dart b/lib/features/log/data/log_path_resolver.dart new file mode 100644 index 00000000..08762f28 --- /dev/null +++ b/lib/features/log/data/log_path_resolver.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class LogPathResolver { + const LogPathResolver(this._workingDir); + + final Directory _workingDir; + + Directory get directory => _workingDir; + + File coreFile() { + return File(p.join(directory.path, "box.log")); + } + + File appFile() { + return File(p.join(directory.path, "app.log")); + } +} diff --git a/lib/features/log/data/log_repository.dart b/lib/features/log/data/log_repository.dart new file mode 100644 index 00000000..4f4cb443 --- /dev/null +++ b/lib/features/log/data/log_repository.dart @@ -0,0 +1,70 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/log/data/log_parser.dart'; +import 'package:hiddify/features/log/data/log_path_resolver.dart'; +import 'package:hiddify/features/log/model/log_entity.dart'; +import 'package:hiddify/features/log/model/log_failure.dart'; +import 'package:hiddify/singbox/service/singbox_service.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +abstract interface class LogRepository { + TaskEither init(); + Stream>> watchLogs(); + TaskEither clearLogs(); +} + +class LogRepositoryImpl + with ExceptionHandler, InfraLogger + implements LogRepository { + LogRepositoryImpl({ + required this.singbox, + required this.logPathResolver, + }); + + final SingboxService singbox; + final LogPathResolver logPathResolver; + + @override + TaskEither init() { + return exceptionHandler( + () async { + if (!await logPathResolver.directory.exists()) { + await logPathResolver.directory.create(recursive: true); + } + if (await logPathResolver.coreFile().exists()) { + await logPathResolver.coreFile().writeAsString(""); + } else { + await logPathResolver.coreFile().create(recursive: true); + } + if (await logPathResolver.appFile().exists()) { + await logPathResolver.appFile().writeAsString(""); + } else { + await logPathResolver.appFile().create(recursive: true); + } + return right(unit); + }, + LogUnexpectedFailure.new, + ); + } + + @override + Stream>> watchLogs() { + return singbox + .watchLogs(logPathResolver.coreFile().path) + .map((event) => event.map(LogParser.parseSingbox).toList()) + .handleExceptions( + (error, stackTrace) { + loggy.warning("error watching logs", error, stackTrace); + return LogFailure.unexpected(error, stackTrace); + }, + ); + } + + @override + TaskEither clearLogs() { + return exceptionHandler( + () => singbox.clearLogs().mapLeft(LogFailure.unexpected).run(), + LogFailure.unexpected, + ); + } +} diff --git a/lib/features/log/model/log_entity.dart b/lib/features/log/model/log_entity.dart new file mode 100644 index 00000000..878927a9 --- /dev/null +++ b/lib/features/log/model/log_entity.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; + +part 'log_entity.freezed.dart'; + +@freezed +class LogEntity with _$LogEntity { + const factory LogEntity({ + LogLevel? level, + DateTime? time, + required String message, + }) = _LogEntity; +} diff --git a/lib/features/log/model/log_failure.dart b/lib/features/log/model/log_failure.dart new file mode 100644 index 00000000..5815053e --- /dev/null +++ b/lib/features/log/model/log_failure.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; + +part 'log_failure.freezed.dart'; + +@freezed +sealed class LogFailure with _$LogFailure, Failure { + const LogFailure._(); + + @With() + const factory LogFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = LogUnexpectedFailure; + + @override + ({String type, String? message}) present(TranslationsEn t) { + return switch (this) { + LogUnexpectedFailure() => ( + type: t.failure.unexpected, + message: null, + ), + }; + } +} diff --git a/lib/features/log/model/log_level.dart b/lib/features/log/model/log_level.dart new file mode 100644 index 00000000..a81cf47c --- /dev/null +++ b/lib/features/log/model/log_level.dart @@ -0,0 +1,25 @@ +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart'; + +enum LogLevel { + trace, + debug, + info, + warn, + error, + fatal, + panic; + + /// [LogLevel] selectable by user as preference + static List get choices => values.takeFirst(4); + + Color? get color => switch (this) { + trace => Colors.lightBlueAccent, + debug => Colors.grey, + info => Colors.lightGreen, + warn => Colors.orange, + error => Colors.redAccent, + fatal => Colors.red, + panic => Colors.red, + }; +} diff --git a/lib/features/logs/notifier/logs_notifier.dart b/lib/features/log/overview/logs_overview_notifier.dart similarity index 79% rename from lib/features/logs/notifier/logs_notifier.dart rename to lib/features/log/overview/logs_overview_notifier.dart index b089840d..8b3c4243 100644 --- a/lib/features/logs/notifier/logs_notifier.dart +++ b/lib/features/log/overview/logs_overview_notifier.dart @@ -1,21 +1,22 @@ import 'dart:async'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/features/logs/notifier/logs_state.dart'; +import 'package:hiddify/features/log/data/log_data_providers.dart'; +import 'package:hiddify/features/log/model/log_entity.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; +import 'package:hiddify/features/log/overview/logs_overview_state.dart'; import 'package:hiddify/utils/riverpod_utils.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rxdart/rxdart.dart'; -part 'logs_notifier.g.dart'; +part 'logs_overview_notifier.g.dart'; @riverpod -class LogsNotifier extends _$LogsNotifier with AppLogger { +class LogsOverviewNotifier extends _$LogsOverviewNotifier with AppLogger { @override - LogsState build() { + LogsOverviewState build() { ref.disposeDelay(const Duration(seconds: 20)); - state = const LogsState(); + state = const LogsOverviewState(); ref.onDispose( () { loggy.debug("disposing"); @@ -41,7 +42,7 @@ class LogsNotifier extends _$LogsNotifier with AppLogger { ); _addListeners(); - return const LogsState(); + return const LogsOverviewState(); } StreamSubscription? _listener; @@ -50,7 +51,8 @@ class LogsNotifier extends _$LogsNotifier with AppLogger { loggy.debug("adding listeners"); await _listener?.cancel(); _listener = ref - .read(coreFacadeProvider) + .read(logRepositoryProvider) + .requireValue .watchLogs() .throttle( (_) => Stream.value(_listener?.isPaused ?? false), @@ -78,15 +80,14 @@ class LogsNotifier extends _$LogsNotifier with AppLogger { ).listen((event) {}); } - Iterable _logs = []; + Iterable _logs = []; final _debouncer = CallbackDebouncer(const Duration(milliseconds: 200)); LogLevel? _levelFilter; String _filter = ""; - Future> _computeLogs() async { - final logs = _logs.map(BoxLog.parse); - if (_levelFilter == null && _filter.isEmpty) return logs.toList(); - return logs.where((e) { + Future> _computeLogs() async { + if (_levelFilter == null && _filter.isEmpty) return _logs.toList(); + return _logs.where((e) { return (_filter.isEmpty || e.message.contains(_filter)) && (_levelFilter == null || e.level == null || @@ -108,7 +109,7 @@ class LogsNotifier extends _$LogsNotifier with AppLogger { Future clear() async { loggy.debug("clearing"); - await ref.read(coreFacadeProvider).clearLogs().match( + await ref.read(logRepositoryProvider).requireValue.clearLogs().match( (l) { loggy.warning("error clearing logs", l); }, diff --git a/lib/features/logs/view/logs_page.dart b/lib/features/log/overview/logs_overview_page.dart similarity index 90% rename from lib/features/logs/view/logs_page.dart rename to lib/features/log/overview/logs_overview_page.dart index ace41283..17b1b8c9 100644 --- a/lib/features/logs/view/logs_page.dart +++ b/lib/features/log/overview/logs_overview_page.dart @@ -2,28 +2,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fpdart/fpdart.dart'; import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hiddify/features/common/nested_app_bar.dart'; -import 'package:hiddify/features/logs/notifier/notifier.dart'; -import 'package:hiddify/services/service_providers.dart'; +import 'package:hiddify/features/log/data/log_data_providers.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; +import 'package:hiddify/features/log/overview/logs_overview_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sliver_tools/sliver_tools.dart'; -class LogsPage extends HookConsumerWidget with PresLogger { - const LogsPage({super.key}); +class LogsOverviewPage extends HookConsumerWidget with PresLogger { + const LogsOverviewPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final state = ref.watch(logsNotifierProvider); - final notifier = ref.watch(logsNotifierProvider.notifier); + final state = ref.watch(logsOverviewNotifierProvider); + final notifier = ref.watch(logsOverviewNotifierProvider.notifier); final debug = ref.watch(debugModeNotifierProvider); - final filesEditor = ref.watch(filesEditorServiceProvider); + final pathResolver = ref.watch(logPathResolverProvider); final filterController = useTextEditingController(text: state.filter); @@ -33,8 +33,8 @@ class LogsPage extends HookConsumerWidget with PresLogger { child: Text(t.logs.shareCoreLogs), onTap: () async { await UriUtils.tryShareOrLaunchFile( - Uri.parse(filesEditor.coreLogsFile.path), - fileOrDir: filesEditor.logsDir.uri, + Uri.parse(pathResolver.coreFile().path), + fileOrDir: pathResolver.directory.uri, ); }, ), @@ -42,8 +42,8 @@ class LogsPage extends HookConsumerWidget with PresLogger { child: Text(t.logs.shareAppLogs), onTap: () async { await UriUtils.tryShareOrLaunchFile( - Uri.parse(filesEditor.appLogsFile.path), - fileOrDir: filesEditor.logsDir.uri, + Uri.parse(pathResolver.appFile().path), + fileOrDir: pathResolver.directory.uri, ); }, ), diff --git a/lib/features/log/overview/logs_overview_state.dart b/lib/features/log/overview/logs_overview_state.dart new file mode 100644 index 00000000..abc47ca5 --- /dev/null +++ b/lib/features/log/overview/logs_overview_state.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/features/log/model/log_entity.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'logs_overview_state.freezed.dart'; + +@freezed +class LogsOverviewState with _$LogsOverviewState { + const LogsOverviewState._(); + + const factory LogsOverviewState({ + @Default(AsyncLoading()) AsyncValue> logs, + @Default(false) bool paused, + @Default("") String filter, + LogLevel? levelFilter, + }) = _LogsOverviewState; +} diff --git a/lib/features/logs/notifier/logs_state.dart b/lib/features/logs/notifier/logs_state.dart deleted file mode 100644 index 4318870a..00000000 --- a/lib/features/logs/notifier/logs_state.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'logs_state.freezed.dart'; - -@freezed -class LogsState with _$LogsState { - const LogsState._(); - - const factory LogsState({ - @Default(AsyncLoading()) AsyncValue> logs, - @Default(false) bool paused, - @Default("") String filter, - LogLevel? levelFilter, - }) = _LogsState; -} diff --git a/lib/features/logs/notifier/notifier.dart b/lib/features/logs/notifier/notifier.dart deleted file mode 100644 index 69135d86..00000000 --- a/lib/features/logs/notifier/notifier.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'logs_notifier.dart'; -export 'logs_state.dart'; diff --git a/lib/features/logs/view/view.dart b/lib/features/logs/view/view.dart deleted file mode 100644 index 2982c609..00000000 --- a/lib/features/logs/view/view.dart +++ /dev/null @@ -1 +0,0 @@ -export 'logs_page.dart'; diff --git a/lib/features/per_app_proxy/data/per_app_proxy_data_providers.dart b/lib/features/per_app_proxy/data/per_app_proxy_data_providers.dart new file mode 100644 index 00000000..74f01681 --- /dev/null +++ b/lib/features/per_app_proxy/data/per_app_proxy_data_providers.dart @@ -0,0 +1,9 @@ +import 'package:hiddify/features/per_app_proxy/data/per_app_proxy_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'per_app_proxy_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +PerAppProxyRepository perAppProxyRepository(PerAppProxyRepositoryRef ref) { + return PerAppProxyRepositoryImpl(); +} diff --git a/lib/features/per_app_proxy/data/per_app_proxy_repository.dart b/lib/features/per_app_proxy/data/per_app_proxy_repository.dart new file mode 100644 index 00000000..7219727c --- /dev/null +++ b/lib/features/per_app_proxy/data/per_app_proxy_repository.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/features/per_app_proxy/model/installed_package_info.dart'; +import 'package:hiddify/utils/utils.dart'; + +abstract interface class PerAppProxyRepository { + TaskEither> getInstalledPackages(); + TaskEither getPackageIcon(String packageName); +} + +class PerAppProxyRepositoryImpl + with InfraLogger + implements PerAppProxyRepository { + final _methodChannel = const MethodChannel("app.hiddify.com/platform"); + + @override + TaskEither> getInstalledPackages() { + return TaskEither( + () async { + loggy.debug("getting installed packages info"); + final result = + await _methodChannel.invokeMethod("get_installed_packages"); + if (result == null) return left("null response"); + return right( + (jsonDecode(result) as List).map((e) { + return InstalledPackageInfo.fromJson(e as Map); + }).toList(), + ); + }, + ); + } + + @override + TaskEither getPackageIcon(String packageName) { + return TaskEither( + () async { + loggy.debug("getting package [$packageName] icon"); + final result = await _methodChannel.invokeMethod( + "get_package_icon", + {"packageName": packageName}, + ); + if (result == null) return left("null response"); + final Uint8List decoded; + try { + decoded = base64.decode(result); + } catch (e) { + return left("error parsing base64 response"); + } + return right(decoded); + }, + ); + } +} diff --git a/lib/features/per_app_proxy/model/installed_package_info.dart b/lib/features/per_app_proxy/model/installed_package_info.dart new file mode 100644 index 00000000..b951c170 --- /dev/null +++ b/lib/features/per_app_proxy/model/installed_package_info.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'installed_package_info.freezed.dart'; +part 'installed_package_info.g.dart'; + +@freezed +class InstalledPackageInfo with _$InstalledPackageInfo { + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory InstalledPackageInfo({ + required String packageName, + required String name, + required bool isSystemApp, + }) = _InstalledPackageInfo; + + factory InstalledPackageInfo.fromJson(Map json) => + _$InstalledPackageInfoFromJson(json); +} diff --git a/lib/features/per_app_proxy/model/per_app_proxy_mode.dart b/lib/features/per_app_proxy/model/per_app_proxy_mode.dart new file mode 100644 index 00000000..6a84bb34 --- /dev/null +++ b/lib/features/per_app_proxy/model/per_app_proxy_mode.dart @@ -0,0 +1,24 @@ +import 'package:hiddify/core/localization/translations.dart'; + +enum PerAppProxyMode { + off, + include, + exclude; + + bool get enabled => this != off; + + ({String title, String message}) present(TranslationsEn t) => switch (this) { + off => ( + title: t.settings.network.perAppProxyModes.off, + message: t.settings.network.perAppProxyModes.offMsg, + ), + include => ( + title: t.settings.network.perAppProxyModes.include, + message: t.settings.network.perAppProxyModes.includeMsg, + ), + exclude => ( + title: t.settings.network.perAppProxyModes.exclude, + message: t.settings.network.perAppProxyModes.excludeMsg, + ), + }; +} diff --git a/lib/features/per_app_proxy/overview/per_app_proxy_notifier.dart b/lib/features/per_app_proxy/overview/per_app_proxy_notifier.dart new file mode 100644 index 00000000..0830151f --- /dev/null +++ b/lib/features/per_app_proxy/overview/per_app_proxy_notifier.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/features/per_app_proxy/data/per_app_proxy_data_providers.dart'; +import 'package:hiddify/features/per_app_proxy/model/installed_package_info.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'per_app_proxy_notifier.g.dart'; + +@riverpod +Future> installedPackagesInfo( + InstalledPackagesInfoRef ref, +) async { + return ref + .watch(perAppProxyRepositoryProvider) + .getInstalledPackages() + .getOrElse((err) { + // _logger.error("error getting installed packages", err); + throw err; + }).run(); +} + +@riverpod +Future packageIcon( + PackageIconRef ref, + String packageName, +) async { + ref.disposeDelay(const Duration(seconds: 10)); + final bytes = await ref + .watch(perAppProxyRepositoryProvider) + .getPackageIcon(packageName) + .getOrElse((err) { + // _logger.warning("error getting package icon", err); + throw err; + }).run(); + return MemoryImage(bytes); +} diff --git a/lib/features/settings/view/per_app_proxy_page.dart b/lib/features/per_app_proxy/overview/per_app_proxy_page.dart similarity index 85% rename from lib/features/settings/view/per_app_proxy_page.dart rename to lib/features/per_app_proxy/overview/per_app_proxy_page.dart index d885110a..651fa6ff 100644 --- a/lib/features/settings/view/per_app_proxy_page.dart +++ b/lib/features/per_app_proxy/overview/per_app_proxy_page.dart @@ -2,51 +2,15 @@ import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/general_prefs.dart'; -import 'package:hiddify/domain/singbox/rules.dart'; -import 'package:hiddify/services/platform_services.dart'; -import 'package:hiddify/services/service_providers.dart'; -import 'package:hiddify/utils/riverpod_utils.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; +import 'package:hiddify/features/per_app_proxy/model/installed_package_info.dart'; +import 'package:hiddify/features/per_app_proxy/model/per_app_proxy_mode.dart'; +import 'package:hiddify/features/per_app_proxy/overview/per_app_proxy_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:loggy/loggy.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sliver_tools/sliver_tools.dart'; -part 'per_app_proxy_page.g.dart'; - -final _logger = Loggy("PerAppProxySettings"); - -@riverpod -Future> installedPackagesInfo( - InstalledPackagesInfoRef ref, -) async { - return ref - .watch(platformServicesProvider) - .getInstalledPackages() - .getOrElse((err) { - _logger.error("error getting installed packages", err); - throw err; - }).run(); -} - -@riverpod -Future packageIcon( - PackageIconRef ref, - String packageName, -) async { - ref.disposeDelay(const Duration(seconds: 10)); - final bytes = await ref - .watch(platformServicesProvider) - .getPackageIcon(packageName) - .getOrElse((err) { - _logger.warning("error getting package icon", err); - throw err; - }).run(); - return MemoryImage(bytes); -} - class PerAppProxyPage extends HookConsumerWidget with PresLogger { const PerAppProxyPage({super.key}); diff --git a/lib/features/profiles/view/add_profile_modal.dart b/lib/features/profile/add/add_profile_modal.dart similarity index 79% rename from lib/features/profiles/view/add_profile_modal.dart rename to lib/features/profile/add/add_profile_modal.dart index a0ce2ecb..1a470601 100644 --- a/lib/features/profiles/view/add_profile_modal.dart +++ b/lib/features/profile/add/add_profile_modal.dart @@ -3,12 +3,10 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/features/common/qr_code_scanner_screen.dart'; -import 'package:hiddify/features/profiles/notifier/notifier.dart'; +import 'package:hiddify/features/profile/notifier/profile_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -25,40 +23,26 @@ class AddProfileModal extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); + final addProfileState = ref.watch(addProfileProvider); - final mutationTriggered = useState(false); - final addProfileMutation = useMutation( - initialOnFailure: (err) { - mutationTriggered.value = false; - if (err case ProfileInvalidUrlFailure()) { - CustomToast.error( - t.failure.profiles.invalidUrl, - ).show(context); - } else { - CustomAlertDialog.fromErr( - t.presentError(err, action: t.profile.add.failureMsg), - ).show(context); + ref.listen( + addProfileProvider, + (previous, next) { + if (next case AsyncData(value: final _?)) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (context.mounted) context.pop(); + }, + ); } }, - initialOnSuccess: () { - CustomToast.success(t.profile.save.successMsg).show(context); - WidgetsBinding.instance.addPostFrameCallback( - (_) { - if (context.mounted) context.pop(); - }, - ); - }, ); - final showProgressIndicator = - addProfileMutation.state.isInProgress || mutationTriggered.value; - useMemoized(() async { await Future.delayed(const Duration(milliseconds: 200)); if (url != null && context.mounted) { - addProfileMutation.setFuture( - ref.read(profilesNotifierProvider.notifier).addProfile(url!), - ); + if (addProfileState.isLoading) return; + ref.read(addProfileProvider.notifier).add(url!); } }); @@ -112,13 +96,10 @@ class AddProfileModal extends HookConsumerWidget { final captureResult = await Clipboard.getData(Clipboard.kTextPlain) .then((value) => value?.text ?? ''); - if (addProfileMutation.state.isInProgress) return; - mutationTriggered.value = true; - addProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .addProfile(captureResult), - ); + if (addProfileState.isLoading) return; + ref + .read(addProfileProvider.notifier) + .add(captureResult); }, ), const Gap(buttonsGap), @@ -133,15 +114,10 @@ class AddProfileModal extends HookConsumerWidget { await const QRCodeScannerScreen() .open(context); if (captureResult == null) return; - if (addProfileMutation.state.isInProgress) { - return; - } - mutationTriggered.value = true; - addProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .addProfile(captureResult), - ); + if (addProfileState.isLoading) return; + ref + .read(addProfileProvider.notifier) + .add(captureResult); }, ) else @@ -205,7 +181,7 @@ class AddProfileModal extends HookConsumerWidget { const Gap(24), ], ), - crossFadeState: showProgressIndicator + crossFadeState: addProfileState.isLoading ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 250), diff --git a/lib/features/profile/data/profile_data_mapper.dart b/lib/features/profile/data/profile_data_mapper.dart new file mode 100644 index 00000000..6abe7bc3 --- /dev/null +++ b/lib/features/profile/data/profile_data_mapper.dart @@ -0,0 +1,85 @@ +import 'package:drift/drift.dart'; +import 'package:hiddify/core/database/app_database.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; + +extension ProfileEntityMapper on ProfileEntity { + ProfileEntriesCompanion toEntry() { + return switch (this) { + RemoteProfileEntity(:final url, :final options, :final subInfo) => + ProfileEntriesCompanion.insert( + id: id, + type: ProfileType.remote, + active: active, + name: name, + url: Value(url), + lastUpdate: lastUpdate, + updateInterval: Value(options?.updateInterval), + upload: Value(subInfo?.upload), + download: Value(subInfo?.download), + total: Value(subInfo?.total), + expire: Value(subInfo?.expire), + webPageUrl: Value(subInfo?.webPageUrl), + supportUrl: Value(subInfo?.supportUrl), + ), + LocalProfileEntity() => ProfileEntriesCompanion.insert( + id: id, + type: ProfileType.local, + active: active, + name: name, + lastUpdate: lastUpdate, + ), + }; + } +} + +extension RemoteProfileEntityMapper on RemoteProfileEntity { + ProfileEntriesCompanion subInfoPatch() { + return ProfileEntriesCompanion( + upload: Value(subInfo?.upload), + download: Value(subInfo?.download), + total: Value(subInfo?.total), + expire: Value(subInfo?.expire), + webPageUrl: Value(subInfo?.webPageUrl), + supportUrl: Value(subInfo?.supportUrl), + ); + } +} + +extension ProfileEntryMapper on ProfileEntry { + ProfileEntity toEntity() { + ProfileOptions? options; + if (updateInterval != null) { + options = ProfileOptions(updateInterval: updateInterval!); + } + + SubscriptionInfo? subInfo; + if (upload != null && download != null && total != null && expire != null) { + subInfo = SubscriptionInfo( + upload: upload!, + download: download!, + total: total!, + expire: expire!, + webPageUrl: webPageUrl, + supportUrl: supportUrl, + ); + } + + return switch (type) { + ProfileType.remote => RemoteProfileEntity( + id: id, + active: active, + name: name, + url: url!, + lastUpdate: lastUpdate, + options: options, + subInfo: subInfo, + ), + ProfileType.local => LocalProfileEntity( + id: id, + active: active, + name: name, + lastUpdate: lastUpdate, + ), + }; + } +} diff --git a/lib/features/profile/data/profile_data_providers.dart b/lib/features/profile/data/profile_data_providers.dart new file mode 100644 index 00000000..3520b6a5 --- /dev/null +++ b/lib/features/profile/data/profile_data_providers.dart @@ -0,0 +1,34 @@ +import 'package:hiddify/core/database/database_provider.dart'; +import 'package:hiddify/core/http_client/http_client_provider.dart'; +import 'package:hiddify/features/profile/data/profile_data_source.dart'; +import 'package:hiddify/features/profile/data/profile_path_resolver.dart'; +import 'package:hiddify/features/profile/data/profile_repository.dart'; +import 'package:hiddify/services/service_providers.dart'; +import 'package:hiddify/singbox/service/singbox_service_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'profile_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +Future profileRepository(ProfileRepositoryRef ref) async { + final repo = ProfileRepositoryImpl( + profileDataSource: ref.watch(profileDataSourceProvider), + profilePathResolver: ref.watch(profilePathResolverProvider), + singbox: ref.watch(singboxServiceProvider), + dio: ref.watch(httpClientProvider), + ); + await repo.init().getOrElse((l) => throw l).run(); + return repo; +} + +@Riverpod(keepAlive: true) +ProfileDataSource profileDataSource(ProfileDataSourceRef ref) { + return ProfileDao(ref.watch(appDatabaseProvider)); +} + +@Riverpod(keepAlive: true) +ProfilePathResolver profilePathResolver(ProfilePathResolverRef ref) { + return ProfilePathResolver( + ref.watch(filesEditorServiceProvider).dirs.workingDir, + ); +} diff --git a/lib/data/local/dao/profiles_dao.dart b/lib/features/profile/data/profile_data_source.dart similarity index 54% rename from lib/data/local/dao/profiles_dao.dart rename to lib/features/profile/data/profile_data_source.dart index 3d41c934..8d8bc6bd 100644 --- a/lib/data/local/dao/profiles_dao.dart +++ b/lib/features/profile/data/profile_data_source.dart @@ -1,12 +1,24 @@ import 'package:drift/drift.dart'; -import 'package:hiddify/data/local/data_mappers.dart'; -import 'package:hiddify/data/local/database.dart'; -import 'package:hiddify/data/local/tables.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:hiddify/core/database/app_database.dart'; +import 'package:hiddify/core/database/tables/database_tables.dart'; +import 'package:hiddify/features/profile/model/profile_sort_enum.dart'; import 'package:hiddify/utils/utils.dart'; -part 'profiles_dao.g.dart'; +part 'profile_data_source.g.dart'; + +abstract interface class ProfileDataSource { + Future getById(String id); + Future getByUrl(String url); + Stream watchActiveProfile(); + Stream watchProfilesCount(); + Stream> watchAll({ + required ProfilesSort sort, + required SortMode sortMode, + }); + Future insert(ProfileEntriesCompanion entry); + Future edit(String id, ProfileEntriesCompanion entry); + Future deleteById(String id); +} Map orderMap = { SortMode.ascending: OrderingMode.asc, @@ -14,41 +26,45 @@ Map orderMap = { }; @DriftAccessor(tables: [ProfileEntries]) -class ProfilesDao extends DatabaseAccessor - with _$ProfilesDaoMixin, InfraLogger { - ProfilesDao(super.db); +class ProfileDao extends DatabaseAccessor + with _$ProfileDaoMixin, InfraLogger + implements ProfileDataSource { + ProfileDao(super.db); - Future getById(String id) async { + @override + Future getById(String id) async { return (profileEntries.select()..where((tbl) => tbl.id.equals(id))) - .map(ProfileMapper.fromEntry) .getSingleOrNull(); } - Future getProfileByUrl(String url) async { - return (select(profileEntries)..where((tbl) => tbl.url.like('%$url%'))) - .map(ProfileMapper.fromEntry) - .get() - .then((value) => value.firstOrNull); + @override + Future getByUrl(String url) async { + return (select(profileEntries) + ..where((tbl) => tbl.url.like('%$url%')) + ..limit(1)) + .getSingleOrNull(); } - Stream watchActiveProfile() { + @override + Stream watchActiveProfile() { return (profileEntries.select() ..where((tbl) => tbl.active.equals(true)) ..limit(1)) - .map(ProfileMapper.fromEntry) .watchSingleOrNull(); } - Stream watchProfileCount() { + @override + Stream watchProfilesCount() { final count = profileEntries.id.count(); return (profileEntries.selectOnly()..addColumns([count])) .map((exp) => exp.read(count)!) .watchSingle(); } - Stream> watchAll({ - ProfilesSort sort = ProfilesSort.lastUpdate, - SortMode mode = SortMode.ascending, + @override + Stream> watchAll({ + required ProfilesSort sort, + required SortMode sortMode, }) { return (profileEntries.select() ..orderBy( @@ -67,56 +83,47 @@ class ProfilesDao extends DatabaseAccessor switch (sort) { ProfilesSort.name => (tbl) => OrderingTerm( expression: tbl.name, - mode: orderMap[mode]!, + mode: orderMap[sortMode]!, ), ProfilesSort.lastUpdate => (tbl) => OrderingTerm( expression: tbl.lastUpdate, - mode: orderMap[mode]!, + mode: orderMap[sortMode]!, ), }, ], )) - .map(ProfileMapper.fromEntry) .watch(); } - Future create(Profile profile) async { + @override + Future insert(ProfileEntriesCompanion entry) async { await transaction( () async { - if (profile.active) { + if (entry.active.present && entry.active.value) { await update(profileEntries) .write(const ProfileEntriesCompanion(active: Value(false))); } - await into(profileEntries).insert(profile.toCompanion()); + await into(profileEntries).insert(entry); }, ); } - Future edit(Profile patch) async { + @override + Future edit(String id, ProfileEntriesCompanion entry) async { await transaction( () async { - if (patch.active) { + if (entry.active.present && entry.active.value) { await update(profileEntries) .write(const ProfileEntriesCompanion(active: Value(false))); } - await (update(profileEntries)..where((tbl) => tbl.id.equals(patch.id))) - .write(patch.toCompanion()); - }, - ); - } - - Future setAsActive(String id) async { - await transaction( - () async { - await update(profileEntries) - .write(const ProfileEntriesCompanion(active: Value(false))); await (update(profileEntries)..where((tbl) => tbl.id.equals(id))) - .write(const ProfileEntriesCompanion(active: Value(true))); + .write(entry); }, ); } - Future removeById(String id) async { + @override + Future deleteById(String id) async { await transaction( () async { await (delete(profileEntries)..where((tbl) => tbl.id.equals(id))).go(); diff --git a/lib/features/profile/data/profile_parser.dart b/lib/features/profile/data/profile_parser.dart new file mode 100644 index 00000000..5496658b --- /dev/null +++ b/lib/features/profile/data/profile_parser.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:dartx/dartx.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:uuid/uuid.dart'; + +/// parse profile subscription url and headers for data +/// +/// ***name parser hierarchy:*** +/// - `profile-title` header +/// - `content-disposition` header +/// - url fragment (example: `https://example.com/config#user`) -> name=`user` +/// - url filename extension (example: `https://example.com/config.json`) -> name=`config` +/// - if none of these methods return a non-blank string, fallback to `Remote Profile` +abstract class ProfileParser { + static RemoteProfileEntity parse( + String url, + Map> headers, + ) { + var name = ''; + if (headers['profile-title'] case [final titleHeader]) { + if (titleHeader.startsWith("base64:")) { + name = + utf8.decode(base64.decode(titleHeader.replaceFirst("base64:", ""))); + } else { + name = titleHeader.trim(); + } + } + if (headers['content-disposition'] case [final contentDispositionHeader] + when name.isEmpty) { + final regExp = RegExp('filename="([^"]*)"'); + final match = regExp.firstMatch(contentDispositionHeader); + if (match != null && match.groupCount >= 1) { + name = match.group(1) ?? ''; + } + } + if (Uri.parse(url).fragment case final fragment when name.isEmpty) { + name = fragment; + } + if (url.split("/").lastOrNull case final part? when name.isEmpty) { + final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*"); + name = part.replaceFirst(pattern, ""); + } + if (name.isBlank) name = "Remote Profile"; + + ProfileOptions? options; + if (headers['profile-update-interval'] case [final updateIntervalStr]) { + final updateInterval = Duration(hours: int.parse(updateIntervalStr)); + options = ProfileOptions(updateInterval: updateInterval); + } + + SubscriptionInfo? subInfo; + if (headers['subscription-userinfo'] case [final subInfoStr]) { + subInfo = parseSubscriptionInfo(subInfoStr); + } + + if (subInfo != null) { + if (headers['profile-web-page-url'] case [final profileWebPageUrl] + when isUrl(profileWebPageUrl)) { + subInfo = subInfo.copyWith(webPageUrl: profileWebPageUrl); + } + if (headers['support-url'] case [final profileSupportUrl] + when isUrl(profileSupportUrl)) { + subInfo = subInfo.copyWith(supportUrl: profileSupportUrl); + } + } + + return RemoteProfileEntity( + id: const Uuid().v4(), + active: false, + name: name, + url: url, + lastUpdate: DateTime.now(), + options: options, + subInfo: subInfo, + ); + } + + static SubscriptionInfo? parseSubscriptionInfo(String subInfoStr) { + final values = subInfoStr.split(';'); + final map = { + for (final v in values) + v.split('=').first.trim(): + num.tryParse(v.split('=').second.trim())?.toInt(), + }; + if (map + case { + "upload": final upload?, + "download": final download?, + "total": final total, + "expire": final expire + }) { + return SubscriptionInfo( + upload: upload, + download: download, + total: total ?? 9223372036854775807, + expire: DateTime.fromMillisecondsSinceEpoch( + (expire ?? 92233720368) * 1000, + ), + ); + } + return null; + } +} diff --git a/lib/features/profile/data/profile_path_resolver.dart b/lib/features/profile/data/profile_path_resolver.dart new file mode 100644 index 00000000..ea340344 --- /dev/null +++ b/lib/features/profile/data/profile_path_resolver.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class ProfilePathResolver { + const ProfilePathResolver(this._workingDir); + + final Directory _workingDir; + + Directory get directory => Directory(p.join(_workingDir.path, "configs")); + + File file(String fileName) { + return File(p.join(directory.path, "$fileName.json")); + } + + File tempFile(String fileName) => file("$fileName.tmp"); +} diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart new file mode 100644 index 00000000..4f4494c8 --- /dev/null +++ b/lib/features/profile/data/profile_repository.dart @@ -0,0 +1,414 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:drift/drift.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/database/app_database.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/profile/data/profile_data_mapper.dart'; +import 'package:hiddify/features/profile/data/profile_data_source.dart'; +import 'package:hiddify/features/profile/data/profile_parser.dart'; +import 'package:hiddify/features/profile/data/profile_path_resolver.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/model/profile_failure.dart'; +import 'package:hiddify/features/profile/model/profile_sort_enum.dart'; +import 'package:hiddify/singbox/service/singbox_service.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:hiddify/utils/link_parsers.dart'; +import 'package:meta/meta.dart'; +import 'package:retry/retry.dart'; +import 'package:uuid/uuid.dart'; + +abstract interface class ProfileRepository { + TaskEither init(); + TaskEither getById(String id); + Stream> watchActiveProfile(); + Stream> watchHasAnyProfile(); + + Stream>> watchAll({ + ProfilesSort sort = ProfilesSort.lastUpdate, + SortMode sortMode = SortMode.ascending, + }); + + TaskEither addByUrl( + String url, { + bool markAsActive = false, + }); + + TaskEither addByContent( + String content, { + required String name, + bool markAsActive = false, + }); + + TaskEither add(RemoteProfileEntity baseProfile); + + TaskEither generateConfig(String id); + + TaskEither updateSubscription( + RemoteProfileEntity baseProfile, + ); + + TaskEither patch(ProfileEntity profile); + TaskEither setAsActive(String id); + TaskEither deleteById(String id); +} + +class ProfileRepositoryImpl + with ExceptionHandler, InfraLogger + implements ProfileRepository { + ProfileRepositoryImpl({ + required this.profileDataSource, + required this.profilePathResolver, + required this.singbox, + required this.dio, + }); + + final ProfileDataSource profileDataSource; + final ProfilePathResolver profilePathResolver; + final SingboxService singbox; + final Dio dio; + + @override + TaskEither init() { + return exceptionHandler( + () async { + if (!await profilePathResolver.directory.exists()) { + await profilePathResolver.directory.create(recursive: true); + } + return right(unit); + }, + ProfileUnexpectedFailure.new, + ); + } + + @override + TaskEither getById(String id) { + return TaskEither.tryCatch( + () => profileDataSource.getById(id).then((value) => value?.toEntity()), + ProfileUnexpectedFailure.new, + ); + } + + @override + Stream> watchActiveProfile() { + return profileDataSource + .watchActiveProfile() + .map((event) => event?.toEntity()) + .handleExceptions( + (error, stackTrace) { + loggy.error("error watching active profile", error, stackTrace); + return ProfileUnexpectedFailure(error, stackTrace); + }, + ); + } + + @override + Stream> watchHasAnyProfile() { + return profileDataSource + .watchProfilesCount() + .map((event) => event != 0) + .handleExceptions(ProfileUnexpectedFailure.new); + } + + @override + Stream>> watchAll({ + ProfilesSort sort = ProfilesSort.lastUpdate, + SortMode sortMode = SortMode.ascending, + }) { + return profileDataSource + .watchAll(sort: sort, sortMode: sortMode) + .map((event) => event.map((e) => e.toEntity()).toList()) + .handleExceptions(ProfileUnexpectedFailure.new); + } + + @override + TaskEither addByUrl( + String url, { + bool markAsActive = false, + }) { + return exceptionHandler( + () async { + final existingProfile = await profileDataSource + .getByUrl(url) + .then((value) => value?.toEntity()); + if (existingProfile case RemoteProfileEntity()) { + loggy.info("profile with same url already exists, updating"); + final baseProfile = markAsActive + ? existingProfile.copyWith(active: true) + : existingProfile; + return updateSubscription(baseProfile).run(); + } + + final profileId = const Uuid().v4(); + return fetch(url, profileId) + .flatMap( + (profile) => TaskEither( + () async { + await profileDataSource.insert( + profile + .copyWith(id: profileId, active: markAsActive) + .toEntry(), + ); + return right(unit); + }, + ), + ) + .run(); + }, + (error, stackTrace) { + loggy.warning("error adding profile by url", error, stackTrace); + return ProfileUnexpectedFailure(error, stackTrace); + }, + ); + } + + @visibleForTesting + TaskEither validateConfig( + String path, + String tempPath, + bool debug, + ) { + return exceptionHandler( + () { + return singbox + .validateConfigByPath(path, tempPath, debug) + .mapLeft(ProfileFailure.invalidConfig) + .run(); + }, + ProfileUnexpectedFailure.new, + ); + } + + @override + TaskEither addByContent( + String content, { + required String name, + bool markAsActive = false, + }) { + return exceptionHandler( + () async { + final profileId = const Uuid().v4(); + final file = profilePathResolver.file(profileId); + final tempFile = profilePathResolver.tempFile(profileId); + + try { + await tempFile.writeAsString(content); + return await validateConfig(file.path, tempFile.path, false) + .andThen( + () => TaskEither(() async { + final profile = LocalProfileEntity( + id: profileId, + active: markAsActive, + name: name, + lastUpdate: DateTime.now(), + ); + await profileDataSource.insert(profile.toEntry()); + return right(unit); + }), + ) + .run(); + } finally { + if (tempFile.existsSync()) tempFile.deleteSync(); + } + }, + (error, stackTrace) { + loggy.warning("error adding profile by content", error, stackTrace); + return ProfileUnexpectedFailure(error, stackTrace); + }, + ); + } + + @override + TaskEither add(RemoteProfileEntity baseProfile) { + return exceptionHandler( + () async { + return fetch(baseProfile.url, baseProfile.id) + .flatMap( + (remoteProfile) => TaskEither(() async { + await profileDataSource.insert( + baseProfile + .copyWith( + subInfo: remoteProfile.subInfo, + lastUpdate: DateTime.now(), + ) + .toEntry(), + ); + return right(unit); + }), + ) + .run(); + }, + (error, stackTrace) { + loggy.warning("error adding profile", error, stackTrace); + return ProfileUnexpectedFailure(error, stackTrace); + }, + ); + } + + @override + TaskEither generateConfig(String id) { + return TaskEither.Do( + ($) async { + final configFile = profilePathResolver.file(id); + // TODO pass options + return await $( + singbox + .generateFullConfigByPath(configFile.path) + .mapLeft(ProfileFailure.unexpected), + ); + }, + ).handleExceptions(ProfileFailure.unexpected); + } + + @override + TaskEither updateSubscription( + RemoteProfileEntity baseProfile, + ) { + return exceptionHandler( + () async { + loggy.debug( + "updating profile [${baseProfile.name} (${baseProfile.id})]", + ); + return fetch(baseProfile.url, baseProfile.id) + .flatMap( + (remoteProfile) => TaskEither(() async { + await profileDataSource.edit( + baseProfile.id, + remoteProfile + .subInfoPatch() + .copyWith(lastUpdate: Value(DateTime.now())), + ); + return right(unit); + }), + ) + .run(); + }, + (error, stackTrace) { + loggy.warning("error updating profile", error, stackTrace); + return ProfileUnexpectedFailure(error, stackTrace); + }, + ); + } + + @override + TaskEither patch(ProfileEntity profile) { + return exceptionHandler( + () async { + loggy.debug( + "editing profile [${profile.name} (${profile.id})]", + ); + await profileDataSource.edit(profile.id, profile.toEntry()); + return right(unit); + }, + (error, stackTrace) { + loggy.warning("error editing profile", error, stackTrace); + return ProfileUnexpectedFailure(error, stackTrace); + }, + ); + } + + @override + TaskEither setAsActive(String id) { + return TaskEither.tryCatch( + () async { + await profileDataSource.edit( + id, + const ProfileEntriesCompanion(active: Value(true)), + ); + return unit; + }, + ProfileUnexpectedFailure.new, + ); + } + + @override + TaskEither deleteById(String id) { + return TaskEither.tryCatch( + () async { + await profileDataSource.deleteById(id); + await profilePathResolver.file(id).delete(); + return unit; + }, + ProfileUnexpectedFailure.new, + ); + } + + final _subInfoHeaders = [ + 'profile-title', + 'content-disposition', + 'subscription-userinfo', + 'profile-update-interval', + 'support-url', + 'profile-web-page-url', + ]; + + @visibleForTesting + TaskEither fetch( + String url, + String fileName, + ) { + return TaskEither( + () async { + final file = profilePathResolver.file(fileName); + final tempFile = profilePathResolver.tempFile(fileName); + try { + final response = await retry( + () async => dio.download(url.trim(), tempFile.path), + maxAttempts: 3, + ); + final headers = + await _populateHeaders(response.headers.map, tempFile.path); + return await validateConfig(file.path, tempFile.path, false) + .andThen( + () => TaskEither(() async { + final profile = ProfileParser.parse(url, headers); + return right(profile); + }), + ) + .run(); + } finally { + if (tempFile.existsSync()) tempFile.deleteSync(); + } + }, + ); + } + + Future>> _populateHeaders( + Map> headers, + String path, + ) async { + var headersFound = 0; + for (final key in _subInfoHeaders) { + if (headers.containsKey(key)) headersFound++; + } + if (headersFound >= 4) return headers; + + loggy.debug( + "only [$headersFound] headers found, checking file content for possible information", + ); + var content = await File(path).readAsString(); + content = safeDecodeBase64(content); + final lines = content.split("\n"); + final linesToProcess = lines.length < 10 ? lines.length : 10; + for (int i = 0; i < linesToProcess; i++) { + final line = lines[i]; + if (line.startsWith("#") || line.startsWith("//")) { + final index = line.indexOf(':'); + if (index == -1) continue; + final key = line + .substring(0, index) + .replaceFirst(RegExp("^#|//"), "") + .trim() + .toLowerCase(); + final value = line.substring(index + 1).trim(); + if (!headers.keys.contains(key) && + _subInfoHeaders.contains(key) && + value.isNotEmpty) { + headers[key] = [value]; + } + } + } + return headers; + } +} diff --git a/lib/features/profile_detail/notifier/profile_detail_notifier.dart b/lib/features/profile/details/profile_details_notifier.dart similarity index 60% rename from lib/features/profile_detail/notifier/profile_detail_notifier.dart rename to lib/features/profile/details/profile_details_notifier.dart index 6397164e..6cd5df65 100644 --- a/lib/features/profile_detail/notifier/profile_detail_notifier.dart +++ b/lib/features/profile/details/profile_details_notifier.dart @@ -1,25 +1,27 @@ import 'package:dartx/dartx.dart'; import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/features/profile_detail/notifier/profile_detail_state.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_repository.dart'; +import 'package:hiddify/features/profile/details/profile_details_state.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/model/profile_failure.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:uuid/uuid.dart'; -part 'profile_detail_notifier.g.dart'; +part 'profile_details_notifier.g.dart'; @riverpod -class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { +class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger { @override - Future build( + Future build( String id, { String? url, String? profileName, }) async { if (id == 'new') { - return ProfileDetailState( - profile: RemoteProfile( + return ProfileDetailsState( + profile: RemoteProfileEntity( id: const Uuid().v4(), active: true, name: profileName ?? "", @@ -28,7 +30,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { ), ); } - final failureOrProfile = await _profilesRepo.get(id).run(); + final failureOrProfile = await _profilesRepo.getById(id).run(); return failureOrProfile.match( (err) { loggy.warning('failed to load profile', err); @@ -40,13 +42,14 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { throw const ProfileNotFoundFailure(); } _originalProfile = profile; - return ProfileDetailState(profile: profile, isEditing: true); + return ProfileDetailsState(profile: profile, isEditing: true); }, ); } - ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider); - Profile? _originalProfile; + ProfileRepository get _profilesRepo => + ref.read(profileRepositoryProvider).requireValue; + ProfileEntity? _originalProfile; void setField({String? name, String? url, Option? updateInterval}) { if (state case AsyncData(:final value)) { @@ -74,41 +77,47 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { Future save() async { if (state case AsyncData(:final value)) { - if (value.save.isInProgress) return; + if (value.save case AsyncLoading()) return; + final profile = value.profile; Either? failureOrSuccess; - state = AsyncData(value.copyWith(save: const MutationInProgress())); + state = AsyncData(value.copyWith(save: const AsyncLoading())); + switch (profile) { - case RemoteProfile(): + case RemoteProfileEntity(): loggy.debug( 'saving profile, url: [${profile.url}], name: [${profile.name}]', ); if (profile.name.isBlank || profile.url.isBlank) { - loggy.debug('profile save: invalid arguments'); + loggy.debug('save: invalid arguments'); } else if (value.isEditing) { - if (_originalProfile case RemoteProfile(:final url) + if (_originalProfile case RemoteProfileEntity(:final url) when url == profile.url) { loggy.debug('editing profile'); - failureOrSuccess = await _profilesRepo.edit(profile).run(); + failureOrSuccess = await _profilesRepo.patch(profile).run(); } else { loggy.debug('updating profile'); - failureOrSuccess = await _profilesRepo.update(profile).run(); + failureOrSuccess = + await _profilesRepo.updateSubscription(profile).run(); } } else { loggy.debug('adding profile, url: [${profile.url}]'); failureOrSuccess = await _profilesRepo.add(profile).run(); } - case LocalProfile() when value.isEditing: + + case LocalProfileEntity() when value.isEditing: loggy.debug('editing profile'); - failureOrSuccess = await _profilesRepo.edit(profile).run(); + failureOrSuccess = await _profilesRepo.patch(profile).run(); + default: loggy.warning("local profile can't be added manually"); } + state = AsyncData( value.copyWith( save: failureOrSuccess?.fold( - (l) => MutationFailure(l), - (_) => const MutationSuccess(), + (l) => AsyncError(l, StackTrace.current), + (_) => const AsyncData(null), ) ?? value.save, showErrorMessages: true, @@ -119,24 +128,25 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { Future updateProfile() async { if (state case AsyncData(:final value)) { - loggy.debug('updating profile'); - if (value.profile case LocalProfile()) { + if (value.update?.isLoading ?? false || !value.isEditing) return; + if (value.profile case LocalProfileEntity()) { loggy.warning("local profile can't be updated"); return; } - if (value.update.isInProgress || !value.isEditing) return; + final profile = value.profile; - loggy.debug('updating profile'); - state = AsyncData(value.copyWith(update: const MutationInProgress())); + state = AsyncData(value.copyWith(update: const AsyncLoading())); + final failureOrUpdatedProfile = await _profilesRepo - .update(profile as RemoteProfile) - .flatMap((_) => _profilesRepo.get(id)) + .updateSubscription(profile as RemoteProfileEntity) + .flatMap((_) => _profilesRepo.getById(id)) .run(); + state = AsyncData( value.copyWith( update: failureOrUpdatedProfile.match( - (l) => MutationFailure(l), - (_) => const MutationSuccess(), + (l) => AsyncError(l, StackTrace.current), + (_) => const AsyncData(null), ), profile: failureOrUpdatedProfile.match( (_) => profile, @@ -149,17 +159,18 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { Future delete() async { if (state case AsyncData(:final value)) { - if (value.delete.isInProgress) return; + if (value.delete case AsyncLoading()) return; final profile = value.profile; - loggy.debug('deleting profile'); - state = AsyncData(value.copyWith(delete: const MutationInProgress())); - final result = await _profilesRepo.delete(profile.id).run(); + state = AsyncData(value.copyWith(delete: const AsyncLoading())); + state = AsyncData( value.copyWith( - delete: result.match( - (l) => MutationFailure(l), - (_) => const MutationSuccess(), - ), + delete: await AsyncValue.guard(() async { + await _profilesRepo + .deleteById(profile.id) + .getOrElse((l) => throw l) + .run(); + }), ), ); } diff --git a/lib/features/profile_detail/view/profile_detail_page.dart b/lib/features/profile/details/profile_details_page.dart similarity index 78% rename from lib/features/profile_detail/view/profile_detail_page.dart rename to lib/features/profile/details/profile_details_page.dart index 2c8eb0ff..4c55d4ed 100644 --- a/lib/features/profile_detail/view/profile_detail_page.dart +++ b/lib/features/profile/details/profile_details_page.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; import 'package:fpdart/fpdart.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; import 'package:hiddify/features/common/confirmation_dialogs.dart'; -import 'package:hiddify/features/profile_detail/notifier/notifier.dart'; +import 'package:hiddify/features/profile/details/profile_details_notifier.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; import 'package:hiddify/features/settings/widgets/widgets.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:humanizer/humanizer.dart'; -class ProfileDetailPage extends HookConsumerWidget with PresLogger { - const ProfileDetailPage(this.id, {super.key}); +class ProfileDetailsPage extends HookConsumerWidget with PresLogger { + const ProfileDetailsPage(this.id, {super.key}); final String id; @@ -20,65 +20,59 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final provider = profileDetailNotifierProvider(id); + final provider = profileDetailsNotifierProvider(id); final notifier = ref.watch(provider.notifier); ref.listen( - provider.select((data) => data.whenData((value) => value.save)), - (_, asyncSave) { - if (asyncSave case AsyncData(value: final save)) { - switch (save) { - case MutationFailure(:final failure): - final String action; - if (ref.read(provider) case AsyncData(value: final data) - when data.isEditing) { - action = t.profile.save.failureMsg; - } else { - action = t.profile.add.failureMsg; - } - CustomAlertDialog.fromErr(t.presentError(failure, action: action)) - .show(context); - case MutationSuccess(): - CustomToast.success(t.profile.save.successMsg).show(context); - WidgetsBinding.instance.addPostFrameCallback( - (_) { - if (context.mounted) context.pop(); - }, - ); - } + provider.selectAsync((data) => data.save), + (_, next) async { + switch (await next) { + case AsyncData(): + CustomToast.success(t.profile.save.successMsg).show(context); + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (context.mounted) context.pop(); + }, + ); + case AsyncError(:final error): + final String action; + if (ref.read(provider) case AsyncData(value: final data) + when data.isEditing) { + action = t.profile.save.failureMsg; + } else { + action = t.profile.add.failureMsg; + } + CustomAlertDialog.fromErr(t.presentError(error, action: action)) + .show(context); } }, ); ref.listen( - provider.select((data) => data.whenData((value) => value.update)), - (_, asyncUpdate) { - if (asyncUpdate case AsyncData(value: final update)) { - switch (update) { - case MutationFailure(:final failure): - CustomAlertDialog.fromErr(t.presentError(failure)).show(context); - case MutationSuccess(): - CustomToast.success(t.profile.update.successMsg).show(context); - } + provider.selectAsync((data) => data.update), + (_, next) async { + switch (await next) { + case AsyncData(): + CustomToast.success(t.profile.update.successMsg).show(context); + case AsyncError(:final error): + CustomAlertDialog.fromErr(t.presentError(error)).show(context); } }, ); ref.listen( - provider.select((data) => data.whenData((value) => value.delete)), - (_, asyncDelete) { - if (asyncDelete case AsyncData(value: final delete)) { - switch (delete) { - case MutationFailure(:final failure): - CustomToast.error(t.presentShortError(failure)).show(context); - case MutationSuccess(): - CustomToast.success(t.profile.delete.successMsg).show(context); - WidgetsBinding.instance.addPostFrameCallback( - (_) { - if (context.mounted) context.pop(); - }, - ); - } + provider.selectAsync((data) => data.delete), + (_, next) async { + switch (await next) { + case AsyncData(): + CustomToast.success(t.profile.delete.successMsg).show(context); + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (context.mounted) context.pop(); + }, + ); + case AsyncError(:final error): + CustomToast.error(t.presentShortError(error)).show(context); } }, ); @@ -102,7 +96,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger { PopupMenuButton( itemBuilder: (context) { return [ - if (state.profile case RemoteProfile()) + if (state.profile case RemoteProfileEntity()) PopupMenuItem( child: Text(t.profile.update.buttonTxt), onTap: () async { @@ -151,7 +145,10 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger { ), ), if (state.profile - case RemoteProfile(:final url, :final options)) ...[ + case RemoteProfileEntity( + :final url, + :final options + )) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, diff --git a/lib/features/profile/details/profile_details_state.dart b/lib/features/profile/details/profile_details_state.dart new file mode 100644 index 00000000..894abf24 --- /dev/null +++ b/lib/features/profile/details/profile_details_state.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +part 'profile_details_state.freezed.dart'; + +@freezed +class ProfileDetailsState with _$ProfileDetailsState { + const ProfileDetailsState._(); + + const factory ProfileDetailsState({ + required ProfileEntity profile, + @Default(false) bool isEditing, + @Default(false) bool showErrorMessages, + AsyncValue? save, + AsyncValue? update, + AsyncValue? delete, + }) = _ProfileDetailsState; + + bool get isBusy => + save is AsyncLoading || delete is AsyncLoading || update is AsyncLoading; +} diff --git a/lib/features/profile/model/profile_entity.dart b/lib/features/profile/model/profile_entity.dart new file mode 100644 index 00000000..144546be --- /dev/null +++ b/lib/features/profile/model/profile_entity.dart @@ -0,0 +1,57 @@ +import 'package:dartx/dartx.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profile_entity.freezed.dart'; + +enum ProfileType { remote, local } + +@freezed +sealed class ProfileEntity with _$ProfileEntity { + const ProfileEntity._(); + + const factory ProfileEntity.remote({ + required String id, + required bool active, + required String name, + required String url, + required DateTime lastUpdate, + ProfileOptions? options, + SubscriptionInfo? subInfo, + }) = RemoteProfileEntity; + + const factory ProfileEntity.local({ + required String id, + required bool active, + required String name, + required DateTime lastUpdate, + }) = LocalProfileEntity; +} + +@freezed +class ProfileOptions with _$ProfileOptions { + const factory ProfileOptions({ + required Duration updateInterval, + }) = _ProfileOptions; +} + +@freezed +class SubscriptionInfo with _$SubscriptionInfo { + const SubscriptionInfo._(); + + const factory SubscriptionInfo({ + required int upload, + required int download, + required int total, + required DateTime expire, + String? webPageUrl, + String? supportUrl, + }) = _SubscriptionInfo; + + bool get isExpired => expire <= DateTime.now(); + + int get consumption => upload + download; + + double get ratio => (consumption / total).clamp(0, 1); + + Duration get remaining => expire.difference(DateTime.now()); +} diff --git a/lib/domain/profiles/profiles_failure.dart b/lib/features/profile/model/profile_failure.dart similarity index 89% rename from lib/domain/profiles/profiles_failure.dart rename to lib/features/profile/model/profile_failure.dart index 7a9edb8a..440daf38 100644 --- a/lib/domain/profiles/profiles_failure.dart +++ b/lib/features/profile/model/profile_failure.dart @@ -1,8 +1,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; -part 'profiles_failure.freezed.dart'; +part 'profile_failure.freezed.dart'; @freezed sealed class ProfileFailure with _$ProfileFailure, Failure { diff --git a/lib/domain/profiles/profile_enums.dart b/lib/features/profile/model/profile_sort_enum.dart similarity index 78% rename from lib/domain/profiles/profile_enums.dart rename to lib/features/profile/model/profile_sort_enum.dart index 1ad081bd..5852a515 100644 --- a/lib/domain/profiles/profile_enums.dart +++ b/lib/features/profile/model/profile_sort_enum.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/localization/translations.dart'; enum ProfilesSort { lastUpdate, @@ -17,3 +17,5 @@ enum ProfilesSort { name => Icons.sort_by_alpha, }; } + +enum SortMode { ascending, descending } diff --git a/lib/features/profile/notifier/active_profile_notifier.dart b/lib/features/profile/notifier/active_profile_notifier.dart new file mode 100644 index 00000000..74e4214a --- /dev/null +++ b/lib/features/profile/notifier/active_profile_notifier.dart @@ -0,0 +1,31 @@ +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'active_profile_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class ActiveProfile extends _$ActiveProfile with AppLogger { + @override + Stream build() { + loggy.debug("watching active profile"); + return ref + .watch(profileRepositoryProvider) + .requireValue + .watchActiveProfile() + .map((event) => event.getOrElse((l) => throw l)); + } +} + +// TODO: move to specific feature +@Riverpod(keepAlive: true) +Stream hasAnyProfile( + HasAnyProfileRef ref, +) { + return ref + .watch(profileRepositoryProvider) + .requireValue + .watchHasAnyProfile() + .map((event) => event.getOrElse((l) => throw l)); +} diff --git a/lib/features/profile/notifier/profile_notifier.dart b/lib/features/profile/notifier/profile_notifier.dart new file mode 100644 index 00000000..7514b24b --- /dev/null +++ b/lib/features/profile/notifier/profile_notifier.dart @@ -0,0 +1,140 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; +import 'package:hiddify/core/notification/in_app_notification_controller.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_repository.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/model/profile_failure.dart'; +import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'profile_notifier.g.dart'; + +@riverpod +class AddProfile extends _$AddProfile with AppLogger { + @override + AsyncValue build() { + ref.disposeDelay(const Duration(minutes: 1)); + ref.listenSelf( + (previous, next) { + final t = ref.read(translationsProvider); + final notification = ref.read(inAppNotificationControllerProvider); + switch (next) { + case AsyncData(value: final _?): + notification.showSuccessToast(t.profile.save.successMsg); + case AsyncError(:final error): + if (error case ProfileInvalidUrlFailure()) { + notification.showErrorToast(t.failure.profiles.invalidUrl); + } else { + notification.showErrorDialog( + t.presentError(error, action: t.profile.add.failureMsg), + ); + } + } + }, + ); + return const AsyncData(null); + } + + ProfileRepository get _profilesRepo => + ref.read(profileRepositoryProvider).requireValue; + + Future add(String rawInput) async { + if (state.isLoading) return; + state = const AsyncLoading(); + state = await AsyncValue.guard( + () async { + final activeProfile = await ref.read(activeProfileProvider.future); + final markAsActive = + activeProfile == null || ref.read(markNewProfileActiveProvider); + final TaskEither task; + if (LinkParser.parse(rawInput) case (final link)?) { + loggy.debug("adding profile, url: [${link.url}]"); + task = _profilesRepo.addByUrl(link.url, markAsActive: markAsActive); + } else if (LinkParser.protocol(rawInput) case (final parsed)?) { + loggy.debug("adding profile, content"); + task = _profilesRepo.addByContent( + parsed.content, + name: parsed.name, + markAsActive: markAsActive, + ); + } else { + loggy.debug("invalid content"); + throw const ProfileInvalidUrlFailure(); + } + return task.match( + (err) { + loggy.warning("failed to add profile", err); + throw err; + }, + (_) { + loggy.info( + "successfully added profile, mark as active? [$markAsActive]", + ); + return unit; + }, + ).run(); + }, + ); + } +} + +@riverpod +class UpdateProfile extends _$UpdateProfile with AppLogger { + @override + AsyncValue build(String id) { + ref.disposeDelay(const Duration(minutes: 1)); + ref.listenSelf( + (previous, next) { + final t = ref.read(translationsProvider); + final notification = ref.read(inAppNotificationControllerProvider); + switch (next) { + case AsyncData(value: final _?): + notification.showSuccessToast(t.profile.update.successMsg); + case AsyncError(:final error): + notification.showErrorDialog( + t.presentError(error, action: t.profile.update.failureMsg), + ); + } + }, + ); + return const AsyncData(null); + } + + ProfileRepository get _profilesRepo => + ref.read(profileRepositoryProvider).requireValue; + + Future updateProfile(RemoteProfileEntity profile) async { + if (state.isLoading) return; + state = const AsyncLoading(); + state = await AsyncValue.guard( + () async { + return await _profilesRepo.updateSubscription(profile).match( + (err) { + loggy.warning("failed to update profile", err); + throw err; + }, + (_) async { + loggy.info( + 'successfully updated profile, was active? [${profile.active}]', + ); + + await ref.read(activeProfileProvider.future).then((active) async { + if (active != null && active.id == profile.id) { + await ref + .read(connectionNotifierProvider.notifier) + .reconnect(profile.id); + } + }); + return unit; + }, + ).run(); + }, + ); + } +} diff --git a/lib/features/profile/notifier/profiles_update_notifier.dart b/lib/features/profile/notifier/profiles_update_notifier.dart new file mode 100644 index 00000000..222f7f8e --- /dev/null +++ b/lib/features/profile/notifier/profiles_update_notifier.dart @@ -0,0 +1,94 @@ +import 'package:dartx/dartx.dart'; +import 'package:hiddify/core/preferences/preferences_provider.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:meta/meta.dart'; +import 'package:neat_periodic_task/neat_periodic_task.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'profiles_update_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class ForegroundProfilesUpdateNotifier + extends _$ForegroundProfilesUpdateNotifier with AppLogger { + static const prefKey = "profiles_update_check"; + static const interval = Duration(minutes: 15); + + @override + Future build() async { + loggy.debug("initializing"); + var cycleCount = 0; + final scheduler = NeatPeriodicTaskScheduler( + name: 'profiles update worker', + interval: interval, + timeout: const Duration(minutes: 5), + task: () async { + loggy.debug("cycle [${cycleCount++}]"); + await updateProfiles(); + }, + ); + + ref.onDispose(() async { + await scheduler.stop(); + }); + + return scheduler.start(); + } + + @visibleForTesting + Future updateProfiles() async { + try { + final previousRun = DateTime.tryParse( + ref.read(sharedPreferencesProvider).requireValue.getString(prefKey) ?? + "", + ); + + if (previousRun != null && previousRun.add(interval) > DateTime.now()) { + loggy.debug("too soon! previous run: [$previousRun]"); + return; + } + loggy.debug("running, previous run: [$previousRun]"); + + final remoteProfiles = await ref + .read(profileRepositoryProvider) + .requireValue + .watchAll() + .map( + (event) => event.getOrElse((f) { + loggy.error("error getting profiles"); + throw f; + }).whereType(), + ) + .first; + + await for (final profile in Stream.fromIterable(remoteProfiles)) { + final updateInterval = profile.options?.updateInterval; + if (updateInterval != null && + updateInterval <= DateTime.now().difference(profile.lastUpdate)) { + await ref + .read(profileRepositoryProvider) + .requireValue + .updateSubscription(profile) + .mapLeft( + (l) => loggy.debug("error updating profile [${profile.id}]", l), + ) + .map( + (_) => + loggy.debug("profile [${profile.id}] updated successfully"), + ) + .run(); + } else { + loggy.debug( + "skipping profile [${profile.id}] update. last successful update: [${profile.lastUpdate}] - interval: [${profile.options?.updateInterval}]", + ); + } + } + } finally { + await ref + .read(sharedPreferencesProvider) + .requireValue + .setString(prefKey, DateTime.now().toIso8601String()); + } + } +} diff --git a/lib/features/profile/overview/profiles_overview_notifier.dart b/lib/features/profile/overview/profiles_overview_notifier.dart new file mode 100644 index 00000000..7dcc68d4 --- /dev/null +++ b/lib/features/profile/overview/profiles_overview_notifier.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_repository.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/model/profile_sort_enum.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'profiles_overview_notifier.g.dart'; + +@riverpod +class ProfilesOverviewSortNotifier extends _$ProfilesOverviewSortNotifier + with AppLogger { + @override + ({ProfilesSort by, SortMode mode}) build() { + return (by: ProfilesSort.lastUpdate, mode: SortMode.descending); + } + + void changeSort(ProfilesSort sortBy) => + state = (by: sortBy, mode: state.mode); + + void toggleMode() => state = ( + by: state.by, + mode: state.mode == SortMode.ascending + ? SortMode.descending + : SortMode.ascending + ); +} + +@riverpod +class ProfilesOverviewNotifier extends _$ProfilesOverviewNotifier + with AppLogger { + @override + Stream> build() { + final sort = ref.watch(profilesOverviewSortNotifierProvider); + return _profilesRepo + .watchAll(sort: sort.by, sortMode: sort.mode) + .map((event) => event.getOrElse((l) => throw l)); + } + + ProfileRepository get _profilesRepo => + ref.read(profileRepositoryProvider).requireValue; + + Future selectActiveProfile(String id) async { + loggy.debug('changing active profile to: [$id]'); + return _profilesRepo.setAsActive(id).getOrElse((err) { + loggy.warning('failed to set [$id] as active profile', err); + throw err; + }).run(); + } + + Future deleteProfile(ProfileEntity profile) async { + loggy.debug('deleting profile: ${profile.name}'); + await _profilesRepo.deleteById(profile.id).match( + (err) { + loggy.warning('failed to delete profile', err); + throw err; + }, + (_) { + loggy.info( + 'successfully deleted profile, was active? [${profile.active}]', + ); + return unit; + }, + ).run(); + } + + Future exportConfigToClipboard(ProfileEntity profile) async { + await _profilesRepo.generateConfig(profile.id).match( + (err) { + loggy.warning('error generating config', err); + throw err; + }, + (configJson) async { + await Clipboard.setData(ClipboardData(text: configJson)); + }, + ).run(); + } +} diff --git a/lib/features/profiles/view/profiles_modal.dart b/lib/features/profile/overview/profiles_overview_page.dart similarity index 79% rename from lib/features/profiles/view/profiles_modal.dart rename to lib/features/profile/overview/profiles_overview_page.dart index f207d9e8..3e875b13 100644 --- a/lib/features/profiles/view/profiles_modal.dart +++ b/lib/features/profile/overview/profiles_overview_page.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/features/common/profile_tile.dart'; -import 'package:hiddify/features/profiles/notifier/notifier.dart'; -import 'package:hiddify/utils/utils.dart'; +import 'package:hiddify/features/profile/model/profile_sort_enum.dart'; +import 'package:hiddify/features/profile/overview/profiles_overview_notifier.dart'; +import 'package:hiddify/features/profile/widget/profile_tile.dart'; +import 'package:hiddify/utils/placeholders.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class ProfilesModal extends HookConsumerWidget { - const ProfilesModal({ +class ProfilesOverviewModal extends HookConsumerWidget { + const ProfilesOverviewModal({ super.key, this.scrollController, }); @@ -21,7 +20,7 @@ class ProfilesModal extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final asyncProfiles = ref.watch(profilesNotifierProvider); + final asyncProfiles = ref.watch(profilesOverviewNotifierProvider); return Stack( children: [ @@ -85,12 +84,14 @@ class ProfilesSortModal extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); + final sortNotifier = + ref.watch(profilesOverviewSortNotifierProvider.notifier); return AlertDialog( title: Text(t.general.sortBy), content: Consumer( builder: (context, ref, child) { - final sort = ref.watch(profilesSortNotifierProvider); + final sort = ref.watch(profilesOverviewSortNotifierProvider); return SingleChildScrollView( child: Column( children: [ @@ -104,13 +105,9 @@ class ProfilesSortModal extends HookConsumerWidget { title: Text(e.present(t)), onTap: () { if (selected) { - ref - .read(profilesSortNotifierProvider.notifier) - .toggleMode(); + sortNotifier.toggleMode(); } else { - ref - .read(profilesSortNotifierProvider.notifier) - .changeSort(e); + sortNotifier.changeSort(e); } }, selected: selected, @@ -118,9 +115,7 @@ class ProfilesSortModal extends HookConsumerWidget { trailing: selected ? IconButton( onPressed: () { - ref - .read(profilesSortNotifierProvider.notifier) - .toggleMode(); + sortNotifier.toggleMode(); }, icon: AnimatedRotation( turns: arrowTurn, diff --git a/lib/features/common/profile_tile.dart b/lib/features/profile/widget/profile_tile.dart similarity index 77% rename from lib/features/common/profile_tile.dart rename to lib/features/profile/widget/profile_tile.dart index b33f8c2a..6d7f6c4b 100644 --- a/lib/features/common/profile_tile.dart +++ b/lib/features/profile/widget/profile_tile.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/features/common/confirmation_dialogs.dart'; -import 'package:hiddify/features/profiles/notifier/notifier.dart'; +import 'package:hiddify/features/common/qr_code_dialog.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/notifier/profile_notifier.dart'; +import 'package:hiddify/features/profile/overview/profiles_overview_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:percent_indicator/percent_indicator.dart'; @@ -20,7 +22,7 @@ class ProfileTile extends HookConsumerWidget { this.isMain = false, }); - final Profile profile; + final ProfileEntity profile; /// home screen active profile card final bool isMain; @@ -40,7 +42,7 @@ class ProfileTile extends HookConsumerWidget { ); final subInfo = switch (profile) { - RemoteProfile(:final subInfo) => subInfo, + RemoteProfileEntity(:final subInfo) => subInfo, _ => null, }; @@ -63,7 +65,7 @@ class ProfileTile extends HookConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (profile is RemoteProfile || !isMain) ...[ + if (profile is RemoteProfileEntity || !isMain) ...[ SizedBox( width: 48, child: Semantics( @@ -87,13 +89,13 @@ class ProfileTile extends HookConsumerWidget { child: InkWell( onTap: () { if (isMain) { - const ProfilesRoute().go(context); + const ProfilesOverviewRoute().go(context); } else { if (selectActiveMutation.state.isInProgress) return; if (profile.active) return; selectActiveMutation.setFuture( ref - .read(profilesNotifierProvider.notifier) + .read(profilesOverviewNotifierProvider.notifier) .selectActiveProfile(profile.id), ); } @@ -171,39 +173,27 @@ class ProfileTile extends HookConsumerWidget { class ProfileActionButton extends HookConsumerWidget { const ProfileActionButton(this.profile, this.showAllActions, {super.key}); - final Profile profile; + final ProfileEntity profile; final bool showAllActions; @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final updateProfileMutation = useMutation( - initialOnFailure: (err) { - CustomAlertDialog.fromErr( - t.presentError(err, action: t.profile.update.failureMsg), - ).show(context); - }, - initialOnSuccess: () => - CustomToast.success(t.profile.update.successMsg).show(context), - ); - - if (profile case RemoteProfile() when !showAllActions) { + if (profile case RemoteProfileEntity() when !showAllActions) { return Semantics( button: true, - enabled: !updateProfileMutation.state.isInProgress, + enabled: !ref.watch(updateProfileProvider(profile.id)).isLoading, child: Tooltip( message: t.profile.update.tooltip, child: InkWell( onTap: () { - if (updateProfileMutation.state.isInProgress) { + if (ref.read(updateProfileProvider(profile.id)).isLoading) { return; } - updateProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .updateProfile(profile as RemoteProfile), - ); + ref + .read(updateProfileProvider(profile.id).notifier) + .updateProfile(profile as RemoteProfileEntity); }, child: const Icon(Icons.update), ), @@ -237,7 +227,7 @@ class ProfileActionButton extends HookConsumerWidget { class ProfileActionsMenu extends HookConsumerWidget { const ProfileActionsMenu(this.profile, this.builder, {super.key, this.child}); - final Profile profile; + final ProfileEntity profile; final MenuAnchorChildBuilder builder; final Widget? child; @@ -245,14 +235,13 @@ class ProfileActionsMenu extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final updateProfileMutation = useMutation( + final exportConfigMutation = useMutation( initialOnFailure: (err) { - CustomAlertDialog.fromErr( - t.presentError(err, action: t.profile.update.failureMsg), - ).show(context); + CustomToast.error(t.presentShortError(err)).show(context); }, initialOnSuccess: () => - CustomToast.success(t.profile.update.successMsg).show(context), + CustomToast.success(t.profile.share.exportConfigToClipboardSuccess) + .show(context), ); final deleteProfileMutation = useMutation( initialOnFailure: (err) { @@ -263,21 +252,65 @@ class ProfileActionsMenu extends HookConsumerWidget { return MenuAnchor( builder: builder, menuChildren: [ - if (profile case RemoteProfile()) + if (profile case RemoteProfileEntity()) MenuItemButton( leadingIcon: const Icon(Icons.update), child: Text(t.profile.update.buttonTxt), onPressed: () { - if (updateProfileMutation.state.isInProgress) { + if (ref.read(updateProfileProvider(profile.id)).isLoading) { return; } - updateProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .updateProfile(profile as RemoteProfile), - ); + ref + .read(updateProfileProvider(profile.id).notifier) + .updateProfile(profile as RemoteProfileEntity); }, ), + SubmenuButton( + menuChildren: [ + if (profile case RemoteProfileEntity(:final url, :final name)) ...[ + MenuItemButton( + child: Text(t.profile.share.exportSubLinkToClipboard), + onPressed: () async { + final link = LinkParser.generateSubShareLink(url, name); + if (link.isNotEmpty) { + await Clipboard.setData(ClipboardData(text: link)); + if (context.mounted) { + CustomToast(t.profile.share.exportToClipboardSuccess) + .show(context); + } + } + }, + ), + MenuItemButton( + child: Text(t.profile.share.subLinkQrCode), + onPressed: () async { + final link = LinkParser.generateSubShareLink(url, name); + if (link.isNotEmpty) { + await QrCodeDialog( + link, + message: name, + ).show(context); + } + }, + ), + ], + MenuItemButton( + child: Text(t.profile.share.exportConfigToClipboard), + onPressed: () async { + if (exportConfigMutation.state.isInProgress) { + return; + } + exportConfigMutation.setFuture( + ref + .read(profilesOverviewNotifierProvider.notifier) + .exportConfigToClipboard(profile), + ); + }, + ), + ], + leadingIcon: const Icon(Icons.share), + child: Text(t.profile.share.buttonText), + ), MenuItemButton( leadingIcon: const Icon(Icons.edit), child: Text(t.profile.edit.buttonTxt), @@ -300,7 +333,7 @@ class ProfileActionsMenu extends HookConsumerWidget { if (deleteConfirmed) { deleteProfileMutation.setFuture( ref - .read(profilesNotifierProvider.notifier) + .read(profilesOverviewNotifierProvider.notifier) .deleteProfile(profile), ); } diff --git a/lib/features/profile_detail/notifier/notifier.dart b/lib/features/profile_detail/notifier/notifier.dart deleted file mode 100644 index a6381143..00000000 --- a/lib/features/profile_detail/notifier/notifier.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'profile_detail_notifier.dart'; -export 'profile_detail_state.dart'; diff --git a/lib/features/profile_detail/notifier/profile_detail_state.dart b/lib/features/profile_detail/notifier/profile_detail_state.dart deleted file mode 100644 index 344cd7c5..00000000 --- a/lib/features/profile_detail/notifier/profile_detail_state.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/utils/utils.dart'; - -part 'profile_detail_state.freezed.dart'; - -@freezed -class ProfileDetailState with _$ProfileDetailState { - const ProfileDetailState._(); - - const factory ProfileDetailState({ - required Profile profile, - @Default(false) bool isEditing, - @Default(false) bool showErrorMessages, - @Default(MutationState.initial()) MutationState save, - @Default(MutationState.initial()) MutationState update, - @Default(MutationState.initial()) MutationState delete, - }) = _ProfileDetailState; - - bool get isBusy => - save.isInProgress || delete.isInProgress || update.isInProgress; -} diff --git a/lib/features/profile_detail/view/view.dart b/lib/features/profile_detail/view/view.dart deleted file mode 100644 index bcb57dd1..00000000 --- a/lib/features/profile_detail/view/view.dart +++ /dev/null @@ -1 +0,0 @@ -export 'profile_detail_page.dart'; diff --git a/lib/features/profiles/notifier/notifier.dart b/lib/features/profiles/notifier/notifier.dart deleted file mode 100644 index 7fc6e689..00000000 --- a/lib/features/profiles/notifier/notifier.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'profiles_notifier.dart'; -export 'profiles_update_notifier.dart'; diff --git a/lib/features/profiles/notifier/profiles_notifier.dart b/lib/features/profiles/notifier/profiles_notifier.dart deleted file mode 100644 index 87266d2a..00000000 --- a/lib/features/profiles/notifier/profiles_notifier.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'dart:async'; - -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'profiles_notifier.g.dart'; - -@riverpod -class ProfilesSortNotifier extends _$ProfilesSortNotifier with AppLogger { - @override - ({ProfilesSort by, SortMode mode}) build() { - return (by: ProfilesSort.lastUpdate, mode: SortMode.descending); - } - - void changeSort(ProfilesSort sortBy) => - state = (by: sortBy, mode: state.mode); - - void toggleMode() => state = ( - by: state.by, - mode: state.mode == SortMode.ascending - ? SortMode.descending - : SortMode.ascending - ); -} - -@riverpod -class ProfilesNotifier extends _$ProfilesNotifier with AppLogger { - @override - Stream> build() { - final sort = ref.watch(profilesSortNotifierProvider); - return _profilesRepo - .watchAll(sort: sort.by, mode: sort.mode) - .map((event) => event.getOrElse((l) => throw l)); - } - - ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider); - - Future selectActiveProfile(String id) async { - loggy.debug('changing active profile to: [$id]'); - return _profilesRepo.setAsActive(id).getOrElse((err) { - loggy.warning('failed to set [$id] as active profile', err); - throw err; - }).run(); - } - - Future addProfile(String rawInput) async { - final activeProfile = await ref.read(activeProfileProvider.future); - final markAsActive = - activeProfile == null || ref.read(markNewProfileActiveProvider); - final TaskEither task; - if (LinkParser.parse(rawInput) case (final link)?) { - loggy.debug("adding profile, url: [${link.url}]"); - task = ref - .read(profilesRepositoryProvider) - .addByUrl(link.url, markAsActive: markAsActive); - } else if (LinkParser.protocol(rawInput) case (final parsed)?) { - loggy.debug("adding profile, content"); - task = ref.read(profilesRepositoryProvider).addByContent( - parsed.content, - name: parsed.name, - markAsActive: markAsActive, - ); - } else { - loggy.debug("invalid content"); - throw const ProfileInvalidUrlFailure(); - } - return task.match( - (err) { - loggy.warning("failed to add profile", err); - throw err; - }, - (_) { - loggy.info( - "successfully added profile, mark as active? [$markAsActive]", - ); - return unit; - }, - ).run(); - } - - Future updateProfile(RemoteProfile profile) async { - loggy.debug("updating profile"); - return await ref.read(profilesRepositoryProvider).update(profile).match( - (err) { - loggy.warning("failed to update profile", err); - throw err; - }, - (_) async { - loggy.info( - 'successfully updated profile, was active? [${profile.active}]', - ); - - await ref.read(activeProfileProvider.future).then((active) async { - if (active != null && active.id == profile.id) { - await ref - .read(connectivityControllerProvider.notifier) - .reconnect(profile.id); - } - }); - return unit; - }, - ).run(); - } - - Future deleteProfile(Profile profile) async { - loggy.debug('deleting profile: ${profile.name}'); - await _profilesRepo.delete(profile.id).match( - (err) { - loggy.warning('failed to delete profile', err); - throw err; - }, - (_) { - loggy.info( - 'successfully deleted profile, was active? [${profile.active}]', - ); - return unit; - }, - ).run(); - } -} diff --git a/lib/features/profiles/notifier/profiles_update_notifier.dart b/lib/features/profiles/notifier/profiles_update_notifier.dart deleted file mode 100644 index cccc9588..00000000 --- a/lib/features/profiles/notifier/profiles_update_notifier.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/services/service_providers.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'profiles_update_notifier.g.dart'; - -typedef ProfileUpdateResult = ({ - String name, - Either failureOrSuccess -}); - -@Riverpod(keepAlive: true) -class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger { - @override - Stream build() { - _schedule(); - return const Stream.empty(); - } - - Future _schedule() async { - loggy.debug("scheduling profiles update worker"); - return ref.read(cronServiceProvider).schedule( - key: 'profiles_update', - duration: const Duration(minutes: 10), - callback: () async { - final failureOrProfiles = - await ref.read(profilesRepositoryProvider).watchAll().first; - if (failureOrProfiles case Right(value: final profiles)) { - for (final profile in profiles) { - if (profile case RemoteProfile()) { - loggy.debug("checking profile: [${profile.name}]"); - final updateInterval = profile.options?.updateInterval; - if (updateInterval != null && - updateInterval <= - DateTime.now().difference(profile.lastUpdate)) { - final failureOrSuccess = await ref - .read(profilesRepositoryProvider) - .update(profile) - .run(); - state = AsyncData( - (name: profile.name, failureOrSuccess: failureOrSuccess), - ); - } else { - loggy.debug("skipping profile: [${profile.name}]"); - } - } - } - } - }, - ); - } -} diff --git a/lib/features/profiles/view/view.dart b/lib/features/profiles/view/view.dart deleted file mode 100644 index cb18b1bf..00000000 --- a/lib/features/profiles/view/view.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'add_profile_modal.dart'; -export 'profiles_modal.dart'; diff --git a/lib/features/proxies/notifier/notifier.dart b/lib/features/proxies/notifier/notifier.dart deleted file mode 100644 index 60e74984..00000000 --- a/lib/features/proxies/notifier/notifier.dart +++ /dev/null @@ -1 +0,0 @@ -export 'proxies_notifier.dart'; diff --git a/lib/features/proxies/view/view.dart b/lib/features/proxies/view/view.dart deleted file mode 100644 index b35ebe17..00000000 --- a/lib/features/proxies/view/view.dart +++ /dev/null @@ -1 +0,0 @@ -export 'proxies_page.dart'; diff --git a/lib/features/proxies/widgets/widgets.dart b/lib/features/proxies/widgets/widgets.dart deleted file mode 100644 index 6565a8f3..00000000 --- a/lib/features/proxies/widgets/widgets.dart +++ /dev/null @@ -1 +0,0 @@ -export 'proxy_tile.dart'; diff --git a/lib/features/proxy/data/proxy_data_providers.dart b/lib/features/proxy/data/proxy_data_providers.dart new file mode 100644 index 00000000..1c0c0823 --- /dev/null +++ b/lib/features/proxy/data/proxy_data_providers.dart @@ -0,0 +1,12 @@ +import 'package:hiddify/features/proxy/data/proxy_repository.dart'; +import 'package:hiddify/singbox/service/singbox_service_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'proxy_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +ProxyRepository proxyRepository(ProxyRepositoryRef ref) { + return ProxyRepositoryImpl( + singbox: ref.watch(singboxServiceProvider), + ); +} diff --git a/lib/features/proxy/data/proxy_repository.dart b/lib/features/proxy/data/proxy_repository.dart new file mode 100644 index 00000000..f318ecaf --- /dev/null +++ b/lib/features/proxy/data/proxy_repository.dart @@ -0,0 +1,78 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/proxy/model/proxy_entity.dart'; +import 'package:hiddify/features/proxy/model/proxy_failure.dart'; +import 'package:hiddify/singbox/service/singbox_service.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +abstract interface class ProxyRepository { + Stream>> watchProxies(); + TaskEither selectProxy( + String groupTag, + String outboundTag, + ); + TaskEither urlTest(String groupTag); +} + +class ProxyRepositoryImpl + with ExceptionHandler, InfraLogger + implements ProxyRepository { + ProxyRepositoryImpl({required this.singbox}); + + final SingboxService singbox; + + @override + Stream>> watchProxies() { + return singbox.watchOutbounds().map((event) { + final groupWithSelected = { + for (final group in event) group.tag: group.selected, + }; + return event + .map( + (e) => ProxyGroupEntity( + tag: e.tag, + type: e.type, + selected: e.selected, + items: e.items + .map( + (e) => ProxyItemEntity( + tag: e.tag, + type: e.type, + urlTestDelay: e.urlTestDelay, + selectedTag: groupWithSelected[e.tag], + ), + ) + .toList(), + ), + ) + .toList(); + }).handleExceptions( + (error, stackTrace) { + loggy.error("error watching proxies", error, stackTrace); + return ProxyUnexpectedFailure(error, stackTrace); + }, + ); + } + + @override + TaskEither selectProxy( + String groupTag, + String outboundTag, + ) { + return exceptionHandler( + () => singbox + .selectOutbound(groupTag, outboundTag) + .mapLeft(ProxyUnexpectedFailure.new) + .run(), + ProxyUnexpectedFailure.new, + ); + } + + @override + TaskEither urlTest(String groupTag) { + return exceptionHandler( + () => singbox.urlTest(groupTag).mapLeft(ProxyUnexpectedFailure.new).run(), + ProxyUnexpectedFailure.new, + ); + } +} diff --git a/lib/features/proxy/model/proxy_entity.dart b/lib/features/proxy/model/proxy_entity.dart new file mode 100644 index 00000000..2dbf96d0 --- /dev/null +++ b/lib/features/proxy/model/proxy_entity.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/singbox/model/singbox_proxy_type.dart'; + +part 'proxy_entity.freezed.dart'; + +@freezed +class ProxyGroupEntity with _$ProxyGroupEntity { + const ProxyGroupEntity._(); + + const factory ProxyGroupEntity({ + required String tag, + required ProxyType type, + required String selected, + @Default([]) List items, + }) = _ProxyGroupEntity; + + String get name => _sanitizedTag(tag); +} + +@freezed +class ProxyItemEntity with _$ProxyItemEntity { + const ProxyItemEntity._(); + + const factory ProxyItemEntity({ + required String tag, + required ProxyType type, + required int urlTestDelay, + String? selectedTag, + }) = _ProxyItemEntity; + + String get name => _sanitizedTag(tag); + String? get selectedName => + selectedTag == null ? null : _sanitizedTag(selectedTag!); +} + +String _sanitizedTag(String tag) => + tag.replaceFirst(RegExp(r"\§[^]*"), "").trimRight(); diff --git a/lib/features/proxy/model/proxy_failure.dart b/lib/features/proxy/model/proxy_failure.dart new file mode 100644 index 00000000..8fcf1b35 --- /dev/null +++ b/lib/features/proxy/model/proxy_failure.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; + +part 'proxy_failure.freezed.dart'; + +@freezed +sealed class ProxyFailure with _$ProxyFailure, Failure { + const ProxyFailure._(); + + @With() + const factory ProxyFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = ProxyUnexpectedFailure; + + @With() + const factory ProxyFailure.serviceNotRunning() = ServiceNotRunning; + + @override + ({String type, String? message}) present(TranslationsEn t) { + return switch (this) { + ProxyUnexpectedFailure() => ( + type: t.failure.unexpected, + message: null, + ), + ServiceNotRunning() => ( + type: t.failure.singbox.serviceNotRunning, + message: null, + ), + }; + } +} diff --git a/lib/features/proxies/notifier/proxies_notifier.dart b/lib/features/proxy/overview/proxies_overview_notifier.dart similarity index 72% rename from lib/features/proxies/notifier/proxies_notifier.dart rename to lib/features/proxy/overview/proxies_overview_notifier.dart index 743d3d20..6c3fc4a7 100644 --- a/lib/features/proxies/notifier/proxies_notifier.dart +++ b/lib/features/proxy/overview/proxies_overview_notifier.dart @@ -2,18 +2,19 @@ import 'dart:async'; import 'package:combine/combine.dart'; import 'package:dartx/dartx.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/core_service_failure.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/preferences/preferences_provider.dart'; +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; +import 'package:hiddify/features/proxy/data/proxy_data_providers.dart'; +import 'package:hiddify/features/proxy/model/proxy_entity.dart'; +import 'package:hiddify/features/proxy/model/proxy_failure.dart'; import 'package:hiddify/utils/pref_notifier.dart'; import 'package:hiddify/utils/riverpod_utils.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rxdart/rxdart.dart'; -part 'proxies_notifier.g.dart'; +part 'proxies_overview_notifier.g.dart'; enum ProxiesSort { unsorted, @@ -30,7 +31,7 @@ enum ProxiesSort { @Riverpod(keepAlive: true) class ProxiesSortNotifier extends _$ProxiesSortNotifier { late final _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, "proxies_sort_mode", ProxiesSort.unsorted, mapFrom: ProxiesSort.values.byName, @@ -47,18 +48,18 @@ class ProxiesSortNotifier extends _$ProxiesSortNotifier { } @riverpod -class ProxiesNotifier extends _$ProxiesNotifier with AppLogger { +class ProxiesOverviewNotifier extends _$ProxiesOverviewNotifier with AppLogger { @override - Stream> build() async* { + Stream> build() async* { ref.disposeDelay(const Duration(seconds: 15)); final serviceRunning = await ref.watch(serviceRunningProvider.future); if (!serviceRunning) { - throw const CoreServiceNotRunning(); + throw const ServiceNotRunning(); } final sortBy = ref.watch(proxiesSortNotifierProvider); yield* ref - .watch(coreFacadeProvider) - .watchOutbounds() + .watch(proxyRepositoryProvider) + .watchProxies() .throttleTime( const Duration(milliseconds: 100), leading: false, @@ -75,17 +76,17 @@ class ProxiesNotifier extends _$ProxiesNotifier with AppLogger { .asyncMap((proxies) async => _sortOutbounds(proxies, sortBy)); } - Future> _sortOutbounds( - List outbounds, + Future> _sortOutbounds( + List proxies, ProxiesSort sortBy, ) async { return CombineWorker().execute( () { final groupWithSelected = { - for (final o in outbounds) o.tag: o.selected, + for (final o in proxies) o.tag: o.selected, }; - final sortedOutbounds = []; - for (final group in outbounds) { + final sortedProxies = []; + for (final group in proxies) { final sortedItems = switch (sortBy) { ProxiesSort.name => group.items.sortedBy((e) => e.tag), ProxiesSort.delay => group.items.sortedWith((a, b) { @@ -99,7 +100,7 @@ class ProxiesNotifier extends _$ProxiesNotifier with AppLogger { }), ProxiesSort.unsorted => group.items, }; - final items = []; + final items = []; for (final item in sortedItems) { if (groupWithSelected.keys.contains(item.tag)) { items @@ -108,9 +109,9 @@ class ProxiesNotifier extends _$ProxiesNotifier with AppLogger { items.add(item); } } - sortedOutbounds.add(group.copyWith(items: items)); + sortedProxies.add(group.copyWith(items: items)); } - return sortedOutbounds; + return sortedProxies; }, ); } @@ -121,8 +122,8 @@ class ProxiesNotifier extends _$ProxiesNotifier with AppLogger { ); if (state case AsyncData(value: final outbounds)) { await ref - .read(coreFacadeProvider) - .selectOutbound(groupTag, outboundTag) + .read(proxyRepositoryProvider) + .selectProxy(groupTag, outboundTag) .getOrElse((err) { loggy.warning("error selecting outbound", err); throw err; @@ -140,7 +141,10 @@ class ProxiesNotifier extends _$ProxiesNotifier with AppLogger { Future urlTest(String groupTag) async { loggy.debug("testing group: [$groupTag]"); if (state case AsyncData()) { - await ref.read(coreFacadeProvider).urlTest(groupTag).getOrElse((err) { + await ref + .read(proxyRepositoryProvider) + .urlTest(groupTag) + .getOrElse((err) { loggy.error("error testing group", err); throw err; }).run(); diff --git a/lib/features/proxies/view/proxies_page.dart b/lib/features/proxy/overview/proxies_overview_page.dart similarity index 91% rename from lib/features/proxies/view/proxies_page.dart rename to lib/features/proxy/overview/proxies_overview_page.dart index c5952064..1720473a 100644 --- a/lib/features/proxies/view/proxies_page.dart +++ b/lib/features/proxy/overview/proxies_overview_page.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; import 'package:hiddify/features/common/nested_app_bar.dart'; -import 'package:hiddify/features/proxies/notifier/notifier.dart'; -import 'package:hiddify/features/proxies/widgets/widgets.dart'; +import 'package:hiddify/features/proxy/overview/proxies_overview_notifier.dart'; +import 'package:hiddify/features/proxy/widget/proxy_tile.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class ProxiesPage extends HookConsumerWidget with PresLogger { - const ProxiesPage({super.key}); +class ProxiesOverviewPage extends HookConsumerWidget with PresLogger { + const ProxiesOverviewPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final asyncProxies = ref.watch(proxiesNotifierProvider); - final notifier = ref.watch(proxiesNotifierProvider.notifier); + final asyncProxies = ref.watch(proxiesOverviewNotifierProvider); + final notifier = ref.watch(proxiesOverviewNotifierProvider.notifier); final sortBy = ref.watch(proxiesSortNotifierProvider); final selectActiveProxyMutation = useMutation( diff --git a/lib/features/proxies/widgets/proxy_tile.dart b/lib/features/proxy/widget/proxy_tile.dart similarity index 71% rename from lib/features/proxies/widgets/proxy_tile.dart rename to lib/features/proxy/widget/proxy_tile.dart index fe2a2375..e23630e0 100644 --- a/lib/features/proxies/widgets/proxy_tile.dart +++ b/lib/features/proxy/widget/proxy_tile.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/features/proxy/model/proxy_entity.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class ProxyTile extends HookConsumerWidget { +class ProxyTile extends HookConsumerWidget with PresLogger { const ProxyTile( this.proxy, { super.key, @@ -10,7 +11,7 @@ class ProxyTile extends HookConsumerWidget { required this.onSelect, }); - final OutboundGroupItem proxy; + final ProxyItemEntity proxy; final bool selected; final VoidCallback onSelect; @@ -21,7 +22,7 @@ class ProxyTile extends HookConsumerWidget { return ListTile( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Text( - sanitizedTag(proxy.tag), + proxy.name, overflow: TextOverflow.ellipsis, ), leading: Padding( @@ -39,9 +40,9 @@ class ProxyTile extends HookConsumerWidget { TextSpan( text: proxy.type.label, children: [ - if (proxy.selectedTag != null) + if (proxy.selectedName != null) TextSpan( - text: ' (${sanitizedTag(proxy.selectedTag!)})', + text: ' (${proxy.selectedName})', style: Theme.of(context).textTheme.bodySmall, ), ], @@ -56,6 +57,20 @@ class ProxyTile extends HookConsumerWidget { : null, selected: selected, onTap: onSelect, + onLongPress: () async { + showDialog( + context: context, + builder: (context) => AlertDialog( + content: SelectionArea(child: Text(proxy.name)), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(MaterialLocalizations.of(context).closeButtonLabel), + ), + ], + ), + ); + }, horizontalTitleGap: 4, ); } diff --git a/lib/features/about/view/about_page.dart b/lib/features/settings/about/about_page.dart similarity index 91% rename from lib/features/about/view/about_page.dart rename to lib/features/settings/about/about_page.dart index bbec9e5f..fb7f1b81 100644 --- a/lib/features/about/view/about_page.dart +++ b/lib/features/settings/about/about_page.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/features/common/app_update_notifier.dart'; +import 'package:hiddify/core/app_info/app_info_provider.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/constants.dart'; +import 'package:hiddify/core/model/failures.dart'; +import 'package:hiddify/features/app_update/notifier/app_update_notifier.dart'; +import 'package:hiddify/features/app_update/notifier/app_update_state.dart'; +import 'package:hiddify/features/app_update/widget/new_version_dialog.dart'; import 'package:hiddify/features/common/nested_app_bar.dart'; -import 'package:hiddify/features/common/new_version_dialog.dart'; import 'package:hiddify/gen/assets.gen.dart'; import 'package:hiddify/services/service_providers.dart'; import 'package:hiddify/utils/utils.dart'; @@ -18,7 +20,7 @@ class AboutPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final appInfo = ref.watch(appInfoProvider); + final appInfo = ref.watch(appInfoProvider).requireValue; final appUpdate = ref.watch(appUpdateNotifierProvider); ref.listen( diff --git a/lib/features/settings/data/settings_data_providers.dart b/lib/features/settings/data/settings_data_providers.dart new file mode 100644 index 00000000..000f5b73 --- /dev/null +++ b/lib/features/settings/data/settings_data_providers.dart @@ -0,0 +1,9 @@ +import 'package:hiddify/features/settings/data/settings_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'settings_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +SettingsRepository settingsRepository(SettingsRepositoryRef ref) { + return SettingsRepositoryImpl(); +} diff --git a/lib/features/settings/data/settings_repository.dart b/lib/features/settings/data/settings_repository.dart new file mode 100644 index 00000000..3aa949ee --- /dev/null +++ b/lib/features/settings/data/settings_repository.dart @@ -0,0 +1,44 @@ +import 'package:flutter/services.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/settings/model/settings_failure.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +abstract interface class SettingsRepository { + TaskEither isIgnoringBatteryOptimizations(); + TaskEither requestIgnoreBatteryOptimizations(); +} + +class SettingsRepositoryImpl + with ExceptionHandler, InfraLogger + implements SettingsRepository { + final _methodChannel = const MethodChannel("app.hiddify.com/platform"); + + @override + TaskEither isIgnoringBatteryOptimizations() { + return exceptionHandler( + () async { + loggy.debug("checking battery optimization status"); + final result = await _methodChannel + .invokeMethod("is_ignoring_battery_optimizations"); + loggy.debug("is ignoring battery optimizations? [$result]"); + return right(result!); + }, + SettingsUnexpectedFailure.new, + ); + } + + @override + TaskEither requestIgnoreBatteryOptimizations() { + return exceptionHandler( + () async { + loggy.debug("requesting ignore battery optimization"); + final result = await _methodChannel + .invokeMethod("request_ignore_battery_optimizations"); + loggy.debug("ignore battery optimization result: [$result]"); + return right(result!); + }, + SettingsUnexpectedFailure.new, + ); + } +} diff --git a/lib/features/settings/model/settings_failure.dart b/lib/features/settings/model/settings_failure.dart new file mode 100644 index 00000000..345c03a4 --- /dev/null +++ b/lib/features/settings/model/settings_failure.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; + +part 'settings_failure.freezed.dart'; + +@freezed +sealed class SettingsFailure with _$SettingsFailure, Failure { + const SettingsFailure._(); + + @With() + const factory SettingsFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = SettingsUnexpectedFailure; + + @override + ({String type, String? message}) present(TranslationsEn t) { + return switch (this) { + SettingsUnexpectedFailure() => ( + type: t.failure.unexpected, + message: null, + ), + }; + } +} diff --git a/lib/features/settings/notifier/platform_settings_notifier.dart b/lib/features/settings/notifier/platform_settings_notifier.dart new file mode 100644 index 00000000..2afb3af1 --- /dev/null +++ b/lib/features/settings/notifier/platform_settings_notifier.dart @@ -0,0 +1,25 @@ +import 'package:hiddify/features/settings/data/settings_data_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'platform_settings_notifier.g.dart'; + +@riverpod +class IgnoreBatteryOptimizations extends _$IgnoreBatteryOptimizations { + @override + Future build() async { + return ref + .watch(settingsRepositoryProvider) + .isIgnoringBatteryOptimizations() + .getOrElse((l) => false) + .run(); + } + + Future request() async { + await ref + .read(settingsRepositoryProvider) + .requestIgnoreBatteryOptimizations() + .run(); + await Future.delayed(const Duration(seconds: 1)); + ref.invalidateSelf(); + } +} diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/overview/settings_overview_page.dart similarity index 85% rename from lib/features/settings/view/settings_page.dart rename to lib/features/settings/overview/settings_overview_page.dart index 164641fe..3fee58b2 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/overview/settings_overview_page.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/features/common/nested_app_bar.dart'; import 'package:hiddify/features/settings/widgets/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class SettingsPage extends HookConsumerWidget { - const SettingsPage({super.key}); +class SettingsOverviewPage extends HookConsumerWidget { + const SettingsOverviewPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/features/settings/view/config_options_page.dart b/lib/features/settings/view/config_options_page.dart deleted file mode 100644 index a4064fe2..00000000 --- a/lib/features/settings/view/config_options_page.dart +++ /dev/null @@ -1,298 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/data/repository/config_options_store.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/features/settings/widgets/widgets.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:humanizer/humanizer.dart'; - -class ConfigOptionsPage extends HookConsumerWidget { - const ConfigOptionsPage({super.key}); - - static final _default = ConfigOptions.initial; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); - - final options = ref.watch(configPreferencesProvider); - final serviceMode = ref.watch(serviceModeStoreProvider); - - return Scaffold( - appBar: AppBar( - title: Text(t.settings.config.pageTitle), - actions: [ - PopupMenuButton( - itemBuilder: (context) { - return [ - PopupMenuItem( - child: Text(t.general.addToClipboard), - onTap: () { - Clipboard.setData( - ClipboardData(text: options.format()), - ); - }, - ), - ]; - }, - ), - ], - ), - body: ListView( - children: [ - ListTile( - title: Text(t.settings.config.logLevel), - subtitle: Text(options.logLevel.name.toUpperCase()), - onTap: () async { - final logLevel = await SettingsPickerDialog( - title: t.settings.config.logLevel, - selected: options.logLevel, - options: LogLevel.choices, - getTitle: (e) => e.name.toUpperCase(), - resetValue: _default.logLevel, - ).show(context); - if (logLevel == null) return; - await ref.read(logLevelStore.notifier).update(logLevel); - }, - ), - const SettingsDivider(), - SettingsSection(t.settings.config.section.route), - // SwitchListTile( - // title: Text(t.settings.config.bypassLan), - // value: options.bypassLan, - // onChanged: ref.read(bypassLanStore.notifier).update, - // ), - SwitchListTile( - title: Text(t.settings.config.resolveDestination), - value: options.resolveDestination, - onChanged: ref.read(resolveDestinationStore.notifier).update, - ), - ListTile( - title: Text(t.settings.config.ipv6Mode), - subtitle: Text(options.ipv6Mode.present(t)), - onTap: () async { - final ipv6Mode = await SettingsPickerDialog( - title: t.settings.config.ipv6Mode, - selected: options.ipv6Mode, - options: IPv6Mode.values, - getTitle: (e) => e.present(t), - resetValue: _default.ipv6Mode, - ).show(context); - if (ipv6Mode == null) return; - await ref.read(ipv6ModeStore.notifier).update(ipv6Mode); - }, - ), - const SettingsDivider(), - SettingsSection(t.settings.config.section.dns), - ListTile( - title: Text(t.settings.config.remoteDnsAddress), - subtitle: Text(options.remoteDnsAddress), - onTap: () async { - final url = await SettingsInputDialog( - title: t.settings.config.remoteDnsAddress, - initialValue: options.remoteDnsAddress, - resetValue: _default.remoteDnsAddress, - ).show(context); - if (url == null || url.isEmpty) return; - await ref.read(remoteDnsAddressStore.notifier).update(url); - }, - ), - ListTile( - title: Text(t.settings.config.remoteDnsDomainStrategy), - subtitle: Text(options.remoteDnsDomainStrategy.displayName), - onTap: () async { - final domainStrategy = await SettingsPickerDialog( - title: t.settings.config.remoteDnsDomainStrategy, - selected: options.remoteDnsDomainStrategy, - options: DomainStrategy.values, - getTitle: (e) => e.displayName, - resetValue: _default.remoteDnsDomainStrategy, - ).show(context); - if (domainStrategy == null) return; - await ref - .read(remoteDnsDomainStrategyStore.notifier) - .update(domainStrategy); - }, - ), - ListTile( - title: Text(t.settings.config.directDnsAddress), - subtitle: Text(options.directDnsAddress), - onTap: () async { - final url = await SettingsInputDialog( - title: t.settings.config.directDnsAddress, - initialValue: options.directDnsAddress, - resetValue: _default.directDnsAddress, - ).show(context); - if (url == null || url.isEmpty) return; - await ref.read(directDnsAddressStore.notifier).update(url); - }, - ), - ListTile( - title: Text(t.settings.config.directDnsDomainStrategy), - subtitle: Text(options.directDnsDomainStrategy.displayName), - onTap: () async { - final domainStrategy = await SettingsPickerDialog( - title: t.settings.config.directDnsDomainStrategy, - selected: options.directDnsDomainStrategy, - options: DomainStrategy.values, - getTitle: (e) => e.displayName, - resetValue: _default.directDnsDomainStrategy, - ).show(context); - if (domainStrategy == null) return; - await ref - .read(directDnsDomainStrategyStore.notifier) - .update(domainStrategy); - }, - ), - // SwitchListTile( - // title: Text(t.settings.config.enableFakeDns), - // value: options.enableFakeDns, - // onChanged: ref.read(enableFakeDnsStore.notifier).update, - // ), - const SettingsDivider(), - SettingsSection(t.settings.config.section.inbound), - // if (PlatformUtils.isDesktop) ...[ - // SwitchListTile( - // title: Text(t.settings.config.enableTun), - // value: options.enableTun, - // onChanged: ref.read(enableTunStore.notifier).update, - // ), - // SwitchListTile( - // title: Text(t.settings.config.setSystemProxy), - // value: options.setSystemProxy, - // onChanged: ref.read(setSystemProxyStore.notifier).update, - // ), - // ], - ListTile( - title: Text(t.settings.config.serviceMode), - subtitle: Text(serviceMode.present(t)), - onTap: () async { - final pickedMode = await SettingsPickerDialog( - title: t.settings.config.serviceMode, - selected: serviceMode, - options: ServiceMode.choices, - getTitle: (e) => e.present(t), - resetValue: ServiceMode.defaultMode, - ).show(context); - if (pickedMode == null) return; - await ref - .read(serviceModeStoreProvider.notifier) - .update(pickedMode); - }, - ), - SwitchListTile( - title: Text(t.settings.config.strictRoute), - value: options.strictRoute, - onChanged: ref.read(strictRouteStore.notifier).update, - ), - ListTile( - title: Text(t.settings.config.tunImplementation), - subtitle: Text(options.tunImplementation.name), - onTap: () async { - final tunImplementation = await SettingsPickerDialog( - title: t.settings.config.tunImplementation, - selected: options.tunImplementation, - options: TunImplementation.values, - getTitle: (e) => e.name, - resetValue: _default.tunImplementation, - ).show(context); - if (tunImplementation == null) return; - await ref - .read(tunImplementationStore.notifier) - .update(tunImplementation); - }, - ), - ListTile( - title: Text(t.settings.config.mixedPort), - subtitle: Text(options.mixedPort.toString()), - onTap: () async { - final mixedPort = await SettingsInputDialog( - title: t.settings.config.mixedPort, - initialValue: options.mixedPort, - resetValue: _default.mixedPort, - validator: isPort, - mapTo: int.tryParse, - digitsOnly: true, - ).show(context); - if (mixedPort == null) return; - await ref.read(mixedPortStore.notifier).update(mixedPort); - }, - ), - ListTile( - title: Text(t.settings.config.localDnsPort), - subtitle: Text(options.localDnsPort.toString()), - onTap: () async { - final localDnsPort = await SettingsInputDialog( - title: t.settings.config.localDnsPort, - initialValue: options.localDnsPort, - resetValue: _default.localDnsPort, - validator: isPort, - mapTo: int.tryParse, - digitsOnly: true, - ).show(context); - if (localDnsPort == null) return; - await ref.read(localDnsPortStore.notifier).update(localDnsPort); - }, - ), - const SettingsDivider(), - SettingsSection(t.settings.config.section.misc), - ListTile( - title: Text(t.settings.config.connectionTestUrl), - subtitle: Text(options.connectionTestUrl), - onTap: () async { - final url = await SettingsInputDialog( - title: t.settings.config.connectionTestUrl, - initialValue: options.connectionTestUrl, - resetValue: _default.connectionTestUrl, - ).show(context); - if (url == null || url.isEmpty || !isUrl(url)) return; - await ref.read(connectionTestUrlStore.notifier).update(url); - }, - ), - ListTile( - title: Text(t.settings.config.urlTestInterval), - subtitle: Text( - options.urlTestInterval.toApproximateTime(isRelativeToNow: false), - ), - onTap: () async { - final urlTestInterval = await SettingsSliderDialog( - title: t.settings.config.urlTestInterval, - initialValue: options.urlTestInterval.inMinutes.toDouble(), - resetValue: _default.urlTestInterval.inMinutes.toDouble(), - min: 1, - max: 60, - divisions: 60, - labelGen: (value) => Duration(minutes: value.toInt()) - .toApproximateTime(isRelativeToNow: false), - ).show(context); - if (urlTestInterval == null) return; - await ref - .read(urlTestIntervalStore.notifier) - .update(Duration(minutes: urlTestInterval.toInt())); - }, - ), - ListTile( - title: Text(t.settings.config.clashApiPort), - subtitle: Text(options.clashApiPort.toString()), - onTap: () async { - final clashApiPort = await SettingsInputDialog( - title: t.settings.config.clashApiPort, - initialValue: options.clashApiPort, - resetValue: _default.clashApiPort, - validator: isPort, - mapTo: int.tryParse, - digitsOnly: true, - ).show(context); - if (clashApiPort == null) return; - await ref.read(clashApiPortStore.notifier).update(clashApiPort); - }, - ), - const Gap(24), - ], - ), - ); - } -} diff --git a/lib/features/settings/view/view.dart b/lib/features/settings/view/view.dart deleted file mode 100644 index c94412af..00000000 --- a/lib/features/settings/view/view.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'config_options_page.dart'; -export 'per_app_proxy_page.dart'; -export 'settings_page.dart'; diff --git a/lib/features/settings/widgets/advanced_setting_tiles.dart b/lib/features/settings/widgets/advanced_setting_tiles.dart index ef916622..f82281f9 100644 --- a/lib/features/settings/widgets/advanced_setting_tiles.dart +++ b/lib/features/settings/widgets/advanced_setting_tiles.dart @@ -2,11 +2,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; import 'package:hiddify/features/common/general_pref_tiles.dart'; +import 'package:hiddify/features/per_app_proxy/model/per_app_proxy_mode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class AdvancedSettingTiles extends HookConsumerWidget { @@ -30,6 +30,13 @@ class AdvancedSettingTiles extends HookConsumerWidget { await const ConfigOptionsRoute().push(context); }, ), + ListTile( + title: Text(t.settings.geoAssets.pageTitle), + leading: const Icon(Icons.folder), + onTap: () async { + await const GeoAssetsRoute().push(context); + }, + ), if (Platform.isAndroid) ...[ ListTile( title: Text(t.settings.network.perAppProxyPageTitle), diff --git a/lib/features/settings/widgets/general_setting_tiles.dart b/lib/features/settings/widgets/general_setting_tiles.dart index d042c1d4..6c246c43 100644 --- a/lib/features/settings/widgets/general_setting_tiles.dart +++ b/lib/features/settings/widgets/general_setting_tiles.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/preferences/general_preferences.dart'; +import 'package:hiddify/core/theme/app_theme_mode.dart'; +import 'package:hiddify/core/theme/theme_preferences.dart'; import 'package:hiddify/features/common/general_pref_tiles.dart'; import 'package:hiddify/services/auto_start_service.dart'; import 'package:hiddify/utils/utils.dart'; @@ -14,7 +16,7 @@ class GeneralSettingTiles extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final theme = ref.watch(themeProvider); + final themeMode = ref.watch(themePreferencesProvider); return Column( children: [ @@ -43,7 +45,7 @@ class GeneralSettingTiles extends HookConsumerWidget { ), ListTile( title: Text(t.settings.general.themeMode), - subtitle: Text(theme.mode.present(t)), + subtitle: Text(themeMode.present(t)), leading: const Icon(Icons.light_mode), onTap: () async { final selectedThemeMode = await showDialog( @@ -56,7 +58,7 @@ class GeneralSettingTiles extends HookConsumerWidget { (e) => RadioListTile( title: Text(e.present(t)), value: e, - groupValue: theme.mode, + groupValue: themeMode, onChanged: (e) => context.pop(e), ), ) @@ -66,8 +68,8 @@ class GeneralSettingTiles extends HookConsumerWidget { ); if (selectedThemeMode != null) { await ref - .read(themeModeNotifierProvider.notifier) - .update(selectedThemeMode); + .read(themePreferencesProvider.notifier) + .changeThemeMode(selectedThemeMode); } }, ), diff --git a/lib/features/settings/widgets/platform_settings_tiles.dart b/lib/features/settings/widgets/platform_settings_tiles.dart index 11f83834..cdeb18e4 100644 --- a/lib/features/settings/widgets/platform_settings_tiles.dart +++ b/lib/features/settings/widgets/platform_settings_tiles.dart @@ -1,22 +1,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/services/service_providers.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/features/settings/notifier/platform_settings_notifier.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'platform_settings_tiles.g.dart'; - -@riverpod -Future isIgnoringBatteryOptimizations( - IsIgnoringBatteryOptimizationsRef ref, -) async => - ref - .watch(platformServicesProvider) - .isIgnoringBatteryOptimizations() - .getOrElse((l) => false) - .run(); class PlatformSettingsTiles extends HookConsumerWidget { const PlatformSettingsTiles({super.key}); @@ -26,7 +13,7 @@ class PlatformSettingsTiles extends HookConsumerWidget { final t = ref.watch(translationsProvider); final isIgnoringBatteryOptimizations = - ref.watch(isIgnoringBatteryOptimizationsProvider); + ref.watch(ignoreBatteryOptimizationsProvider); ListTile buildIgnoreTile(bool enabled) => ListTile( title: Text(t.settings.general.ignoreBatteryOptimizations), @@ -35,11 +22,8 @@ class PlatformSettingsTiles extends HookConsumerWidget { enabled: enabled, onTap: () async { await ref - .read(platformServicesProvider) - .requestIgnoreBatteryOptimizations() - .run(); - await Future.delayed(const Duration(seconds: 1)); - ref.invalidate(isIgnoringBatteryOptimizationsProvider); + .read(ignoreBatteryOptimizationsProvider.notifier) + .request(); }, ); diff --git a/lib/features/settings/widgets/settings_input_dialog.dart b/lib/features/settings/widgets/settings_input_dialog.dart index 394158da..84a552be 100644 --- a/lib/features/settings/widgets/settings_input_dialog.dart +++ b/lib/features/settings/widgets/settings_input_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/lib/features/stats/data/stats_data_providers.dart b/lib/features/stats/data/stats_data_providers.dart new file mode 100644 index 00000000..e352369c --- /dev/null +++ b/lib/features/stats/data/stats_data_providers.dart @@ -0,0 +1,10 @@ +import 'package:hiddify/features/stats/data/stats_repository.dart'; +import 'package:hiddify/singbox/service/singbox_service_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'stats_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +StatsRepository statsRepository(StatsRepositoryRef ref) { + return StatsRepositoryImpl(singbox: ref.watch(singboxServiceProvider)); +} diff --git a/lib/features/stats/data/stats_repository.dart b/lib/features/stats/data/stats_repository.dart new file mode 100644 index 00000000..49025344 --- /dev/null +++ b/lib/features/stats/data/stats_repository.dart @@ -0,0 +1,33 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/stats/model/stats_entity.dart'; +import 'package:hiddify/features/stats/model/stats_failure.dart'; +import 'package:hiddify/singbox/service/singbox_service.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +abstract interface class StatsRepository { + Stream> watchStats(); +} + +class StatsRepositoryImpl + with ExceptionHandler, InfraLogger + implements StatsRepository { + StatsRepositoryImpl({required this.singbox}); + + final SingboxService singbox; + + @override + Stream> watchStats() { + return singbox + .watchStats() + .map( + (event) => StatsEntity( + uplink: event.uplink, + downlink: event.downlink, + uplinkTotal: event.downlink, + downlinkTotal: event.downlinkTotal, + ), + ) + .handleExceptions(StatsUnexpectedFailure.new); + } +} diff --git a/lib/features/stats/model/stats_entity.dart b/lib/features/stats/model/stats_entity.dart new file mode 100644 index 00000000..13bc86f2 --- /dev/null +++ b/lib/features/stats/model/stats_entity.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'stats_entity.freezed.dart'; + +@freezed +class StatsEntity with _$StatsEntity { + const StatsEntity._(); + + const factory StatsEntity({ + required int uplink, + required int downlink, + required int uplinkTotal, + required int downlinkTotal, + }) = _StatsEntity; + + factory StatsEntity.empty() => const StatsEntity( + uplink: 0, + downlink: 0, + uplinkTotal: 0, + downlinkTotal: 0, + ); +} diff --git a/lib/features/stats/model/stats_failure.dart b/lib/features/stats/model/stats_failure.dart new file mode 100644 index 00000000..bce4ead3 --- /dev/null +++ b/lib/features/stats/model/stats_failure.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; + +part 'stats_failure.freezed.dart'; + +@freezed +sealed class StatsFailure with _$StatsFailure, Failure { + const StatsFailure._(); + + @With() + const factory StatsFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = StatsUnexpectedFailure; + + @override + ({String type, String? message}) present(TranslationsEn t) { + return switch (this) { + StatsUnexpectedFailure() => ( + type: t.failure.unexpected, + message: null, + ), + }; + } +} diff --git a/lib/features/stats/notifier/stats_notifier.dart b/lib/features/stats/notifier/stats_notifier.dart new file mode 100644 index 00000000..d04adbf8 --- /dev/null +++ b/lib/features/stats/notifier/stats_notifier.dart @@ -0,0 +1,23 @@ +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; +import 'package:hiddify/features/stats/data/stats_data_providers.dart'; +import 'package:hiddify/features/stats/model/stats_entity.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'stats_notifier.g.dart'; + +@riverpod +class StatsNotifier extends _$StatsNotifier with AppLogger { + @override + Stream build() async* { + final serviceRunning = await ref.watch(serviceRunningProvider.future); + if (serviceRunning) { + yield* ref + .watch(statsRepositoryProvider) + .watchStats() + .map((event) => event.getOrElse((_) => StatsEntity.empty())); + } else { + yield* Stream.value(StatsEntity.empty()); + } + } +} diff --git a/lib/features/common/side_bar_stats_overview.dart b/lib/features/stats/widget/side_bar_stats_overview.dart similarity index 89% rename from lib/features/common/side_bar_stats_overview.dart rename to lib/features/stats/widget/side_bar_stats_overview.dart index b3395cee..a1b6ff6b 100644 --- a/lib/features/common/side_bar_stats_overview.dart +++ b/lib/features/stats/widget/side_bar_stats_overview.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/features/common/stats_provider.dart'; -import 'package:hiddify/utils/utils.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/features/stats/model/stats_entity.dart'; +import 'package:hiddify/features/stats/notifier/stats_notifier.dart'; +import 'package:hiddify/utils/number_formatters.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class SideBarStatsOverview extends HookConsumerWidget { @@ -13,7 +13,8 @@ class SideBarStatsOverview extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final stats = ref.watch(statsProvider).asData?.value ?? CoreStatus.empty(); + final stats = + ref.watch(statsNotifierProvider).asData?.value ?? StatsEntity.empty(); return Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), diff --git a/lib/features/system_tray/system_tray_controller.dart b/lib/features/system_tray/system_tray_controller.dart index 92d179fe..f4c0906f 100644 --- a/lib/features/system_tray/system_tray_controller.dart +++ b/lib/features/system_tray/system_tray_controller.dart @@ -1,13 +1,15 @@ import 'dart:io'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/data/repository/config_options_store.dart'; -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/constants.dart'; +import 'package:hiddify/core/router/router.dart'; import 'package:hiddify/features/common/window/window_controller.dart'; +import 'package:hiddify/features/config_option/model/config_option_patch.dart'; +import 'package:hiddify/features/config_option/notifier/config_option_notifier.dart'; +import 'package:hiddify/features/connection/model/connection_status.dart'; +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; import 'package:hiddify/gen/assets.gen.dart'; +import 'package:hiddify/singbox/model/singbox_config_enum.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:tray_manager/tray_manager.dart'; @@ -30,10 +32,22 @@ class SystemTrayController extends _$SystemTrayController _initialized = true; } - final connection = await ref.watch(connectivityControllerProvider.future); - final serviceMode = ref.watch(serviceModeStoreProvider); + final connection = switch (ref.watch(connectionNotifierProvider)) { + AsyncData(:final value) => value, + _ => const Disconnected(), + }; + final serviceMode = await ref + .watch(configOptionNotifierProvider.future) + .then((value) => value.serviceMode); final t = ref.watch(translationsProvider); + final destinations = <(String label, String location)>[ + (t.home.pageTitle, const HomeRoute().location), + (t.proxies.pageTitle, const ProxiesRoute().location), + (t.logs.pageTitle, const LogsOverviewRoute().location), + (t.settings.pageTitle, const SettingsRoute().location), + (t.about.pageTitle, const AboutRoute().location), + ]; loggy.debug('updating system tray'); @@ -68,8 +82,24 @@ class SystemTrayController extends _$SystemTrayController final newMode = ServiceMode.values.byName(menuItem.key!); loggy.debug("switching service mode: [$newMode]"); await ref - .read(serviceModeStoreProvider.notifier) - .update(newMode); + .read(configOptionNotifierProvider.notifier) + .updateOption(ConfigOptionPatch(serviceMode: newMode)); + }, + ), + ), + ], + ), + ), + MenuItem.submenu( + label: t.tray.open, + submenu: Menu( + items: [ + ...destinations.map( + (e) => MenuItem( + label: e.$1, + onClick: (_) async { + await ref.read(windowControllerProvider.notifier).show(); + ref.read(routerProvider).go(e.$2); }, ), ), @@ -110,11 +140,11 @@ class SystemTrayController extends _$SystemTrayController } Future handleClickSetAsSystemProxy(MenuItem menuItem) async { - return ref.read(connectivityControllerProvider.notifier).toggleConnection(); + return ref.read(connectionNotifierProvider.notifier).toggleConnection(); } Future handleClickExitApp(MenuItem menuItem) async { - await ref.read(connectivityControllerProvider.notifier).abortConnection(); + await ref.read(connectionNotifierProvider.notifier).abortConnection(); await trayManager.destroy(); return ref.read(windowControllerProvider.notifier).quit(); } diff --git a/lib/gen/singbox_generated_bindings.dart b/lib/gen/singbox_generated_bindings.dart index 25279a7c..6bbdb039 100644 --- a/lib/gen/singbox_generated_bindings.dart +++ b/lib/gen/singbox_generated_bindings.dart @@ -934,6 +934,21 @@ class SingboxNativeLibrary { late final _changeConfigOptions = _changeConfigOptionsPtr .asFunction Function(ffi.Pointer)>(); + ffi.Pointer generateConfig( + ffi.Pointer path, + ) { + return _generateConfig( + path, + ); + } + + late final _generateConfigPtr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer)>>('generateConfig'); + late final _generateConfig = _generateConfigPtr + .asFunction Function(ffi.Pointer)>(); + ffi.Pointer start( ffi.Pointer configPath, int disableMemoryLimit, diff --git a/lib/main_dev.dart b/lib/main_dev.dart index 8dcdbd01..0e845d6b 100644 --- a/lib/main_dev.dart +++ b/lib/main_dev.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:hiddify/bootstrap.dart'; -import 'package:hiddify/domain/environment.dart'; +import 'package:hiddify/core/model/environment.dart'; void main() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/main_prod.dart b/lib/main_prod.dart index 81b713ca..43110c09 100644 --- a/lib/main_prod.dart +++ b/lib/main_prod.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:hiddify/bootstrap.dart'; -import 'package:hiddify/domain/environment.dart'; +import 'package:hiddify/core/model/environment.dart'; void main() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/services/auto_start_service.dart b/lib/services/auto_start_service.dart index bb0a31bf..7c60739e 100644 --- a/lib/services/auto_start_service.dart +++ b/lib/services/auto_start_service.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/app_info/app_info_provider.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -13,7 +13,7 @@ class AutoStartService extends _$AutoStartService with InfraLogger { Future build() async { loggy.debug("initializing"); if (!PlatformUtils.isDesktop) return false; - final appInfo = ref.watch(appInfoProvider); + final appInfo = ref.watch(appInfoProvider).requireValue; launchAtStartup.setup( appName: appInfo.name, appPath: Platform.resolvedExecutable, diff --git a/lib/services/cron_service.dart b/lib/services/cron_service.dart deleted file mode 100644 index d3fe1cb9..00000000 --- a/lib/services/cron_service.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:async'; - -import 'package:dartx/dartx.dart'; -import 'package:hiddify/utils/custom_loggers.dart'; -import 'package:neat_periodic_task/neat_periodic_task.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -const _cronKeyPrefix = "cron_"; - -typedef Job = ( - String key, - Duration duration, - FutureOr Function() callback, -); - -class CronService with InfraLogger { - CronService(this.prefs); - - final SharedPreferences prefs; - - NeatPeriodicTaskScheduler? _scheduler; - Map jobs = {}; - - void schedule({ - required String key, - required Duration duration, - required FutureOr Function() callback, - }) { - loggy.debug("scheduling [$key]"); - jobs[key] = (key, duration, callback); - } - - Future run(Job job) async { - final key = job.$1; - final prefKey = "$_cronKeyPrefix$key"; - final previousRunTime = DateTime.tryParse(prefs.getString(prefKey) ?? ""); - loggy.debug( - "[$key] > ${previousRunTime == null ? "first run" : "previous run on [$previousRunTime]"}", - ); - - if (previousRunTime != null && - previousRunTime.add(job.$2) > DateTime.now()) { - loggy.debug("[$key] > didn't meet criteria"); - return; - } - - final result = await job.$3(); - await prefs.setString(prefKey, DateTime.now().toIso8601String()); - return result; - } - - Future startScheduler() async { - loggy.debug("starting job scheduler"); - await _scheduler?.stop(); - int runCount = 0; - _scheduler = NeatPeriodicTaskScheduler( - name: "cron job scheduler", - interval: const Duration(minutes: 10), - timeout: const Duration(minutes: 5), - minCycle: const Duration(minutes: 2), - task: () { - loggy.debug("in run ${runCount++}"); - return Future.wait(jobs.values.map(run)); - }, - ); - _scheduler!.start(); - } - - Future stopScheduler() async { - loggy.debug("stopping job scheduler"); - return _scheduler?.stop(); - } -} diff --git a/lib/services/files_editor_service.dart b/lib/services/files_editor_service.dart index f9a9d298..86bf47c3 100644 --- a/lib/services/files_editor_service.dart +++ b/lib/services/files_editor_service.dart @@ -1,11 +1,7 @@ import 'dart:io'; -import 'package:flutter/services.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/gen/assets.gen.dart'; import 'package:hiddify/services/platform_services.dart'; import 'package:hiddify/utils/utils.dart'; -import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; typedef Directories = ({ @@ -22,12 +18,6 @@ class FilesEditorService with InfraLogger { late final Directories dirs; Directory get workingDir => dirs.workingDir; - Directory get configsDir => - Directory(p.join(workingDir.path, Constants.configsFolderName)); - Directory get logsDir => dirs.workingDir; - - File get appLogsFile => File(p.join(logsDir.path, "app.log")); - File get coreLogsFile => File(p.join(logsDir.path, "box.log")); Future init() async { dirs = await platformServices.getPaths().getOrElse( @@ -45,23 +35,6 @@ class FilesEditorService with InfraLogger { if (!await dirs.workingDir.exists()) { await dirs.workingDir.create(recursive: true); } - if (!await configsDir.exists()) { - await configsDir.create(recursive: true); - } - - if (await appLogsFile.exists()) { - await appLogsFile.writeAsString(""); - } else { - await appLogsFile.create(recursive: true); - } - - if (await coreLogsFile.exists()) { - await coreLogsFile.writeAsString(""); - } else { - await coreLogsFile.create(recursive: true); - } - - await _populateGeoAssets(); } static Future getDatabaseDirectory() async { @@ -72,29 +45,4 @@ class FilesEditorService with InfraLogger { } return getApplicationDocumentsDirectory(); } - - String configPath(String fileName) { - return p.join(configsDir.path, "$fileName.json"); - } - - String tempConfigPath(String fileName) => configPath("temp_$fileName"); - - Future deleteConfig(String fileName) { - return File(configPath(fileName)).delete(); - } - - Future _populateGeoAssets() async { - loggy.debug('populating geo assets'); - final geoipPath = p.join(workingDir.path, Constants.geoipFileName); - if (!await File(geoipPath).exists()) { - final defaultGeoip = await rootBundle.load(Assets.core.geoip); - await File(geoipPath).writeAsBytes(defaultGeoip.buffer.asInt8List()); - } - - final geositePath = p.join(workingDir.path, Constants.geositeFileName); - if (!await File(geositePath).exists()) { - final defaultGeosite = await rootBundle.load(Assets.core.geosite); - await File(geositePath).writeAsBytes(defaultGeosite.buffer.asInt8List()); - } - } } diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index 267f467c..71aadcaa 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -1,19 +1,10 @@ -import 'dart:convert'; -import 'dart:ffi'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:fpdart/fpdart.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hiddify/services/files_editor_service.dart'; -import 'package:hiddify/utils/ffi_utils.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:posix/posix.dart'; -import 'package:win32/win32.dart'; - -part 'platform_services.freezed.dart'; -part 'platform_services.g.dart'; class PlatformServices with InfraLogger { final _methodChannel = const MethodChannel("app.hiddify.com/platform"); @@ -47,130 +38,4 @@ class PlatformServices with InfraLogger { }, ); } - - Future hasPrivilege() async { - try { - if (Platform.isWindows) { - bool isElevated = false; - withMemory(sizeOf(), (phToken) { - withMemory(sizeOf(), (pReturnedSize) { - withMemory(sizeOf<_TokenElevation>(), - (pElevation) { - if (OpenProcessToken( - GetCurrentProcess(), - TOKEN_QUERY, - phToken.cast(), - ) == - 1) { - if (GetTokenInformation( - phToken.value, - TOKEN_INFORMATION_CLASS.TokenElevation, - pElevation, - sizeOf<_TokenElevation>(), - pReturnedSize, - ) == - 1) { - isElevated = pElevation.ref.tokenIsElevated != 0; - } - } - if (phToken.value != 0) { - CloseHandle(phToken.value); - } - }); - }); - }); - return isElevated; - } else if (Platform.isLinux || Platform.isMacOS) { - final euid = geteuid(); - return euid == 0; - } else { - return true; - } - } catch (e) { - loggy.warning("error checking privilege", e); - return true; // return true so core handles it - } - } - - TaskEither isIgnoringBatteryOptimizations() { - return TaskEither( - () async { - loggy.debug("checking battery optimization status"); - final result = await _methodChannel - .invokeMethod("is_ignoring_battery_optimizations"); - loggy.debug("is ignoring battery optimizations? [$result]"); - return right(result!); - }, - ); - } - - TaskEither requestIgnoreBatteryOptimizations() { - return TaskEither( - () async { - loggy.debug("requesting ignore battery optimization"); - final result = await _methodChannel - .invokeMethod("request_ignore_battery_optimizations"); - loggy.debug("ignore battery optimization result: [$result]"); - return right(result!); - }, - ); - } - - TaskEither> getInstalledPackages() { - return TaskEither( - () async { - loggy.debug("getting installed packages info"); - final result = - await _methodChannel.invokeMethod("get_installed_packages"); - if (result == null) return left("null response"); - return right( - (jsonDecode(result) as List).map((e) { - return InstalledPackageInfo.fromJson(e as Map); - }).toList(), - ); - }, - ); - } - - TaskEither getPackageIcon( - String packageName, - ) { - return TaskEither( - () async { - loggy.debug("getting package [$packageName] icon"); - final result = await _methodChannel.invokeMethod( - "get_package_icon", - {"packageName": packageName}, - ); - if (result == null) return left("null response"); - final Uint8List decoded; - try { - decoded = base64.decode(result); - } catch (e) { - return left("error parsing base64 response"); - } - return right(decoded); - }, - ); - } -} - -@freezed -class InstalledPackageInfo with _$InstalledPackageInfo { - @JsonSerializable(fieldRename: FieldRename.kebab) - const factory InstalledPackageInfo({ - required String packageName, - required String name, - required bool isSystemApp, - }) = _InstalledPackageInfo; - - factory InstalledPackageInfo.fromJson(Map json) => - _$InstalledPackageInfoFromJson(json); -} - -sealed class _TokenElevation extends Struct { - /// A nonzero value if the token has elevated privileges; - /// otherwise, a zero value. - @Int32() - external int tokenIsElevated; } diff --git a/lib/services/service_providers.dart b/lib/services/service_providers.dart index ae7445e0..c568c9c0 100644 --- a/lib/services/service_providers.dart +++ b/lib/services/service_providers.dart @@ -1,8 +1,5 @@ -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/services/cron_service.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/services/platform_services.dart'; -import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'service_providers.g.dart'; @@ -11,16 +8,6 @@ part 'service_providers.g.dart'; FilesEditorService filesEditorService(FilesEditorServiceRef ref) => FilesEditorService(ref.watch(platformServicesProvider)); -@Riverpod(keepAlive: true) -SingboxService singboxService(SingboxServiceRef ref) => SingboxService(); - @Riverpod(keepAlive: true) PlatformServices platformServices(PlatformServicesRef ref) => PlatformServices(); - -@Riverpod(keepAlive: true) -CronService cronService(CronServiceRef ref) { - final service = CronService(ref.watch(sharedPreferencesProvider)); - ref.onDispose(() => service.stopScheduler()); - return service; -} diff --git a/lib/services/singbox/shared.dart b/lib/services/singbox/shared.dart deleted file mode 100644 index 6c34b189..00000000 --- a/lib/services/singbox/shared.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/domain/core_service_failure.dart'; - -mixin ServiceStatus { - ConnectionStatus mapEventToStatus(dynamic event) { - final status = event['status'] as String; - late ConnectionStatus connectionStatus; - switch (status) { - case "Stopped": - final failure = event["alert"] as String?; - final message = event["message"] as String?; - connectionStatus = ConnectionStatus.disconnected( - switch (failure) { - null => null, - "RequestVPNPermission" => MissingVpnPermission(message), - "RequestNotificationPermission" => - MissingNotificationPermission(message), - "EmptyConfiguration" || - "StartCommandServer" || - "CreateService" || - "StartService" => - CoreConnectionFailure(fromServiceAlert(failure, message)), - _ => const UnexpectedConnectionFailure(), - }, - ); - case "Starting": - connectionStatus = const Connecting(); - case "Started": - connectionStatus = const Connected(); - case "Stopping": - connectionStatus = const Disconnecting(); - } - return connectionStatus; - } - - CoreServiceFailure fromServiceAlert(String key, String? message) { - return switch (key) { - "EmptyConfiguration" => InvalidConfig(message), - "StartCommandServer" || - "CreateService" => - CoreServiceCreateFailure(message), - "StartService" => CoreServiceStartFailure(message), - _ => const CoreServiceOtherFailure(), - }; - } -} diff --git a/lib/services/singbox/singbox_service.dart b/lib/services/singbox/singbox_service.dart deleted file mode 100644 index 0d89ab88..00000000 --- a/lib/services/singbox/singbox_service.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:io'; - -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/services/singbox/ffi_singbox_service.dart'; -import 'package:hiddify/services/singbox/mobile_singbox_service.dart'; - -abstract interface class SingboxService { - factory SingboxService() { - if (Platform.isAndroid || Platform.isIOS) { - return MobileSingboxService(); - } else if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { - return FFISingboxService(); - } - throw Exception("unsupported platform"); - } - - Future init(); - - TaskEither setup( - String baseDir, - String workingDir, - String tempDir, - bool debug, - ); - - TaskEither parseConfig( - String path, - String tempPath, - bool debug, - ); - - TaskEither changeConfigOptions(ConfigOptions options); - - TaskEither start(String configPath, bool disableMemoryLimit); - - TaskEither stop(); - - TaskEither restart(String configPath, bool disableMemoryLimit); - - Stream watchOutbounds(); - - TaskEither selectOutbound(String groupTag, String outboundTag); - - TaskEither urlTest(String groupTag); - - Stream watchConnectionStatus(); - - Stream watchStats(); - - Stream> watchLogs(String path); - - TaskEither clearLogs(); -} diff --git a/lib/singbox/model/singbox_config_enum.dart b/lib/singbox/model/singbox_config_enum.dart new file mode 100644 index 00000000..a7da0a56 --- /dev/null +++ b/lib/singbox/model/singbox_config_enum.dart @@ -0,0 +1,68 @@ +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/utils/platform_utils.dart'; +import 'package:json_annotation/json_annotation.dart'; + +enum ServiceMode { + proxy, + systemProxy, + tun; + + static ServiceMode get defaultMode => + PlatformUtils.isDesktop ? systemProxy : tun; + + static List get choices { + if (PlatformUtils.isDesktop) { + return values; + } + return [proxy, tun]; + } + + String present(TranslationsEn t) => switch (this) { + proxy => t.settings.config.serviceModes.proxy, + systemProxy => t.settings.config.serviceModes.systemProxy, + tun => t.settings.config.serviceModes.tun, + }; +} + +@JsonEnum(valueField: 'key') +enum IPv6Mode { + disable("ipv4_only"), + enable("prefer_ipv4"), + prefer("prefer_ipv6"), + only("ipv6_only"); + + const IPv6Mode(this.key); + + final String key; + + String present(TranslationsEn t) => switch (this) { + disable => t.settings.config.ipv6Modes.disable, + enable => t.settings.config.ipv6Modes.enable, + prefer => t.settings.config.ipv6Modes.prefer, + only => t.settings.config.ipv6Modes.only, + }; +} + +@JsonEnum(valueField: 'key') +enum DomainStrategy { + auto(""), + preferIpv6("prefer_ipv6"), + preferIpv4("prefer_ipv4"), + ipv4Only("ipv4_only"), + ipv6Only("ipv6_only"); + + const DomainStrategy(this.key); + + final String key; + + String get displayName => switch (this) { + auto => "auto", + _ => key, + }; +} + +enum TunImplementation { + mixed, + system, + gVisor; +} diff --git a/lib/singbox/model/singbox_config_option.dart b/lib/singbox/model/singbox_config_option.dart new file mode 100644 index 00000000..0a11102a --- /dev/null +++ b/lib/singbox/model/singbox_config_option.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/features/log/model/log_level.dart'; +import 'package:hiddify/singbox/model/singbox_config_enum.dart'; +import 'package:hiddify/singbox/model/singbox_rule.dart'; + +part 'singbox_config_option.freezed.dart'; +part 'singbox_config_option.g.dart'; + +@freezed +class SingboxConfigOption with _$SingboxConfigOption { + const SingboxConfigOption._(); + + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory SingboxConfigOption({ + required bool executeConfigAsIs, + required LogLevel logLevel, + required bool resolveDestination, + required IPv6Mode ipv6Mode, + required String remoteDnsAddress, + required DomainStrategy remoteDnsDomainStrategy, + required String directDnsAddress, + required DomainStrategy directDnsDomainStrategy, + required int mixedPort, + required int localDnsPort, + required TunImplementation tunImplementation, + required int mtu, + required bool strictRoute, + required String connectionTestUrl, + @IntervalConverter() required Duration urlTestInterval, + required bool enableClashApi, + required int clashApiPort, + required bool enableTun, + required bool setSystemProxy, + required bool bypassLan, + required bool enableFakeDns, + required bool independentDnsCache, + required String geoipPath, + required String geositePath, + required List rules, + }) = _SingboxConfigOption; + + String format() { + const encoder = JsonEncoder.withIndent(' '); + return encoder.convert(toJson()); + } + + factory SingboxConfigOption.fromJson(Map json) => + _$SingboxConfigOptionFromJson(json); +} + +class IntervalConverter implements JsonConverter { + const IntervalConverter(); + + @override + Duration fromJson(String json) => + Duration(minutes: int.parse(json.replaceAll("m", ""))); + + @override + String toJson(Duration object) => "${object.inMinutes}m"; +} diff --git a/lib/singbox/model/singbox_outbound.dart b/lib/singbox/model/singbox_outbound.dart new file mode 100644 index 00000000..556c40d7 --- /dev/null +++ b/lib/singbox/model/singbox_outbound.dart @@ -0,0 +1,40 @@ +import 'package:dartx/dartx.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/singbox/model/singbox_proxy_type.dart'; + +part 'singbox_outbound.freezed.dart'; +part 'singbox_outbound.g.dart'; + +@freezed +class SingboxOutboundGroup with _$SingboxOutboundGroup { + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory SingboxOutboundGroup({ + required String tag, + @JsonKey(fromJson: _typeFromJson) required ProxyType type, + required String selected, + @Default([]) List items, + }) = _SingboxOutboundGroup; + + factory SingboxOutboundGroup.fromJson(Map json) => + _$SingboxOutboundGroupFromJson(json); +} + +@freezed +class SingboxOutboundGroupItem with _$SingboxOutboundGroupItem { + const SingboxOutboundGroupItem._(); + + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory SingboxOutboundGroupItem({ + required String tag, + @JsonKey(fromJson: _typeFromJson) required ProxyType type, + required int urlTestDelay, + }) = _SingboxOutboundGroupItem; + + factory SingboxOutboundGroupItem.fromJson(Map json) => + _$SingboxOutboundGroupItemFromJson(json); +} + +ProxyType _typeFromJson(dynamic type) => + ProxyType.values + .firstOrNullWhere((e) => e.key == (type as String?)?.toLowerCase()) ?? + ProxyType.unknown; diff --git a/lib/domain/singbox/proxy_type.dart b/lib/singbox/model/singbox_proxy_type.dart similarity index 100% rename from lib/domain/singbox/proxy_type.dart rename to lib/singbox/model/singbox_proxy_type.dart diff --git a/lib/singbox/model/singbox_rule.dart b/lib/singbox/model/singbox_rule.dart new file mode 100644 index 00000000..b927ffee --- /dev/null +++ b/lib/singbox/model/singbox_rule.dart @@ -0,0 +1,35 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'singbox_rule.freezed.dart'; +part 'singbox_rule.g.dart'; + +@freezed +class SingboxRule with _$SingboxRule { + const SingboxRule._(); + + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory SingboxRule({ + String? domains, + String? ip, + String? port, + String? protocol, + @Default(RuleNetwork.tcpAndUdp) RuleNetwork network, + @Default(RuleOutbound.proxy) RuleOutbound outbound, + }) = _SingboxRule; + + factory SingboxRule.fromJson(Map json) => + _$SingboxRuleFromJson(json); +} + +enum RuleOutbound { proxy, bypass, block } + +@JsonEnum(valueField: 'key') +enum RuleNetwork { + tcpAndUdp(""), + tcp("tcp"), + udp("udp"); + + const RuleNetwork(this.key); + + final String? key; +} diff --git a/lib/singbox/model/singbox_stats.dart b/lib/singbox/model/singbox_stats.dart new file mode 100644 index 00000000..b0badbeb --- /dev/null +++ b/lib/singbox/model/singbox_stats.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'singbox_stats.freezed.dart'; +part 'singbox_stats.g.dart'; + +@freezed +class SingboxStats with _$SingboxStats { + const SingboxStats._(); + + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory SingboxStats({ + required int connectionsIn, + required int connectionsOut, + required int uplink, + required int downlink, + required int uplinkTotal, + required int downlinkTotal, + }) = _SingboxStats; + + factory SingboxStats.fromJson(Map json) => + _$SingboxStatsFromJson(json); +} diff --git a/lib/singbox/model/singbox_status.dart b/lib/singbox/model/singbox_status.dart new file mode 100644 index 00000000..04751b7a --- /dev/null +++ b/lib/singbox/model/singbox_status.dart @@ -0,0 +1,50 @@ +import 'package:dartx/dartx.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'singbox_status.freezed.dart'; + +@freezed +sealed class SingboxStatus with _$SingboxStatus { + const SingboxStatus._(); + + const factory SingboxStatus.stopped({ + SingboxAlert? alert, + String? message, + }) = SingboxStopped; + const factory SingboxStatus.starting() = SingboxStarting; + const factory SingboxStatus.started() = SingboxStarted; + const factory SingboxStatus.stopping() = SingboxStopping; + + factory SingboxStatus.fromEvent(dynamic event) { + switch (event) { + case { + "status": "Stopped", + "alert": final String? alertStr, + "message": final String? messageStr, + }: + final alert = SingboxAlert.values.firstOrNullWhere( + (e) => alertStr?.toLowerCase() == e.name.toLowerCase(), + ); + return SingboxStatus.stopped(alert: alert, message: messageStr); + case {"status": "Stopped"}: + return const SingboxStatus.stopped(); + case {"status": "Starting"}: + return const SingboxStarting(); + case {"status": "Started"}: + return const SingboxStarted(); + case {"status": "Stopping"}: + return const SingboxStopping(); + default: + throw Exception("unexpected status [$event]"); + } + } +} + +enum SingboxAlert { + requestVPNPermission, + requestNotificationPermission, + emptyConfiguration, + startCommandServer, + createService, + startService; +} diff --git a/lib/services/singbox/ffi_singbox_service.dart b/lib/singbox/service/ffi_singbox_service.dart similarity index 73% rename from lib/services/singbox/ffi_singbox_service.dart rename to lib/singbox/service/ffi_singbox_service.dart index 5d9aa308..e7bbeede 100644 --- a/lib/services/singbox/ffi_singbox_service.dart +++ b/lib/singbox/service/ffi_singbox_service.dart @@ -7,11 +7,13 @@ import 'dart:isolate'; import 'package:combine/combine.dart'; import 'package:ffi/ffi.dart'; import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/core/model/directories.dart'; import 'package:hiddify/gen/singbox_generated_bindings.dart'; -import 'package:hiddify/services/singbox/shared.dart'; -import 'package:hiddify/services/singbox/singbox_service.dart'; +import 'package:hiddify/singbox/model/singbox_config_option.dart'; +import 'package:hiddify/singbox/model/singbox_outbound.dart'; +import 'package:hiddify/singbox/model/singbox_stats.dart'; +import 'package:hiddify/singbox/model/singbox_status.dart'; +import 'package:hiddify/singbox/service/singbox_service.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:loggy/loggy.dart'; import 'package:path/path.dart' as p; @@ -20,15 +22,13 @@ import 'package:watcher/watcher.dart'; final _logger = Loggy('FFISingboxService'); -class FFISingboxService - with ServiceStatus, InfraLogger - implements SingboxService { +class FFISingboxService with InfraLogger implements SingboxService { static final SingboxNativeLibrary _box = _gen(); - late final ValueStream _connectionStatus; - late final ReceivePort _connectionStatusReceiver; - Stream? _statusStream; - Stream? _groupsStream; + late final ValueStream _status; + late final ReceivePort _statusReceiver; + Stream? _serviceStatsStream; + Stream>? _outboundsStream; static SingboxNativeLibrary _gen() { String fullPath = ""; @@ -50,34 +50,32 @@ class FFISingboxService @override Future init() async { loggy.debug("initializing"); - _connectionStatusReceiver = ReceivePort('service status receiver'); - final source = _connectionStatusReceiver + _statusReceiver = ReceivePort('service status receiver'); + final source = _statusReceiver .asBroadcastStream() - .map((event) => jsonDecode(event as String) as Map) - .map(mapEventToStatus); - _connectionStatus = ValueConnectableStream.seeded( + .map((event) => jsonDecode(event as String)) + .map(SingboxStatus.fromEvent); + _status = ValueConnectableStream.seeded( source, - const ConnectionStatus.disconnected(), + const SingboxStopped(), ).autoConnect(); } @override TaskEither setup( - String baseDir, - String workingDir, - String tempDir, + Directories directories, bool debug, ) { - final port = _connectionStatusReceiver.sendPort.nativePort; + final port = _statusReceiver.sendPort.nativePort; return TaskEither( () => CombineWorker().execute( () { _box.setupOnce(NativeApi.initializeApiDLData); final err = _box .setup( - baseDir.toNativeUtf8().cast(), - workingDir.toNativeUtf8().cast(), - tempDir.toNativeUtf8().cast(), + directories.baseDir.path.toNativeUtf8().cast(), + directories.workingDir.path.toNativeUtf8().cast(), + directories.tempDir.path.toNativeUtf8().cast(), port, debug ? 1 : 0, ) @@ -93,7 +91,7 @@ class FFISingboxService } @override - TaskEither parseConfig( + TaskEither validateConfigByPath( String path, String tempPath, bool debug, @@ -119,7 +117,7 @@ class FFISingboxService } @override - TaskEither changeConfigOptions(ConfigOptions options) { + TaskEither changeOptions(SingboxConfigOption options) { return TaskEither( () => CombineWorker().execute( () { @@ -137,6 +135,28 @@ class FFISingboxService ); } + @override + TaskEither generateFullConfigByPath( + String path, + ) { + return TaskEither( + () => CombineWorker().execute( + () { + final response = _box + .generateConfig( + path.toNativeUtf8().cast(), + ) + .cast() + .toDartString(); + if (response.startsWith("error")) { + return left(response.replaceFirst("error", "")); + } + return right(response); + }, + ), + ); + } + @override TaskEither start(String configPath, bool disableMemoryLimit) { loggy.debug("starting, memory limit: [${!disableMemoryLimit}]"); @@ -197,32 +217,34 @@ class FFISingboxService } @override - Stream watchConnectionStatus() => _connectionStatus; + Stream watchStatus() => _status; @override - Stream watchStats() { - if (_statusStream != null) return _statusStream!; - final receiver = ReceivePort('status receiver'); + Stream watchStats() { + if (_serviceStatsStream != null) return _serviceStatsStream!; + final receiver = ReceivePort('service stats receiver'); final statusStream = receiver.asBroadcastStream( onCancel: (_) { - _logger.debug("stopping status command client"); + _logger.debug("stopping stats command client"); final err = _box.stopCommandClient(1).cast().toDartString(); if (err.isNotEmpty) { - _logger.error("error stopping status client"); + _logger.error("error stopping stats client"); } receiver.close(); - _statusStream = null; + _serviceStatsStream = null; }, ).map( (event) { if (event case String _) { if (event.startsWith('error:')) { - loggy.error("[status client] error received: $event"); + loggy.error("[service stats client] error received: $event"); throw event.replaceFirst('error:', ""); } - return event; + return SingboxStats.fromJson( + jsonDecode(event) as Map, + ); } - loggy.error("[status client] unexpected type, msg: $event"); + loggy.error("[service status client] unexpected type, msg: $event"); throw "invalid type"; }, ); @@ -236,14 +258,14 @@ class FFISingboxService throw err; } - return _statusStream = statusStream; + return _serviceStatsStream = statusStream; } @override - Stream watchOutbounds() { - if (_groupsStream != null) return _groupsStream!; + Stream> watchOutbounds() { + if (_outboundsStream != null) return _outboundsStream!; final receiver = ReceivePort('outbounds receiver'); - final groupsStream = receiver.asBroadcastStream( + final outboundsStream = receiver.asBroadcastStream( onCancel: (_) { _logger.debug("stopping group command client"); final err = _box.stopCommandClient(4).cast().toDartString(); @@ -251,7 +273,7 @@ class FFISingboxService _logger.error("error stopping group client"); } receiver.close(); - _groupsStream = null; + _outboundsStream = null; }, ).map( (event) { @@ -260,7 +282,9 @@ class FFISingboxService loggy.error("[group client] error received: $event"); throw event.replaceFirst('error:', ""); } - return event; + return (jsonDecode(event) as List).map((e) { + return SingboxOutboundGroup.fromJson(e as Map); + }).toList(); } loggy.error("[group client] unexpected type, msg: $event"); throw "invalid type"; @@ -276,7 +300,7 @@ class FFISingboxService throw err; } - return _groupsStream = groupsStream; + return _outboundsStream = outboundsStream; } @override diff --git a/lib/services/singbox/mobile_singbox_service.dart b/lib/singbox/service/platform_singbox_service.dart similarity index 61% rename from lib/services/singbox/mobile_singbox_service.dart rename to lib/singbox/service/platform_singbox_service.dart index 08887bcf..7ed3b131 100644 --- a/lib/services/singbox/mobile_singbox_service.dart +++ b/lib/singbox/service/platform_singbox_service.dart @@ -2,48 +2,45 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/connectivity/connection_status.dart'; -import 'package:hiddify/domain/singbox/config_options.dart'; -import 'package:hiddify/services/singbox/shared.dart'; -import 'package:hiddify/services/singbox/singbox_service.dart'; -import 'package:hiddify/utils/utils.dart'; +import 'package:hiddify/core/model/directories.dart'; +import 'package:hiddify/singbox/model/singbox_config_option.dart'; +import 'package:hiddify/singbox/model/singbox_outbound.dart'; +import 'package:hiddify/singbox/model/singbox_stats.dart'; +import 'package:hiddify/singbox/model/singbox_status.dart'; +import 'package:hiddify/singbox/service/singbox_service.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; import 'package:rxdart/rxdart.dart'; -class MobileSingboxService - with ServiceStatus, InfraLogger - implements SingboxService { +class PlatformSingboxService with InfraLogger implements SingboxService { late final _methodChannel = const MethodChannel("com.hiddify.app/method"); late final _statusChannel = - const EventChannel("com.hiddify.app/service.status"); + const EventChannel("com.hiddify.app/service.status", JSONMethodCodec()); late final _alertsChannel = - const EventChannel("com.hiddify.app/service.alerts"); + const EventChannel("com.hiddify.app/service.alerts", JSONMethodCodec()); late final _logsChannel = const EventChannel("com.hiddify.app/service.logs"); - late final ValueStream _connectionStatus; + late final ValueStream _status; @override Future init() async { loggy.debug("initializing"); final status = - _statusChannel.receiveBroadcastStream().map(mapEventToStatus); + _statusChannel.receiveBroadcastStream().map(SingboxStatus.fromEvent); final alerts = - _alertsChannel.receiveBroadcastStream().map(mapEventToStatus); - _connectionStatus = - ValueConnectableStream(Rx.merge([status, alerts])).autoConnect(); - await _connectionStatus.first; + _alertsChannel.receiveBroadcastStream().map(SingboxStatus.fromEvent); + _status = ValueConnectableStream(Rx.merge([status, alerts])).autoConnect(); + await _status.first; } @override TaskEither setup( - String baseDir, - String workingDir, - String tempDir, + Directories directories, bool debug, ) => TaskEither.of(unit); @override - TaskEither parseConfig( + TaskEither validateConfigByPath( String path, String tempPath, bool debug, @@ -61,7 +58,7 @@ class MobileSingboxService } @override - TaskEither changeConfigOptions(ConfigOptions options) { + TaskEither changeOptions(SingboxConfigOption options) { return TaskEither( () async { await _methodChannel.invokeMethod( @@ -74,13 +71,31 @@ class MobileSingboxService } @override - TaskEither start(String configPath, bool disableMemoryLimit) { + TaskEither generateFullConfigByPath( + String path, + ) { + return TaskEither( + () async { + final configJson = await _methodChannel.invokeMethod( + "generate_config", + {"path": path}, + ); + if (configJson == null || configJson.isEmpty) { + return left("null response"); + } + return right(configJson); + }, + ); + } + + @override + TaskEither start(String path, bool disableMemoryLimit) { return TaskEither( () async { loggy.debug("starting"); await _methodChannel.invokeMethod( "start", - {"path": configPath}, + {"path": path}, ); return right(unit); }, @@ -99,13 +114,13 @@ class MobileSingboxService } @override - TaskEither restart(String configPath, bool disableMemoryLimit) { + TaskEither restart(String path, bool disableMemoryLimit) { return TaskEither( () async { loggy.debug("restarting"); await _methodChannel.invokeMethod( "restart", - {"path": configPath}, + {"path": path}, ); return right(unit); }, @@ -113,13 +128,15 @@ class MobileSingboxService } @override - Stream watchOutbounds() { + Stream> watchOutbounds() { const channel = EventChannel("com.hiddify.app/groups"); loggy.debug("watching outbounds"); return channel.receiveBroadcastStream().map( (event) { if (event case String _) { - return event; + return (jsonDecode(event) as List).map((e) { + return SingboxOutboundGroup.fromJson(e as Map); + }).toList(); } loggy.error("[group client] unexpected type, msg: $event"); throw "invalid type"; @@ -128,11 +145,11 @@ class MobileSingboxService } @override - Stream watchConnectionStatus() => _connectionStatus; + Stream watchStatus() => _status; @override - Stream watchStats() { - // TODO: implement watchStatus + Stream watchStats() { + // TODO: implement watchStats return const Stream.empty(); } diff --git a/lib/singbox/service/singbox_service.dart b/lib/singbox/service/singbox_service.dart new file mode 100644 index 00000000..3ad2d8c5 --- /dev/null +++ b/lib/singbox/service/singbox_service.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/model/directories.dart'; +import 'package:hiddify/singbox/model/singbox_config_option.dart'; +import 'package:hiddify/singbox/model/singbox_outbound.dart'; +import 'package:hiddify/singbox/model/singbox_stats.dart'; +import 'package:hiddify/singbox/model/singbox_status.dart'; +import 'package:hiddify/singbox/service/ffi_singbox_service.dart'; +import 'package:hiddify/singbox/service/platform_singbox_service.dart'; + +abstract interface class SingboxService { + factory SingboxService() { + if (Platform.isAndroid || Platform.isIOS) { + return PlatformSingboxService(); + } else if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + return FFISingboxService(); + } + throw Exception("unsupported platform"); + } + + Future init(); + + TaskEither setup( + Directories directories, + bool debug, + ); + + TaskEither validateConfigByPath( + String path, + String tempPath, + bool debug, + ); + + TaskEither changeOptions(SingboxConfigOption options); + + TaskEither generateFullConfigByPath( + String path, + ); + + TaskEither start(String path, bool disableMemoryLimit); + + TaskEither stop(); + + TaskEither restart(String path, bool disableMemoryLimit); + + Stream> watchOutbounds(); + + TaskEither selectOutbound(String groupTag, String outboundTag); + + TaskEither urlTest(String groupTag); + + Stream watchStatus(); + + Stream watchStats(); + + Stream> watchLogs(String path); + + TaskEither clearLogs(); +} diff --git a/lib/singbox/service/singbox_service_provider.dart b/lib/singbox/service/singbox_service_provider.dart new file mode 100644 index 00000000..569d7ff1 --- /dev/null +++ b/lib/singbox/service/singbox_service_provider.dart @@ -0,0 +1,9 @@ +import 'package:hiddify/singbox/service/singbox_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'singbox_service_provider.g.dart'; + +@Riverpod(keepAlive: true) +SingboxService singboxService(SingboxServiceRef ref) { + return SingboxService(); +} diff --git a/lib/utils/link_parsers.dart b/lib/utils/link_parsers.dart index 378ea4e3..b481564b 100644 --- a/lib/utils/link_parsers.dart +++ b/lib/utils/link_parsers.dart @@ -1,12 +1,25 @@ import 'dart:convert'; -import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/singbox/model/singbox_proxy_type.dart'; import 'package:hiddify/utils/validators.dart'; typedef ProfileLink = ({String url, String name}); // TODO: test and improve abstract class LinkParser { + static String generateSubShareLink(String url, [String? name]) { + final uri = Uri.tryParse(url); + if (uri == null) return ''; + return Uri( + scheme: 'hiddify', + host: 'install-sub', + queryParameters: { + "url": uri.toString(), + if (name != null) "name": name, + }, + ).toString(); + } + // protocols schemas static const protocols = {'clash', 'clashmeta', 'sing-box', 'hiddify'}; static const rawProtocols = { diff --git a/lib/utils/mutation_state.dart b/lib/utils/mutation_state.dart index b83f9a4c..53aab501 100644 --- a/lib/utils/mutation_state.dart +++ b/lib/utils/mutation_state.dart @@ -1,5 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/core/model/failures.dart'; part 'mutation_state.freezed.dart'; diff --git a/lib/utils/pref_notifier.dart b/lib/utils/pref_notifier.dart index 90e6dfd3..6d05cc2d 100644 --- a/lib/utils/pref_notifier.dart +++ b/lib/utils/pref_notifier.dart @@ -1,4 +1,4 @@ -import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/core/preferences/preferences_provider.dart'; import 'package:hiddify/utils/custom_loggers.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -75,7 +75,7 @@ class PrefNotifier extends AutoDisposeNotifier with InfraLogger { final P Function(T)? _mapTo; late final Pref _pref = Pref( - ref.watch(sharedPreferencesProvider), + ref.watch(sharedPreferencesProvider).requireValue, _key, _defaultValue, mapFrom: _mapFrom, diff --git a/lib/utils/sentry_utils.dart b/lib/utils/sentry_utils.dart index 44971fd6..a8976a50 100644 --- a/lib/utils/sentry_utils.dart +++ b/lib/utils/sentry_utils.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/core/model/failures.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; diff --git a/libcore b/libcore index 5c8b283d..24648282 160000 --- a/libcore +++ b/libcore @@ -1 +1 @@ -Subproject commit 5c8b283d9cd84a4b1412dd65572ae039a822de78 +Subproject commit 24648282bc0a47cf19ccc3ca40ce32e898b2765f diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index e1759bc3..600ec847 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 61c93940..62bee187 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,8 +1,10 @@ PODS: + - cupertino_http (0.0.1): + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - mobile_scanner (3.5.2): + - mobile_scanner (3.5.5): - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS @@ -13,13 +15,13 @@ PODS: - FlutterMacOS - screen_retriever (0.0.1): - FlutterMacOS - - Sentry/HybridSDK (8.14.2): - - SentryPrivate (= 8.14.2) + - Sentry/HybridSDK (8.15.2): + - SentryPrivate (= 8.15.2) - sentry_flutter (0.0.1): - Flutter - FlutterMacOS - - Sentry/HybridSDK (= 8.14.2) - - SentryPrivate (8.14.2) + - Sentry/HybridSDK (= 8.15.2) + - SentryPrivate (8.15.2) - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): @@ -48,6 +50,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - cupertino_http (from `Flutter/ephemeral/.symlinks/plugins/cupertino_http/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) @@ -70,6 +73,8 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: + cupertino_http: + :path: Flutter/ephemeral/.symlinks/plugins/cupertino_http/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos FlutterMacOS: @@ -100,16 +105,17 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + cupertino_http: afa11b9e2786b62da2671e4ddd32caf792503748 device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - mobile_scanner: 621cf2c34e1c74ae7ce5c6793638ab600723bdea + mobile_scanner: d12930b68bf502497f78b8b5182aeccfaa1e04f6 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 protocol_handler: 587e1caf6c0b92ce351ab14081968dae49cb8cc6 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - Sentry: e0ea366f95ebb68f26d6030d8c22d6b2e6d23dd0 - sentry_flutter: 9a04c51c373d76ee22167bf1e65bc468c0a91fed - SentryPrivate: 949a21fa59872427edc73b524c3ec8456761d97f + Sentry: 6f5742b4c47c17c9adcf265f6f328cf4a0ed1923 + sentry_flutter: 2c309a1d4b45e59d02cfa15795705687f1e2081b + SentryPrivate: b2f7996f37781080f04a946eb4e377ff63c64195 share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 sqlite3: 6e2d4a4879854d0ec86b476bf3c3e30870bac273 diff --git a/project.inlang.json b/project.inlang.json index 8f748ec1..3c284a91 100644 --- a/project.inlang.json +++ b/project.inlang.json @@ -4,7 +4,8 @@ "languageTags": [ "en", "fa", - "ru", + "ru", + "tr", "zh" ], "modules": [ @@ -20,5 +21,4 @@ "@:" ] } -} - +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 2df07782..e4d7966e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: ansicolor - sha256: "607f8fa9786f392043f169898923e6c59b4518242b68b8862eb8a8b7d9c30b4a" + sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" archive: dependency: transitive description: @@ -109,10 +109,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "67d591d602906ef9201caf93452495ad1812bea2074f04e25dbd7c133785821b" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.7" build_runner_core: dependency: transitive description: @@ -197,10 +197,10 @@ packages: dependency: transitive description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" color: dependency: transitive description: @@ -225,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + cronet_http: + dependency: transitive + description: + name: cronet_http + sha256: "567170c4bb3d237991b852f446c1b52a3325de8b584a82d195f31dd76310d7d1" + url: "https://pub.dev" + source: hosted + version: "0.4.1" cross_file: dependency: transitive description: @@ -257,6 +265,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" + cupertino_http: + dependency: transitive + description: + name: cupertino_http + sha256: "93cdb8a3aa8bc71caf62fdaca66e613871a6aa1388c7cd7f39b6366b5d2f5af6" + url: "https://pub.dev" + source: hosted + version: "1.1.0" cupertino_icons: dependency: "direct main" description: @@ -269,26 +285,26 @@ packages: dependency: "direct dev" description: name: custom_lint - sha256: f9a828b696930cf8307f9a3617b2b65c9b370e484dc845d69100cadb77506778 + sha256: "198ec6b8e084d22f508a76556c9afcfb71706ad3f42b083fe0ee923351a96d90" url: "https://pub.dev" source: hosted - version: "0.5.6" + version: "0.5.7" custom_lint_builder: dependency: transitive description: name: custom_lint_builder - sha256: c6f656a4d83385fc0656ae60410ed06bb382898c45627bfb8bbaa323aea97883 + sha256: dfcfa987d2bd9d0ba751ef4bdef0f6c1aa0062f2a67fe716fd5f3f8b709d6418 url: "https://pub.dev" source: hosted - version: "0.5.6" + version: "0.5.7" custom_lint_core: dependency: transitive description: name: custom_lint_core - sha256: e20a67737adcf0cf2465e734dd624af535add11f9edd1f2d444909b5b0749650 + sha256: f84c3fe2f27ef3b8831953e477e59d4a29c2952623f9eac450d7b40d9cdd94cc url: "https://pub.dev" source: hosted - version: "0.5.6" + version: "0.5.7" dart_style: dependency: transitive description: @@ -333,26 +349,26 @@ packages: dependency: "direct main" description: name: dio - sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7" + sha256: "797e1e341c3dd2f69f2dad42564a6feff3bfb87187d05abb93b9609e6f1645c3" url: "https://pub.dev" source: hosted - version: "5.3.3" + version: "5.4.0" drift: dependency: "direct main" description: name: drift - sha256: ef2ddafe89c1f5f26767e5eada65d739de4e9d2820303f7249f15a005999d5fc + sha256: d542088d353585a252f015b81c1e7603c57c996ba59a80d53a3f4644cc47f543 url: "https://pub.dev" source: hosted - version: "2.13.1" + version: "2.13.2" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: f79281f13411abe4229d6b57956202f047cc49b2c4e0d26ffae7273d6e5e97b1 + sha256: "369d2769d84e0c2d2cb4cd420e4fdb4f975852c83ebb934733c3b382c62961cd" url: "https://pub.dev" source: hosted - version: "2.13.1" + version: "2.13.2" equatable: dependency: transitive description: @@ -434,10 +450,10 @@ packages: dependency: "direct main" description: name: flutter_animate - sha256: "62f346340a96192070e31e3f2a1bd30a28530f1fe8be978821e06cd56b74d6d2" + sha256: "1dbc1aabfb8ec1e9d9feed2b675c21fb6b0a11f99be53ec3bc0f1901af6a8eb7" url: "https://pub.dev" source: hosted - version: "4.2.0+1" + version: "4.3.0" flutter_gen_core: dependency: transitive description: @@ -487,18 +503,18 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: d93394f22f73e810bda59e11ebe83329c5511d6460b6b7509c4e1f3c92d6d625 + sha256: c4d899312b36e7454bedfd0a4740275837b99e532d81c8477579d8183db1de6c url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.3.6" flutter_riverpod: dependency: transitive description: name: flutter_riverpod - sha256: "305203d1578f6857675f9730568548b03900ce53afd319f4aa9d2fa943334dbe" + sha256: d261b0f2461e0595b96f92ed807841eb72cea84a6b12b8fd0c76e5ed803e7921 url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.8" flutter_svg: dependency: "direct main" description: @@ -577,10 +593,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "5098760d7478aabfe682a462bf121d61bc5dbe5df5aac8dad733564a0aee33bc" + sha256: c247a4f76071c3b97bb5ae8912968870d5565644801c5e09f3bc961b4d874895 url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "12.1.1" go_router_builder: dependency: "direct dev" description: @@ -601,10 +617,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "2827136ecc0c2abffc3a58e575db6d5b84d159977fa1edc223c97bf566aa8c73" + sha256: b271e06606e718cf8185db9a792d1af00e2049e7bf5da09583654e020c00fbaa url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.8" hotreloader: dependency: transitive description: @@ -701,6 +717,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + jni: + dependency: transitive + description: + name: jni + sha256: "0d88790bdf7e298aa65a9094c62b58ea231169a2deb84f23defc7d7955885b43" + url: "https://pub.dev" + source: hosted + version: "0.7.2" js: dependency: transitive description: @@ -793,10 +817,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" mime: dependency: transitive description: @@ -809,10 +833,18 @@ packages: dependency: "direct main" description: name: mobile_scanner - sha256: cf978740676ba5b0c17399baf117984b31190bb7a6eaa43e51229ed46abc42ee + sha256: c3e5bba1cb626b6ab4fc46610f72a136803f6854267967e19f4a4a6a31ff9b74 url: "https://pub.dev" source: hosted - version: "3.5.2" + version: "3.5.5" + native_dio_adapter: + dependency: "direct main" + description: + name: native_dio_adapter + sha256: "2bb78b5e20b012ae6a0b5ea6405a3583f2ea6c85be9f7d909decabcb1992c25d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" neat_periodic_task: dependency: "direct main" description: @@ -841,10 +873,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "7e76fad405b3e4016cd39d08f455a4eb5199723cf594cd1b8916d47140d93017" + sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "5.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -977,10 +1009,10 @@ packages: dependency: "direct main" description: name: posix - sha256: "3ad26924254fd2354b0e2b95fc8b45ac392ad87434f8e64807b3a1ac077f2256" + sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.1" process: dependency: transitive description: @@ -1013,6 +1045,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + qr: + dependency: transitive + description: + name: qr + sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" quiver: dependency: transitive description: @@ -1049,42 +1097,42 @@ packages: dependency: transitive description: name: riverpod - sha256: "2e84315036e64c59affaff7443dea51247bc2fe704461a32f26a27986fb63d55" + sha256: "08451ddbaad6eae73e2422d8109775885623340d721c6637b8719c9f4b478848" url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.8" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "22a089135785f27e601075023f93c6622c43ef28c3ba1bef303cfbc314028e64" + sha256: d4dabc35358413bf4611fcb6abb46308a67c4ef4cd5e69fd3367b11925c59f57 url: "https://pub.dev" source: hosted - version: "0.4.3" + version: "0.5.0" riverpod_annotation: dependency: "direct main" description: name: riverpod_annotation - sha256: "9330309e4400f40e39a2a1d1c340e775d0fd23451cf2dd2286e03c7896fd2bd5" + sha256: "02c9bced96ed3ed8d9970820d1ce7b16600955bc01aa8b2276f09dd3d9d29ed9" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.2" riverpod_generator: dependency: "direct dev" description: name: riverpod_generator - sha256: "0a1c8eeb3dba2ce704eb1a4c3b8043716d52bedaaaa5b2725e0bde67ca38a46e" + sha256: "94b6c49bba879729611d690d434796e3b4e7c72a27e88b482b92c505e90f90d9" url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.3.8" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "97342543496f07c5172e0d1ce98c29499d8245776c94bfc837ceea5525c01ade" + sha256: "6fc64ae102ba39b0889b7aa7f4ef6c5a8f71a2ad215b90c787f319a9407a128b" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.6" rxdart: dependency: "direct main" description: @@ -1105,10 +1153,10 @@ packages: dependency: transitive description: name: sentry - sha256: "9cfd325611ab54b57d5e26957466823f05bea9d6cfcc8d48f11817b8bcedf0d1" + sha256: e7ded42974bac5f69e4ca4ddc57d30499dd79381838f24b7e8fd9aa4139e7b79 url: "https://pub.dev" source: hosted - version: "7.12.0" + version: "7.13.2" sentry_dart_plugin: dependency: "direct main" description: @@ -1121,10 +1169,10 @@ packages: dependency: "direct main" description: name: sentry_flutter - sha256: "0cd7d622cb63c94fd1b2f87ab508e158b950bd281e2a80f327ebf73bb217eaf3" + sha256: d6f55ec7a1f681784165021f749007712a72ff57eadf91e963331b6ae326f089 url: "https://pub.dev" source: hosted - version: "7.12.0" + version: "7.13.2" share_plus: dependency: "direct main" description: @@ -1323,13 +1371,13 @@ packages: source: hosted version: "0.32.0" stack_trace: - dependency: "direct main" + dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" state_notifier: dependency: transitive description: @@ -1342,10 +1390,10 @@ packages: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" stream_transform: dependency: transitive description: @@ -1382,10 +1430,10 @@ packages: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.1" time: dependency: transitive description: @@ -1454,10 +1502,10 @@ packages: dependency: "direct main" description: name: upgrader - sha256: "889c1ece7af143df32e8ee2126f2ef17b2ab6bb7ed8fc3b1b022d7faa4fdab20" + sha256: d63081e43d1daa1d0e4f8177b56311523985ac77c25519d559040e3c14fb947e url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "8.4.0" url_launcher: dependency: "direct main" description: @@ -1598,10 +1646,10 @@ packages: dependency: transitive description: name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 url: "https://pub.dev" source: hosted - version: "0.1.4-beta" + version: "0.3.0" web_socket_channel: dependency: "direct main" description: @@ -1614,10 +1662,10 @@ packages: dependency: "direct main" description: name: win32 - sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 url: "https://pub.dev" source: hosted - version: "5.0.9" + version: "5.1.1" win32_registry: dependency: transitive description: @@ -1667,5 +1715,5 @@ packages: source: hosted version: "2.1.1" sdks: - dart: ">=3.1.0 <4.0.0" + dart: ">=3.2.0 <4.0.0" flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index aa4a95b7..7151a08d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: hiddify -description: A Proxy Frontend. +description: Cross Platform Multi Protocol Proxy Frontend. publish_to: "none" -version: 0.10.7+1007 +version: 0.11.1+1101 environment: - sdk: ">=3.1.0 <4.0.0" + sdk: ">=3.2.0 <4.0.0" dependencies: flutter: @@ -20,35 +20,34 @@ dependencies: fpdart: ^1.1.0 freezed_annotation: ^2.4.1 json_annotation: ^4.8.1 - hooks_riverpod: ^2.4.5 + hooks_riverpod: ^2.4.8 flutter_hooks: ^0.20.3 - riverpod_annotation: ^2.3.0 + riverpod_annotation: ^2.3.2 rxdart: ^0.27.7 - drift: ^2.13.1 + drift: ^2.13.2 sqlite3_flutter_libs: ^0.5.18 shared_preferences: ^2.2.2 - dio: ^5.3.3 + dio: ^5.4.0 web_socket_channel: ^2.4.0 ffi: ^2.1.0 path_provider: ^2.1.1 - mobile_scanner: ^3.5.2 + mobile_scanner: ^3.5.5 protocol_handler: ^0.1.5 - flutter_native_splash: ^2.3.5 + flutter_native_splash: ^2.3.6 share_plus: ^7.2.1 window_manager: ^0.3.7 tray_manager: ^0.2.0 - package_info_plus: ^4.2.0 + package_info_plus: ^5.0.1 url_launcher: ^6.2.1 vclibs: ^0.1.0 launch_at_startup: ^0.2.2 - sentry_flutter: ^7.12.0 + sentry_flutter: ^7.13.2 sentry_dart_plugin: ^1.6.3 combine: ^0.5.6 path: ^1.8.3 loggy: ^2.0.3 flutter_loggy: ^2.0.2 - meta: ^1.9.1 - stack_trace: ^1.11.0 + meta: ^1.10.0 dartx: ^1.2.0 uuid: ^4.2.1 tint: ^2.0.1 @@ -56,36 +55,38 @@ dependencies: neat_periodic_task: ^2.0.1 retry: ^3.1.2 watcher: ^1.1.0 - go_router: ^12.1.0 + go_router: ^12.1.1 flex_color_scheme: ^7.3.1 - flutter_animate: ^4.2.0+1 + flutter_animate: ^4.3.0 flutter_svg: ^2.0.9 gap: ^3.0.1 percent_indicator: ^4.2.3 sliver_tools: ^0.2.12 flutter_adaptive_scaffold: ^0.1.7+1 humanizer: ^2.2.0 - upgrader: ^8.2.0 + upgrader: ^8.3.0 toastification: ^1.1.0 version: ^3.0.2 - posix: ^5.0.0 - win32: ^5.0.9 + posix: ^6.0.1 + win32: ^5.1.1 + qr_flutter: ^4.1.0 + native_dio_adapter: ^1.2.0 dev_dependencies: flutter_test: sdk: flutter lint: ^2.2.0 - build_runner: ^2.4.6 + build_runner: ^2.4.7 json_serializable: ^6.7.1 freezed: ^2.4.5 - riverpod_generator: ^2.3.5 - drift_dev: ^2.13.1 + riverpod_generator: ^2.3.8 + drift_dev: ^2.13.2 ffigen: ^8.0.2 slang_build_runner: ^3.25.0 flutter_gen_runner: ^5.3.2 go_router_builder: ^2.3.4 - custom_lint: ^0.5.6 - riverpod_lint: ^2.3.3 + custom_lint: ^0.5.7 + riverpod_lint: ^2.3.6 icons_launcher: ^2.1.5 flutter: diff --git a/test/data/local/generated_migrations/schema.dart b/test/core/database/generated_migrations/schema.dart similarity index 78% rename from test/data/local/generated_migrations/schema.dart rename to test/core/database/generated_migrations/schema.dart index c8c6ff59..1c9347e9 100644 --- a/test/data/local/generated_migrations/schema.dart +++ b/test/core/database/generated_migrations/schema.dart @@ -5,6 +5,7 @@ import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; import 'schema_v1.dart' as v1; import 'schema_v2.dart' as v2; +import 'schema_v3.dart' as v3; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -14,8 +15,10 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v1.DatabaseAtV1(db); case 2: return v2.DatabaseAtV2(db); + case 3: + return v3.DatabaseAtV3(db); default: - throw MissingSchemaException(version, const {1, 2}); + throw MissingSchemaException(version, const {1, 2, 3}); } } } diff --git a/test/data/local/generated_migrations/schema_v1.dart b/test/core/database/generated_migrations/schema_v1.dart similarity index 100% rename from test/data/local/generated_migrations/schema_v1.dart rename to test/core/database/generated_migrations/schema_v1.dart diff --git a/test/data/local/generated_migrations/schema_v2.dart b/test/core/database/generated_migrations/schema_v2.dart similarity index 100% rename from test/data/local/generated_migrations/schema_v2.dart rename to test/core/database/generated_migrations/schema_v2.dart diff --git a/test/core/database/generated_migrations/schema_v3.dart b/test/core/database/generated_migrations/schema_v3.dart new file mode 100644 index 00000000..a07c41dd --- /dev/null +++ b/test/core/database/generated_migrations/schema_v3.dart @@ -0,0 +1,168 @@ +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +//@dart=2.12 +import 'package:drift/drift.dart'; + +class ProfileEntries extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ProfileEntries(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn active = GeneratedColumn( + 'active', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))')); + late final GeneratedColumn name = + GeneratedColumn('name', aliasedName, false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + ), + type: DriftSqlType.string, + requiredDuringInsert: true); + late final GeneratedColumn url = GeneratedColumn( + 'url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn lastUpdate = GeneratedColumn( + 'last_update', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + late final GeneratedColumn updateInterval = GeneratedColumn( + 'update_interval', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn upload = GeneratedColumn( + 'upload', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn download = GeneratedColumn( + 'download', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn total = GeneratedColumn( + 'total', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn expire = GeneratedColumn( + 'expire', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn webPageUrl = GeneratedColumn( + 'web_page_url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn supportUrl = GeneratedColumn( + 'support_url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + id, + type, + active, + name, + url, + lastUpdate, + updateInterval, + upload, + download, + total, + expire, + webPageUrl, + supportUrl + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'profile_entries'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ProfileEntries createAlias(String alias) { + return ProfileEntries(attachedDatabase, alias); + } +} + +class GeoAssetEntries extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GeoAssetEntries(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn active = GeneratedColumn( + 'active', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))')); + late final GeneratedColumn name = + GeneratedColumn('name', aliasedName, false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + ), + type: DriftSqlType.string, + requiredDuringInsert: true); + late final GeneratedColumn providerName = + GeneratedColumn('provider_name', aliasedName, false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + ), + type: DriftSqlType.string, + requiredDuringInsert: true); + late final GeneratedColumn version = GeneratedColumn( + 'version', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn lastCheck = GeneratedColumn( + 'last_check', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => + [id, type, active, name, providerName, version, lastCheck]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'geo_asset_entries'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {name, providerName}, + ]; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GeoAssetEntries createAlias(String alias) { + return GeoAssetEntries(attachedDatabase, alias); + } +} + +class DatabaseAtV3 extends GeneratedDatabase { + DatabaseAtV3(QueryExecutor e) : super(e); + late final ProfileEntries profileEntries = ProfileEntries(this); + late final GeoAssetEntries geoAssetEntries = GeoAssetEntries(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => + [profileEntries, geoAssetEntries]; + @override + int get schemaVersion => 3; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/test/core/database/migrations_test.dart b/test/core/database/migrations_test.dart new file mode 100644 index 00000000..3739b818 --- /dev/null +++ b/test/core/database/migrations_test.dart @@ -0,0 +1,42 @@ +import 'package:drift_dev/api/migrations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hiddify/core/database/app_database.dart'; + +import 'generated_migrations/schema.dart'; + +void main() { + late SchemaVerifier verifier; + setUpAll(() { + verifier = SchemaVerifier(GeneratedHelper()); + }); + + test('upgrade from v1 to v2', () async { + final connection = await verifier.startAt(1); + final db = AppDatabase(connection: connection); + + await verifier.migrateAndValidate(db, 2); + await db.close(); + }); + + test('upgrade from v2 to v3', () async { + final connection = await verifier.startAt(2); + final db = AppDatabase(connection: connection); + + await verifier.migrateAndValidate(db, 3); + + final prePopulated = await db.select(db.geoAssetEntries).get(); + await db.close(); + expect(prePopulated.length, equals(2)); + }); + + test('upgrade from v1 to v3 with pre-population', () async { + final connection = await verifier.startAt(1); + final db = AppDatabase(connection: connection); + + await verifier.migrateAndValidate(db, 3); + + final prePopulated = await db.select(db.geoAssetEntries).get(); + await db.close(); + expect(prePopulated.length, equals(2)); + }); +} diff --git a/test/data/local/migrations_test.dart b/test/data/local/migrations_test.dart deleted file mode 100644 index 9a9a15b4..00000000 --- a/test/data/local/migrations_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:drift_dev/api/migrations.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hiddify/data/local/database.dart'; - -import 'generated_migrations/schema.dart'; - -void main() { - late SchemaVerifier verifier; - - setUpAll(() { - verifier = SchemaVerifier(GeneratedHelper()); - }); - - test('upgrade from v1 to v2', () async { - final connection = await verifier.startAt(1); - final db = AppDatabase(connection: connection); - - await verifier.migrateAndValidate(db, 2); - }); -} diff --git a/test/domain/profiles/profile_test.dart b/test/features/profile/data/profile_parser_test.dart similarity index 69% rename from test/domain/profiles/profile_test.dart rename to test/features/profile/data/profile_parser_test.dart index 15bfa2d0..96737245 100644 --- a/test/domain/profiles/profile_test.dart +++ b/test/features/profile/data/profile_parser_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:hiddify/features/profile/data/profile_parser.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; void main() { const validBaseUrl = "https://example.com/configurations/user1/filename.yaml"; @@ -8,14 +9,26 @@ void main() { const validSupportUrl = "https://example.com/support"; group( - "profile fromResponse", + "parse", () { test( - "with no additional metadata", + "url with file extension, no headers", () { - final profile = Profile.fromResponse(validExtendedUrl, {}); + final profile = ProfileParser.parse(validBaseUrl, {}); expect(profile.name, equals("filename")); + expect(profile.url, equals(validBaseUrl)); + expect(profile.options, isNull); + expect(profile.subInfo, isNull); + }, + ); + + test( + "url with url, no headers", + () { + final profile = ProfileParser.parse(validExtendedUrl, {}); + + expect(profile.name, equals("b")); expect(profile.url, equals(validExtendedUrl)); expect(profile.options, isNull); expect(profile.subInfo, isNull); @@ -23,7 +36,7 @@ void main() { ); test( - "with all metadata", + "with base64 profile-title header", () { final headers = >{ "profile-title": ["base64:ZXhhbXBsZVRpdGxl"], @@ -34,7 +47,7 @@ void main() { "profile-web-page-url": [validBaseUrl], "support-url": [validSupportUrl], }; - final profile = Profile.fromResponse(validExtendedUrl, headers); + final profile = ProfileParser.parse(validExtendedUrl, headers); expect(profile.name, equals("exampleTitle")); expect(profile.url, equals(validExtendedUrl)); diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index 930d2071..903f4899 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0bd3c28a..162a92be 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -15,6 +15,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)