diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d79ef23f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.rs] +indent_size = 4 diff --git a/.fvmrc b/.fvmrc deleted file mode 100644 index 457360f8..00000000 --- a/.fvmrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "flutter": "3.44.0" -} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cc77886e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: Quality + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + frontend: + name: Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run check + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + + desktop: + name: Rust / macOS + # Tauri links against the host platform's native webview and SDK. + runs-on: macos-latest + env: + SPARKLE_FRAMEWORK_PATH: ${{ github.workspace }}/src-tauri + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: rustup show + - run: rustup target add aarch64-apple-darwin x86_64-apple-darwin + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + - run: npm ci + - run: npm run build + - run: npm run check:rust + - run: bash scripts/setup-sparkle.sh + - name: Verify Sparkle signing compatibility with a temporary test key + run: SPARKLE_SIGN_TOOL="$PWD/src-tauri/sparkle-tools/sign_update" npm run test:release + - name: Check release updater + run: | + cargo clippy --manifest-path src-tauri/Cargo.toml --features macos-updater --all-targets --locked -- -D warnings + cargo test --manifest-path src-tauri/Cargo.toml --features macos-updater --locked \ + --config "target.$(rustc -vV | sed -n 's/^host: //p').runner=['env', 'DYLD_FRAMEWORK_PATH=$SPARKLE_FRAMEWORK_PATH']" + - run: npm run tauri build -- --target universal-apple-darwin --features macos-updater --config src-tauri/tauri.release.conf.json -- --locked + - run: bash scripts/verify-macos-bundle.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 792b1a21..306fe22e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,155 +2,140 @@ name: Build & Release on: push: - branches: - - main + branches: [main] + paths-ignore: [appcast.xml] + workflow_dispatch: permissions: contents: write +concurrency: + group: release-macos + cancel-in-progress: false + +defaults: + run: + shell: bash + env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: prepare-release: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} - build: ${{ steps.version.outputs.build }} - tag: ${{ steps.version.outputs.tag }} - + needed: ${{ steps.version.outputs.needed }} + published: ${{ steps.release.outputs.published }} steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Read version from pubspec.yaml + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - name: Check release metadata behavior + run: npm run test:release + - name: Read the current public appcast + run: | + git fetch origin main + git show origin/main:appcast.xml > appcast.xml + - name: Validate version and increasing Sparkle build number id: version + run: node scripts/release.mjs prepare + - name: Create draft release for the source commit + id: release + if: steps.version.outputs.needed == 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + RELEASE_COMMIT: ${{ github.sha }} run: | - FULL_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //') - VERSION=$(echo "$FULL_VERSION" | cut -d'+' -f1) - BUILD=$(echo "$FULL_VERSION" | cut -d'+' -f2) - - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "build=$BUILD" >> $GITHUB_OUTPUT - echo "tag=$VERSION" >> $GITHUB_OUTPUT - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ steps.version.outputs.tag }} - name: ${{ steps.version.outputs.version }} - draft: true - generate_release_notes: true + gh release list --limit 1000 --json tagName,isDraft > "$RUNNER_TEMP/releases.json" + EXISTING=$(jq --arg tag "$VERSION" '.[] | select(.tagName == $tag)' "$RUNNER_TEMP/releases.json") + if [ -n "$EXISTING" ]; then + if [ "$(jq -r '.isDraft' <<< "$EXISTING")" = true ]; then + test "$(gh release view "$VERSION" --json targetCommitish --jq .targetCommitish)" = "$RELEASE_COMMIT" + else + git fetch origin "refs/tags/$VERSION:refs/tags/$VERSION" + test "$(git rev-parse "$VERSION^{commit}")" = "$RELEASE_COMMIT" + fi + echo "published=$(jq -r '.isDraft | not' <<< "$EXISTING")" >> "$GITHUB_OUTPUT" + else + gh release create "$VERSION" --target "$RELEASE_COMMIT" --title "$VERSION" --draft --generate-notes + echo 'published=false' >> "$GITHUB_OUTPUT" + fi build-macos: needs: prepare-release + if: needs.prepare-release.outputs.needed == 'true' runs-on: macos-latest - + env: + VERSION: ${{ needs.prepare-release.outputs.version }} + SPARKLE_FRAMEWORK_PATH: ${{ github.workspace }}/src-tauri + DYLD_FRAMEWORK_PATH: ${{ github.workspace }}/src-tauri steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 + - uses: actions/setup-node@v4 with: - flutter-version: "3.44.0" - channel: "stable" - - - name: Build macOS app + node-version-file: .nvmrc + cache: npm + - run: npm ci + - name: Install pinned Sparkle framework and signing tools + run: bash scripts/setup-sparkle.sh + - name: Install pinned Rust toolchain and both macOS targets + if: needs.prepare-release.outputs.published != 'true' run: | - flutter clean - flutter pub get - dart run build_runner build -d - flutter build macos --release \ - --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ - --dart-define=GIT_REPOSITORY_URL=${{ vars.REPOSITORY_URL }} \ - --dart-define=AUTO_UPDATER_FEED_URL=${{ vars.APP_CAST_FILE_PATH }} \ - --dart-define=ISSUE_URL=${{ vars.ISSUE_URL }} \ - --obfuscate \ - --split-debug-info=build/debug-info - - - name: Sign macOS app with ad-hoc signature + rustup show + rustup target add aarch64-apple-darwin x86_64-apple-darwin + - uses: Swatinem/rust-cache@v2 + if: needs.prepare-release.outputs.published != 'true' + with: + workspaces: src-tauri + - name: Check frontend and Rust, including the release updater + if: needs.prepare-release.outputs.published != 'true' run: | - codesign --force --deep --sign - \ - --entitlements macos/Runner/Release.entitlements \ - "build/macos/Build/Products/Release/Android Tools.app" - - echo "βœ… Application signed successfully" - codesign -d --entitlements - "build/macos/Build/Products/Release/Android Tools.app" 2>&1 | head -10 - - - name: Upload debug symbols to Sentry + npm run check + SPARKLE_SIGN_TOOL="$PWD/src-tauri/sparkle-tools/sign_update" npm run test:release + npm run check:rust + cargo clippy --manifest-path src-tauri/Cargo.toml --features macos-updater --all-targets --locked -- -D warnings + cargo test --manifest-path src-tauri/Cargo.toml --features macos-updater --locked \ + --config "target.$(rustc -vV | sed -n 's/^host: //p').runner=['env', 'DYLD_FRAMEWORK_PATH=$SPARKLE_FRAMEWORK_PATH']" + - name: Build universal macOS application with ad-hoc signing + if: needs.prepare-release.outputs.published != 'true' + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + run: npm run tauri build -- --target universal-apple-darwin --features macos-updater --config src-tauri/tauri.release.conf.json -- --locked + - name: Verify the installable application + if: needs.prepare-release.outputs.published != 'true' + run: bash scripts/verify-macos-bundle.sh + - name: Upload native debug symbols to Sentry + if: needs.prepare-release.outputs.published != 'true' env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: | - # Check if Sentry credentials are available if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ] || [ -z "$SENTRY_PROJECT" ]; then - echo "⚠️ Sentry credentials not configured, skipping debug symbols upload" + echo 'Sentry credentials not configured; skipping symbol upload.' exit 0 fi - - # Install Sentry CLI - curl -sL https://sentry.io/get-cli/ | bash - - VERSION="${{ needs.prepare-release.outputs.version }}" - - # Create a new release in Sentry - echo "πŸ“¦ Creating Sentry release $VERSION..." - sentry-cli releases new "$VERSION" \ - --org "$SENTRY_ORG" \ - --project "$SENTRY_PROJECT" - - # Associate commits with the release - echo "πŸ”— Associating commits with release..." - sentry-cli releases set-commits "$VERSION" \ - --org "$SENTRY_ORG" \ - --auto \ - --ignore-missing - - # Upload Flutter debug symbols - echo "πŸ“€ Uploading debug symbols..." - - # Check if debug info directory exists and has files - if [ -d "build/debug-info" ] && [ "$(ls -A build/debug-info)" ]; then - echo "Found debug symbols in build/debug-info" - ls -lh build/debug-info - - # Flutter generates Breakpad symbol files (.symbols) - sentry-cli debug-files upload \ - --org "$SENTRY_ORG" \ - --project "$SENTRY_PROJECT" \ - --type breakpad \ - build/debug-info - else - echo "⚠️ No debug symbols found in build/debug-info" - fi - - # Also upload macOS dSYM files if they exist - DSYM_PATH="build/macos/Build/Products/Release/Android Tools.app.dSYM" - if [ -d "$DSYM_PATH" ]; then - echo "Found macOS dSYM files" - sentry-cli debug-files upload \ - --org "$SENTRY_ORG" \ - --project "$SENTRY_PROJECT" \ - "$DSYM_PATH" - fi - - # Finalize the release - echo "βœ… Finalizing Sentry release..." - sentry-cli releases finalize "$VERSION" \ - --org "$SENTRY_ORG" \ - --project "$SENTRY_PROJECT" - - - name: Create DMG installer + brew install sentry-cli + RELEASE="android-tools@$(node -p "require('./src-tauri/tauri.conf.json').version")" + sentry-cli releases new "$RELEASE" + sentry-cli releases set-commits "$RELEASE" --auto --ignore-missing + for TARGET in aarch64-apple-darwin x86_64-apple-darwin; do + SYMBOLS="src-tauri/target/$TARGET/release/android-tools.dSYM" + test -d "$SYMBOLS" + sentry-cli debug-files upload --include-sources "$SYMBOLS" + done + sentry-cli releases finalize "$RELEASE" + - name: Create the historical DMG layout + if: needs.prepare-release.outputs.published != 'true' run: | - VERSION="${{ needs.prepare-release.outputs.version }}" - DMG_NAME="AndroidTools-$VERSION-macos.dmg" - brew install create-dmg - create-dmg \ --volname "Android Tools" \ --window-pos 200 120 \ @@ -160,90 +145,70 @@ jobs: --icon "Android Tools.app" 176 192 \ --hide-extension "Android Tools.app" \ --app-drop-link 432 192 \ - "$DMG_NAME" \ - "build/macos/Build/Products/Release/Android Tools.app" - - - name: Install Sparkle tools - run: | - curl -L -o sparkle.tar.xz https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz - mkdir -p sparkle_dist - tar -xf sparkle.tar.xz -C sparkle_dist - chmod +x ./sparkle_dist/bin/sign_update - - - name: Generate Sparkle Signature for DMG - id: sparkle + "AndroidTools-$VERSION-macos.dmg" \ + "src-tauri/target/universal-apple-darwin/release/bundle/macos/Android Tools.app" + - name: Recover an already published download after an appcast push failure + if: needs.prepare-release.outputs.published == 'true' + run: gh release download "$VERSION" --pattern "AndroidTools-$VERSION-macos.dmg" + - name: Sign the DMG with the existing Sparkle key env: - SPARKLE_PRIVATE_KEY_CONTENT: ${{ secrets.SPARKLE_PRIVATE_KEY }} - run: | - DMG_PATH="AndroidTools-${{ needs.prepare-release.outputs.version }}-macos.dmg" - SIGN_TOOL="./sparkle_dist/bin/sign_update" - - echo "--- Preparing key ---" - echo "$SPARKLE_PRIVATE_KEY_CONTENT" > sparkle_key.priv - - echo "--- try to sign dmg ---" - RAW_OUTPUT=$($SIGN_TOOL --ed-key-file sparkle_key.priv "$DMG_PATH") - - SIGNATURE=$(echo "$RAW_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p') - rm sparkle_key.priv - - if [ -z "$SIGNATURE" ]; then - echo "❌ ERROR: Can't parse signature from result" - echo "Sortie brute : $RAW_OUTPUT" - exit 1 - fi - - echo "βœ… Generated signature: $SIGNATURE" - echo "signature=$SIGNATURE" >> $GITHUB_OUTPUT - - - name: Generate appcast - run: | - set -e - - DMG=AndroidTools-${{ needs.prepare-release.outputs.version }}-macos.dmg - - export VERSION=${{ needs.prepare-release.outputs.version }} - export BUILD=${{ needs.prepare-release.outputs.build }} - export SIGNATURE=${{ steps.sparkle.outputs.signature }} - export FILE_SIZE=$(stat -f%z "$DMG") - export PUB_DATE=$(date -R) - export DOWNLOAD_URL=https://github.com/ThomasBernard03/AndroidTools/releases/download/$VERSION/AndroidTools-$VERSION-macos.dmg - export RELEASE_NOTES_URL=https://github.com/ThomasBernard03/AndroidTools/blob/main/CHANGELOG.md - - NEW_ITEM=$(envsubst < appcast/items/macos.item.xml.tpl) - - if [ -f appcast.xml ]; then - EXISTING_ITEMS=$(sed -n '//,/<\/item>/p' appcast.xml) - export ITEMS="$NEW_ITEM - $EXISTING_ITEMS" - else - export ITEMS="$NEW_ITEM" - fi - - envsubst < appcast/appcast.xml.tpl > appcast.xml - - - name: Commit appcast + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | - git config user.name github-actions - git config user.email github-actions@github.com - git add appcast.xml - git diff --quiet --cached || git commit -m "chore(appcast): macOS ${{ needs.prepare-release.outputs.version }}" - git push - - - name: Upload macOS artifacts to release - uses: softprops/action-gh-release@v1 + test -n "$SPARKLE_PRIVATE_KEY" + KEY_FILE=$(mktemp "$RUNNER_TEMP/sparkle-key.XXXXXX") + trap 'rm -f "$KEY_FILE"' EXIT + chmod 600 "$KEY_FILE" + printf '%s' "$SPARKLE_PRIVATE_KEY" > "$KEY_FILE" + DMG="AndroidTools-$VERSION-macos.dmg" + export SIGNATURE_OUTPUT + SIGNATURE_OUTPUT=$(src-tauri/sparkle-tools/sign_update --ed-key-file "$KEY_FILE" "$DMG") + export FILE_SIZE + FILE_SIZE=$(stat -f%z "$DMG") + node scripts/release.mjs metadata + - name: Upload DMG to the draft release + if: needs.prepare-release.outputs.published != 'true' + run: gh release upload "$VERSION" "AndroidTools-$VERSION-macos.dmg" --clobber + - uses: actions/upload-artifact@v4 with: - tag_name: ${{ needs.prepare-release.outputs.tag }} - files: | - AndroidTools-${{ needs.prepare-release.outputs.version }}-macos.dmg + name: release-metadata + path: release-metadata.json + if-no-files-found: error + retention-days: 30 finalize-release: needs: [prepare-release, build-macos] runs-on: ubuntu-latest - + env: + VERSION: ${{ needs.prepare-release.outputs.version }} steps: - - name: Publish Release - uses: softprops/action-gh-release@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - tag_name: ${{ needs.prepare-release.outputs.tag }} - draft: false + node-version-file: .nvmrc + cache: npm + - run: npm ci + - uses: actions/download-artifact@v4 + with: + name: release-metadata + - name: Checkout the latest feed without changing the tested release sources + uses: actions/checkout@v4 + with: + ref: main + path: publication + - name: Generate appcast while retaining published history + run: | + cp publication/appcast.xml appcast.xml + node scripts/release.mjs feed + cp appcast.xml publication/appcast.xml + - name: Publish download before advertising the update + run: | + gh release view "$VERSION" --json assets --jq '.assets[].name' | grep -Fx "AndroidTools-$VERSION-macos.dmg" + gh release edit "$VERSION" --draft=false --latest + - name: Commit appcast at its existing public URL + working-directory: publication + run: | + git add appcast.xml + if ! git diff --cached --quiet; then + git -c user.name=github-actions -c user.email=github-actions@github.com commit -m "chore(appcast): macOS $VERSION" + git push origin HEAD:main + fi diff --git a/.gitignore b/.gitignore index 4813a379..4044ce21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,30 @@ +# Operating system and editor files .DS_Store -devtools_options.yaml -.dart_tool/ -.kotlin/ -.mason/ -mason-lock.json -build/ -.flutter-plugins -.flutter-plugins-dependencies +.idea/ +.vscode/ -# Android -.cxx +# Frontend dependencies and build output +node_modules/ +dist/ +*.tsbuildinfo -# Ignore generated files -*.g.dart -*.freezed.dart -*.mapper.dart -*.init.dart -*.gr.dart -*.config.dart +# Rust build output and generated Tauri schemas +src-tauri/target/ +src-tauri/gen/ +src-tauri/Sparkle.framework/ +src-tauri/sparkle-tools/ +release-metadata.json +AndroidTools-*-macos.dmg -# Test coverage +# Test reports coverage/ +playwright-report/ +test-results/ -# FVM Version Cache -.fvm/ - -# Ignore IDE -.idea/ -.vscode/ - -# Android build -keystore.jks -keystore.properties -*.aab -*.apk +# Local environment configuration +.env +.env.* +!.env.example -# Windows MSIX certificates -CERTIFICATE.pfx -CERTIFICATE.cer -Install.ps1 -AndroidTools-Windows-Package/ \ No newline at end of file +# Local logs +*.log diff --git a/.metadata b/.metadata deleted file mode 100644 index 5f4336f9..00000000 --- a/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - platform: android - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - platform: ios - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - platform: linux - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - platform: macos - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - platform: web - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - platform: windows - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..1c4cd08d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +coverage/ +src-tauri/target/ +src-tauri/gen/ +src-tauri/Sparkle.framework/ +src-tauri/sparkle-tools/ +package-lock.json +src-tauri/Cargo.lock +appcast/ +appcast.xml +CHANGELOG.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..a20502b7 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5c0eda88..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "dart.flutterSdkPath": ".fvm/versions/3.44.0" -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 495834ee..c4395165 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,100 +1,54 @@ -# CLAUDE.md +# Repository guidance -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Stack and scope + +This branch rebuilds Android Tools with Tauri 2, Rust, Vue 3, TypeScript and +Tailwind CSS 4. Read README.md and docs/architecture.md before adding features. +Implement one understandable, documented and tested feature at a time. ## Commands ```bash -# Run the app -fvm flutter run - -# Run with optional integrations -fvm flutter run \ - --dart-define=SENTRY_DSN=your_dsn \ - --dart-define=AUTO_UPDATER_FEED_URL=your_feed_url - -# Code generation (required after modifying Drift tables or @MappableClass models) -fvm dart run build_runner build -d - -# Clean rebuild -fvm flutter clean && fvm flutter pub get && fvm dart run build_runner build -d - -# Tests -fvm flutter test -fvm flutter test test/path/to/specific_test.dart - -# Lint / format -fvm flutter analyze -fvm dart format . - -# Build macOS release -fvm flutter build macos \ - --dart-define=SENTRY_DSN=your_dsn \ - --obfuscate \ - --split-debug-info=build/debug-info +npm ci +npm run tauri dev +npm run dev +npm run check +npm run check:rust +npm run format +cargo fmt --manifest-path src-tauri/Cargo.toml +npm run tauri build -- --no-bundle ``` -## Environment Variables (dart-define) - -| Key | Purpose | -|-----|---------| -| `SENTRY_DSN` | Sentry crash reporting DSN | -| `AUTO_UPDATER_FEED_URL` | Sparkle/auto-updater feed URL | -| `GIT_REPOSITORY_URL` | Link to the repository (shown in UI) | -| `ISSUE_URL` | Link to bug tracker (shown in UI) | - -All four are optional at runtime; missing values produce warnings in the log. +Build the frontend before Rust checks on a fresh checkout. Use the pinned Rust +toolchain and Node 24 LTS. Commit both dependency lockfiles when they change. ## Architecture -### Dependency Injection - -`getIt` (GetIt instance) is a package-level global defined in `lib/main.dart`. Every feature registers its own dependencies in a `*Module.configureDependencies()` static method called from `main()`. Modules must be registered before `await getIt.allReady()`. - -### Feature Structure - -Each feature under `lib/features/` follows clean architecture: - -``` -feature/ -β”œβ”€β”€ core/ # Module registration + feature-specific extensions -β”œβ”€β”€ data/ # Repository implementations, data sources -β”œβ”€β”€ domain/ # Entities, repository interfaces, use cases -└── presentation/ # BLoC (events/states), screens, widgets -``` - -Shared cross-feature code lives in `lib/shared/` with the same layer breakdown. - -### BLoC Pattern - -All state management uses `flutter_bloc`. BLoCs are provided via `BlocProvider` in the widget tree. The root-level `SettingsBloc` (theme mode) is provided in `MyApp` β€” it is the only BLoC not registered through a module. - -### ADB / AAPT Paths - -For release builds, `adb` and `aapt` binaries are bundled inside the app bundle (macOS: `Contents/Resources/`, Windows: next to the exe). `lib/shared/core/constants.dart` resolves the path at runtime based on `Platform.resolvedExecutable`. During development, ensure `adb` is in your PATH or the bundled binary is present. - -### Generated Files - -Never edit files ending in `.g.dart` or `.mapper.dart`. Regenerate with `build_runner` after: -- Modifying a `@DriftDatabase` table or DAO -- Adding/changing a `@MappableClass` annotated model - -### Database - -`AppDatabase` (Drift/SQLite) is a lazily-registered singleton in `SharedModule`. Tables: `install_history`, `app_settings`. - -### Widget Files - -**One widget per file.** Each `StatelessWidget` or `StatefulWidget` class must live in its own file under the `widgets/` subfolder of the relevant presentation layer. Private helper classes (non-widget) used exclusively within a widget may remain in the same file, but every public widget must have its own dedicated file. - -## Coding Standards - -### Comments - -**All comments must be written in English.** This includes: -- Inline comments (`//`) -- Block comments (`/* */`) -- Documentation comments (`///`) -- TODO/FIXME annotations - -This ensures consistency and maintainability across the codebase. +- Organize features by responsibility, following docs/architecture.md. +- Keep business logic independent of Tauri, Vue and external hardware. +- Inject external dependencies through Rust traits and TypeScript interfaces. +- Keep Tauri commands thin; do not call invoke directly from UI components. +- Build deterministic fakes and meaningful behavior tests with each feature. +- The normal test suite must run without a phone, Android SDK or native window. +- Keep explicit demo scenarios separate from real device data and errors. +- Avoid speculative abstractions and empty layer scaffolding. + +## Style and documentation + +- Use English for all project content: user interface text, accessibility labels, + error messages, documentation, project guides, code identifiers and test names. +- Write all comments in English, including code, tests, configuration files and + scripts. API documentation and docstrings must also be written in English. +- Use one Vue component per file; shared widgets live in + src/shared/presentation/widgets, feature widgets in their presentation layer. +- Use TypeScript strict mode and structured Rust errors. +- Document public Rust APIs, non-obvious decisions and feature limitations. +- Do not edit generated files in src-tauri/gen or dependency lockfiles manually. +- Preserve historical release assets unless their removal is explicitly requested. + +## Verification + +Run npm run check and npm run check:rust for changes spanning both stacks. +Use focused behavior tests for features, including failures and simulated +dependencies. Document hardware-only verification separately. The initial CI +checks the frontend on Linux and Rust plus a desktop build on macOS. diff --git a/Claude.md b/Claude.md deleted file mode 120000 index 47dc3e3d..00000000 --- a/Claude.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index bb68421c..968eef33 100644 --- a/README.md +++ b/README.md @@ -1,213 +1,266 @@ -
+# Android Tools -# πŸ”§ Android Tools +A new desktop foundation built with **Tauri 2 + Rust + Vue 3 + TypeScript + +Tailwind CSS 4**. This branch rebuilds the project incrementally. It lists Android +USB devices and reads Android information over an authenticated ADB connection. No Android SDK +or external `adb` executable is required. Tests and explicit demo scenarios run +without a phone. -### A powerful desktop application for managing Android devices +## Prerequisites -[![Version](https://img.shields.io/badge/version-2026.05.3-blue.svg)](https://github.com/ThomasBernard03/AndroidTools/releases) -[![Flutter](https://img.shields.io/badge/Flutter-3.44.0-02569B?logo=flutter)](https://flutter.dev) -[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20-lightgrey.svg)](https://github.com/ThomasBernard03/AndroidTools) +- Node.js **24 LTS**, version 24.15 or later within the 24.x series, and npm. + Run `nvm use` if you use nvm. +- Rust through [rustup](https://rustup.rs/). `rust-toolchain.toml` pins the version + and components used locally and in CI. +- On macOS: Xcode or the Command Line Tools (`xcode-select --install`). +- On Windows/Linux: install the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). + Native CI and release packaging target macOS (Intel and Apple Silicon). -[Features](#features) β€’ [Installation](#installation) β€’ [Usage](#usage) β€’ [Building](#building) β€’ [Contributing](#contributing) - -![Android Tools](documentation/file_explorer.png) - -
- ---- - -## πŸ“± Overview - -**Android Tools** is a comprehensive desktop application designed to simplify Android device management. Whether you're a developer, power user, or enthusiast, Android Tools provides an intuitive interface for exploring device files, monitoring logs, and installing applications. - -Built with Flutter, it offers a modern, responsive UI that works seamlessly across different platforms. - -## ✨ Features - -### πŸ—‚οΈ **File Explorer** -- Browse your Android device's file system with an intuitive, modern interface -- Navigate through folders with breadcrumb navigation -- Preview images, text files, and view detailed file metadata -- Drag and drop files between your computer and device -- Upload and download files with progress indicators -- Support for all common file formats -- File operations: copy, delete, rename, and more - -
- File Explorer -
- -### πŸ“¦ **APK Inspector & Installer** -- Install APK files with a single click or drag and drop -- View detailed APK information (package name, version, permissions) -- Batch installation support for multiple apps -- Track installation history -- Inspect installed applications - -
- APK Inspector -
- -### πŸ“Š **Device Information** -- View detailed device specifications -- Check Android version and build information -- Monitor device status in real-time - -
- Device Information -
- -### πŸ“ **Logcat Viewer** -- Real-time log monitoring with live updates -- Advanced filtering by tag, priority level, and custom search -- Color-coded log levels (Verbose, Debug, Info, Warning, Error) -- Clean, readable log presentation with timestamps -- Clear logs and export functionality - -
- Logcat Viewer -
- -## πŸš€ Installation - -### macOS - -1. Download the latest release from the [Releases page](https://github.com/ThomasBernard03/AndroidTools/releases) -2. Unzip the downloaded file -3. Double-click `android_tools.app` - - ![macOS Installation Step 1](documentation/macos_install_screenshot_1.png) - -4. If you see "Android Tools cannot be opened": - - Go to **System Preferences** β†’ **Privacy & Security** - - ![macOS Security Warning](documentation/macos_install_screenshot_2.png) - - - Scroll down and click **Open Anyway** next to the Android Tools warning - - ![macOS Open Anyway](documentation/macos_install_screenshot_3.png) - -5. The app will launch successfully - -### Windows & Linux -Coming soon! Star this repository to get notified when they're available. - -## πŸ“‹ Prerequisites - -- **USB Debugging** enabled on your Android device -- **macOS 10.14+** (for macOS users) - -## 🎯 Usage - -1. **Connect your Android device** via USB -2. **Enable USB Debugging** on your device (Settings β†’ Developer Options β†’ USB Debugging) -3. **Launch Android Tools** -4. **Accept the USB debugging prompt** on your device -5. Your device will appear in the app, ready to use! - -### Quick Actions -- πŸ—‚οΈ **File Explorer** - Browse and manage device files with drag-and-drop support -- πŸ“¦ **APK Inspector** - Install apps and view detailed APK information -- πŸ“ **Logcat** - Monitor real-time device logs with advanced filtering -- ℹ️ **Device Information** - View comprehensive device specifications and status - -## πŸ› οΈ Building from Source - - -### Setup +## Install and run ```bash -# Clone the repository -git clone https://github.com/ThomasBernard03/AndroidTools.git -cd AndroidTools - -# Install dependencies -fvm flutter pub get - -# Generate code -fvm dart run build_runner build -d +npm ci +npm run tauri dev ``` -### Running the App +Tauri starts Vite, compiles Rust and opens a native window. The first launch takes +longer because Cargo must compile the dependencies. + +To work on the interface in a browser: ```bash -# Run with Sentry (optional, for error tracking) -fvm flutter run --dart-define=SENTRY_DSN=your_sentry_dsn +npm run dev ``` -### Building for macOS +Open for the explicit device demo. +Use `?demo=empty` or `?demo=error` for the other deterministic scenarios. +Demo mode is visibly labeled and only enabled in development builds. Opening the +browser without a demo scenario displays a native-runtime-required error; real +USB failures never fall back to fake data. + +## Select a connected device + +The compact sidebar groups **Device overview**, **File explorer** and **Logcat** +under Device, and **APK analysis**, **Generate keystore**, **APK signing** and +**Keystore explorer** under **APK & Keystore**. APK analysis inspects local packages and Generate keystore creates signing keys +locally. APK signing signs local packages using an existing keystore. File explorer +and Logcat display explicit **Coming soon** screens. +Navigation remains available without a device and preserves the selected device +and ADB state when returning to the overview. + +1. Enable developer options and **USB debugging** on the phone. +2. Connect it with a USB data cable and launch `npm run tauri dev`. +3. The first detected device is selected automatically. Use the **Device** dropdown + to choose another device. +4. Click **Refresh** after connecting or disconnecting a phone. +5. When selecting a device, unlock the phone and accept **Allow USB debugging**. + The application waits up to 30 seconds for authorization; use **Retry ADB + connection** if needed. + +Discovery runs at startup and on manual refresh. Each option shows the USB product +name, serial when available and a connection identifier, so identical models remain +distinguishable. The first device in discovery order is selected by default, starting +its ADB connection. Refresh preserves the current selection while it remains present; +otherwise it selects the first available device. Selection is cleared when discovery +fails or no devices remain. Connection changes are detected on manual refresh. + +The **Device overview** displays the selected device's USB manufacturer, product, +serial number, vendor/product IDs and connection ID. Missing strings are shown as +**Unavailable**; descriptor IDs remain visible even when USB metadata access fails. +These values refresh with the device list and are not Android system properties. + +A persistent bottom status bar shows the selected device and its serial number +(or connection ID), the USB transport and the ADB connection state. Selecting a +device starts a direct ADB-over-USB session. **Android information** displays the +manufacturer, model, Android version, API level, security patch, build, CPU +architecture and battery level/status when available. Use **Refresh Android info** +to read a new snapshot. Connection state reflects the last request, not a live +hotplug monitor. + +This feature detects physical USB interfaces exposing ADB, not emulators, Wi-Fi +devices, MTP-only connections or fastboot devices. USB presence does not establish +ADB authorization. The separate ADB connection performs authentication and reads +Android properties. Missing USB metadata does not hide a detected ADB device; +it appears with a fallback name and a warning. + +ADB requires a readable, unique USB serial number to match the selected device +across the discovery and transport libraries. The app stores its own host key in +its application data directory. If another ADB client holds the USB interface, +close it and stop its ADB server before retrying; Android Tools does not stop it +automatically. See [ADB information](docs/features/adb.md) for details and hardware checks. + +See [device discovery](docs/features/devices.md) for architecture, limitations and +hardware verification instructions. + +## Analyze an APK + +Open **APK & Keystore β†’ APK analysis**, then drag and drop one `.apk` or click **Choose APK**. +The compact report shows the application icon when supported, APK size, application +version, debuggable status, minimum/maximum SDK and signing certificate details. +No phone, Android SDK or upload is required. Results survive navigation. + +APK v2/v3/v3.1 signatures and signed content digests are cryptographically checked; +v1-only APKs explicitly report **Not verified**. Raster, vector and adaptive icons +are previewed locally when their drawable features are supported. APKs up to 1 GiB +are supported, with additional metadata limits. See [APK analysis](docs/features/apk.md) +for report details, limitations and desktop verification. + +## Generate a keystore + +Open **APK & Keystore β†’ Generate keystore** without connecting a phone. Choose a destination, +JKS or PKCS12 format, passwords, an alias and a certificate identity. The defaults +are JKS, alias `upload`, RSA 2048 and 30 years of validity. The format uses a themed, +keyboard-accessible dropdown. Icons inside each password field show, hide or copy +its value. **Generate** creates a secure 24-character password and +fills its confirmation. JKS supports a separate key password; PKCS12 uses the +keystore password for both protections. Leaving the optional key password empty +also reuses the keystore password, which remains required. + +Creation is native: no Java, Android SDK or external `keytool` is required. Existing +files are never replaced. The result includes SHA-1/SHA-256 certificate fingerprints +and **Show in folder**. Back up the file and passwords for future signing operations. +The screen retains its state while navigating within the application. + +In development, `/?demo=empty` or `/?demo=devices` simulates successful creation; +`/?demo=error` simulates a write failure. These explicitly labeled scenarios create +no file or key. See [keystore generation](docs/features/keystore.md) for constraints, +architecture and desktop verification. + +## Sign an APK + +Open **APK & Keystore β†’ APK signing**, enter or choose the APK and keystore paths, then enter +the key alias, keystore password and optional separate key password. JKS and +single-key PKCS12 RSA keystores are supported, including those generated in the app. + +Click **Sign APK**. After local signing succeeds, the native save dialog proposes +`name-signed.apk`. Choose a new filename to save; existing files are never replaced. +Cancelling saves nothing. The result includes **Show in folder**. + +Signing uses APK Signature Scheme v2 for **Android 7.0 and later**, without Java or +an Android SDK. See [APK signing](docs/features/signing.md) for limits, demo scenarios +and independent desktop verification. + +## Explore a keystore + +Open **APK & Keystore β†’ Keystore explorer**, choose a JKS or PKCS12 file and enter +its store password. The read-only report shows aliases, entry types, certificate +subjects and issuers, serial numbers, validity dates and SHA-1/SHA-256 fingerprints. +For JKS, select an alias and use **Verify key** to check its separate password and +confirm that the unlocked key matches its certificate. An empty key password uses +the store password. Editing store credentials clears the previous report. + +No phone, Java or Android SDK is required. Files up to 16 MiB are supported. +PKCS12 exposes the first signing identity and additional certificates, with an +explicit multi-key inventory limitation. Incorrect passwords and damaged files can +produce the same integrity failure. See [Keystore explorer](docs/features/keystore-explorer.md) +for verification semantics, demos and manual checks. + +## Application settings + +Open **Settings** at the bottom of the sidebar to open the GitHub project, create +an issue and control Sentry error reporting. The preference survives restarts and +applies immediately to Vue errors and Rust panics. Supply `SENTRY_DSN` when building +the desktop application to enable delivery; reporting defaults to off. +**Open logs folder** opens local diagnostic files, rotated at 5 MiB with 5 archives +retained. Local logs work independently of Sentry. Installed macOS releases use +Sparkle for automatic checks and **Check for updates**. +See [Settings](docs/features/settings.md) and [Local logs](docs/features/logging.md). + +## Quality checks ```bash -# Build the app -fvm flutter build macos \ - --dart-define=SENTRY_DSN=your_sentry_dsn \ - --obfuscate \ - --split-debug-info=build/debug-info +npm run check +npm run check:rust +npx playwright install chromium +npm run test:e2e ``` +`check` runs formatting checks, ESLint, TypeScript validation, the Vite build and +Vitest tests. `check:rust` runs rustfmt checks, Clippy and Cargo tests. On a fresh +checkout, build the frontend before running Rust checks: Tauri embeds files from +`dist/` in some build configurations. + ```bash -zip -r android_tools.zip android_tools.app +npm run test:watch +npm run format +cargo fmt --manifest-path src-tauri/Cargo.toml ``` -## πŸ—ΊοΈ Roadmap - -- [ ] Real-time device connection/disconnection detection -- [ ] Live SQL database viewer -- [ ] Stack similar logcat lines (VSCode-style) -- [ ] Windows and Linux support -- [ ] Screen mirroring - -## 🀝 Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. +Frontend tests cover selection, refresh, disappearance, errors and retry, stale +requests, demo scenarios, keystore form interactions and IPC validation. Rust tests +also reopen generated keystores, verify private keys and certificates, reject wrong +passwords and protect existing files. Playwright runs the simulated keystore journey +in Chromium. The normal suite requires no phone, Android SDK, Java or native window; +an optional ignored test checks Java `keytool` interoperability. -1. Fork the project -2. Create your feature branch (`git checkout -b feature/AmazingFeature`) -3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request - -## πŸ“ Development Notes - -### Code Generation -The project uses build_runner for code generation. Run this after modifying models or database schemas: +## Build the desktop binary ```bash -fvm flutter clean && fvm flutter pub get && fvm dart run build_runner build -d +npm run tauri build -- --no-bundle ``` -### Sentry Integration -For error tracking, set your Sentry DSN: - -```dart ---dart-define=SENTRY_DSN=your_sentry_dsn -``` +The binary is written to `src-tauri/target/release/`. Normal development does not +require Sparkle or generate installers. Pushes to `main` build and publish a +universal, ad-hoc-signed macOS DMG, upload native symbols to Sentry when configured, +and update the existing Sparkle appcast. See [Build and release](docs/releases.md) +for versioning, secrets, local bundle checks and retry behavior. -### Auto-Updater -Configure auto-updates by setting the feed URL: +The application icon is reused from the Flutter application on `main` +(`macos/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon512x512@2x.png`). +Its original 1024-pixel source is stored as `src-tauri/icons/app-icon.png`. +Regenerate the runtime icon with: -```dart ---dart-define=AUTO_UPDATER_FEED_URL=your_feed_url +```bash +npm run tauri icon -- src-tauri/icons/app-icon.png --output src-tauri/icons --png 32 ``` -## πŸ“„ License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## πŸ™ Acknowledgments - -- Built with [Flutter](https://flutter.dev) -- ADB integration via [adb_dart](https://pub.dev/packages/adb_dart) -- AAPT integration via [aapt_dart](https://pub.dev/packages/aapt_dart) -- Auto-updates powered by [auto_updater](https://pub.dev/packages/auto_updater) - ---- - -
- -**Made with ❀️ for the Android developer community** - -[Report Bug](https://github.com/ThomasBernard03/AndroidTools/issues) β€’ [Request Feature](https://github.com/ThomasBernard03/AndroidTools/issues) +## Project layout + +```text +src/ + main.ts Vue bootstrap + App.vue Interface composition + App.test.ts Composition test without a native runtime + styles.css Tailwind and global styles + features/devices/ Contracts, adapters, selection UI and tests +src-tauri/ + src/lib.rs Tauri bootstrap + src/main.rs Binary entry point + src/features/devices/ Discovery contract, use case, USB adapter and IPC + tauri.conf.json Window, build and content security policy + capabilities/main.json Native window permissions +docs/ + architecture.md Decisions and rules for future features + features/devices.md Device discovery guide and hardware checks +``` -
+Read the [architecture guide](docs/architecture.md) before adding a feature. +All project content, including the interface, documentation and comments, must be +written in English, as specified in [AGENTS.md](AGENTS.md). + +`package-lock.json` and `src-tauri/Cargo.lock` are versioned for reproducible +dependency installation. The existing `appcast/`, `appcast.xml` and `CHANGELOG.md` +preserve Flutter release history and continue to support macOS distribution. +Quality CI checks the frontend on Linux and Rust plus the universal release bundle +on macOS. The separate release workflow publishes only from `main`. + +## Tooling choices + +- **Node.js 24 LTS** runs the frontend development tools. Node.js 26 is currently + in its Current phase; this project uses the latest LTS line for its development + baseline. Node.js is not embedded in the shipped desktop application. +- **Cargo** is Rust's package manager and build tool. It resolves dependencies, + compiles the application and runs Rust tests. +- **rustfmt** formats Rust source code; `cargo fmt --check` verifies formatting. +- **Clippy** is Rust's linter. It detects common mistakes and suggests more idiomatic + code. CI treats its warnings as failures. + +Dependency versions were reviewed on October 2, 2026. Vite 8 and Vitest 5 are used. +TypeScript remains on 6.0.x because the current `typescript-eslint` release supports +TypeScript versions below 6.1; TypeScript 7 is newer but is not supported by that +linter yet. Revisit this constraint when updating the toolchain. + +The CI commands are portable, but native Tauri compilation depends on the host +platform. The macOS job validates the macOS runtime and binary using Apple's SDK; +a Linux build would instead validate WebKitGTK and the Linux binary. Frontend +checks run on Linux because they do not need a native window or macOS APIs. diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 0d290213..00000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/app-archive.json b/app-archive.json deleted file mode 100644 index 3f220bcf..00000000 --- a/app-archive.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "appName": "Android Tools", - "description": "A tool to improve communication between MacOs/Windows/Linux and Android. You can easily install applications, view logcat, search files and view various information", - "items": [ - { - "version": "2.0.1", - "shortVersion": 9, - "changes": [ - { - "type": "chore", - "message": "Test update" - } - ], - "date": "2025-22-12", - "mandatory": true, - "url": "https://github.com/ThomasBernard03/AndroidTools/releases/download/2.0.1/AndroidTools.dmg", - "platform": "windows" - }, - { - "version": "2.0.0", - "shortVersion": 9, - "changes": [ - { - "type": "chore", - "message": "Test update" - } - ], - "date": "2025-22-12", - "mandatory": true, - "url": "https://thomasbernard03.github.io/AndroidTools/releases/2.0.0/2.0.0%2B8-macos", - "platform": "macos" - } - ] -} \ No newline at end of file diff --git a/assets/android_versions/android.png b/assets/android_versions/android.png deleted file mode 100644 index 18bcc1ff..00000000 Binary files a/assets/android_versions/android.png and /dev/null differ diff --git a/assets/android_versions/android_1.png b/assets/android_versions/android_1.png deleted file mode 100644 index bbf5c72b..00000000 Binary files a/assets/android_versions/android_1.png and /dev/null differ diff --git a/assets/android_versions/android_10.svg b/assets/android_versions/android_10.svg deleted file mode 100644 index c4744b6b..00000000 --- a/assets/android_versions/android_10.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_11.svg b/assets/android_versions/android_11.svg deleted file mode 100644 index b40c6f67..00000000 --- a/assets/android_versions/android_11.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_12.svg b/assets/android_versions/android_12.svg deleted file mode 100644 index b2c80bce..00000000 --- a/assets/android_versions/android_12.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_13.svg b/assets/android_versions/android_13.svg deleted file mode 100644 index 000f520f..00000000 --- a/assets/android_versions/android_13.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_14.svg b/assets/android_versions/android_14.svg deleted file mode 100644 index 97baf7a2..00000000 --- a/assets/android_versions/android_14.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_15.svg b/assets/android_versions/android_15.svg deleted file mode 100644 index cb8bf337..00000000 --- a/assets/android_versions/android_15.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_16.svg b/assets/android_versions/android_16.svg deleted file mode 100644 index b4722b11..00000000 --- a/assets/android_versions/android_16.svg +++ /dev/null @@ -1,54 +0,0 @@ - - - \ No newline at end of file diff --git a/assets/android_versions/android_17.svg b/assets/android_versions/android_17.svg deleted file mode 100644 index bcce0fe4..00000000 --- a/assets/android_versions/android_17.svg +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/android_versions/android_3.png b/assets/android_versions/android_3.png deleted file mode 100644 index 88948934..00000000 Binary files a/assets/android_versions/android_3.png and /dev/null differ diff --git a/assets/android_versions/android_4.png b/assets/android_versions/android_4.png deleted file mode 100644 index 96952b12..00000000 Binary files a/assets/android_versions/android_4.png and /dev/null differ diff --git a/assets/android_versions/android_5.svg b/assets/android_versions/android_5.svg deleted file mode 100644 index b64d2d24..00000000 --- a/assets/android_versions/android_5.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_6.svg b/assets/android_versions/android_6.svg deleted file mode 100644 index c982de4d..00000000 --- a/assets/android_versions/android_6.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_7.png b/assets/android_versions/android_7.png deleted file mode 100644 index a9a98439..00000000 Binary files a/assets/android_versions/android_7.png and /dev/null differ diff --git a/assets/android_versions/android_8.svg b/assets/android_versions/android_8.svg deleted file mode 100644 index 072f5748..00000000 --- a/assets/android_versions/android_8.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/android_versions/android_9.svg b/assets/android_versions/android_9.svg deleted file mode 100644 index 0b9fa272..00000000 --- a/assets/android_versions/android_9.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/fonts/nothing.otf b/assets/fonts/nothing.otf deleted file mode 100644 index ae69755e..00000000 Binary files a/assets/fonts/nothing.otf and /dev/null differ diff --git a/assets/images/file/file_dark.svg b/assets/images/file/file_dark.svg deleted file mode 100644 index a2c54fba..00000000 --- a/assets/images/file/file_dark.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/images/file/file_light.svg b/assets/images/file/file_light.svg deleted file mode 100644 index e70167d6..00000000 --- a/assets/images/file/file_light.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/images/file_extensions/database.svg b/assets/images/file_extensions/database.svg deleted file mode 100644 index b0839549..00000000 --- a/assets/images/file_extensions/database.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/file_extensions/document.svg b/assets/images/file_extensions/document.svg deleted file mode 100644 index 3400f527..00000000 --- a/assets/images/file_extensions/document.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/file_extensions/image.svg b/assets/images/file_extensions/image.svg deleted file mode 100644 index 42c4d258..00000000 --- a/assets/images/file_extensions/image.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/file_extensions/json.svg b/assets/images/file_extensions/json.svg deleted file mode 100644 index 156fc634..00000000 --- a/assets/images/file_extensions/json.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/file_extensions/log.svg b/assets/images/file_extensions/log.svg deleted file mode 100644 index fc1983fb..00000000 --- a/assets/images/file_extensions/log.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/file_extensions/svg.svg b/assets/images/file_extensions/svg.svg deleted file mode 100644 index 29b4d5fe..00000000 --- a/assets/images/file_extensions/svg.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/file_extensions/video.svg b/assets/images/file_extensions/video.svg deleted file mode 100644 index 6e84731d..00000000 --- a/assets/images/file_extensions/video.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/file_extensions/xml.svg b/assets/images/file_extensions/xml.svg deleted file mode 100644 index 1cbf2d43..00000000 --- a/assets/images/file_extensions/xml.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/folder/dark_folder.svg b/assets/images/folder/dark_folder.svg deleted file mode 100644 index 154df643..00000000 --- a/assets/images/folder/dark_folder.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/images/folder/light_folder.svg b/assets/images/folder/light_folder.svg deleted file mode 100644 index 4724883e..00000000 --- a/assets/images/folder/light_folder.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/images/folder/red_folder.svg b/assets/images/folder/red_folder.svg deleted file mode 100644 index 0f110391..00000000 --- a/assets/images/folder/red_folder.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..47cc337d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,152 @@ +# Foundation architecture + +## 1. Two languages, one application + +Vue/TypeScript handles presentation and user interactions. Rust handles system +operations and business logic. Tauri provides the window and the IPC bridge between +them. Vite builds the frontend; Cargo builds the backend. + +An IPC call behaves like an asynchronous service call: the frontend sends +serializable arguments, Rust executes a command and returns a result. Events can +report progress for long-running operations. + +The device picker calls `list_devices` through an injectable frontend service. +The native command delegates discovery to a Rust service on a blocking worker. +The USB adapter uses `rusb` with vendored libusb; it does not launch external tools. + +Android information lives in `features/adb`. `read_android_info` delegates to an +ADB application service with injectable `AdbConnector` and `AdbSession` traits. +The native adapter uses `rsadb` with its `nusb` USB transport for authentication +and shell streams. It matches a freshly discovered connection by a unique USB +serial plus vendor/product IDs, rather than choosing the first matching model. +No ADB server or external executable is launched. Authentication and reads are +asynchronous and timeout-bounded; filesystem/key generation and libusb discovery +run on blocking workers. The service owns one session and serializes its use. +The frontend injects a separate `AdbService`, validates IPC, serializes selection +changes and discards stale results. See [ADB information](features/adb.md). + +## 2. Organizing future features + +APK analysis lives in `features/apk` on both stacks. Rust validates input through an +`ApkInspector` trait; the native adapter snapshots the file, reads binary manifests, +resources and certificates with `apk-info`, inventories ZIP entries and hashes the +snapshot. Commands run on blocking workers. Vue injects an `ApkService` with validated +IPC and native drop event registration; its cached workspace discards stale results +and cleans up listeners on navigation. See [APK analysis](features/apk.md). +The analysis adapter separately checks modern APK signer signatures and content +digests, returning an explicit verification status. Android drawable XML is resolved +through an injectable resource reader and rendered to PNG with `resvg`; raw APK XML +is never inserted into the webview DOM. + +Keystore generation lives in `features/keystore` on both stacks. Rust validates +requests through a use case with injectable encoder and file-publication traits; +native adapters generate RSA/certificates with vendored OpenSSL, encode JKS with +`jks`, and atomically publish a new file without replacement. Tauri commands run +blocking work off the UI thread and provide scoped dialog/clipboard/reveal actions. +The frontend injects a `KeystoreService` and validates IPC results. Explicit demos +and Chromium journeys exercise the form without native dialogs or real keys. +See [keystore generation](features/keystore.md) for limitations and verification. + +Read-only keystore exploration shares `features/keystore`. A separate +`KeystoreInspector` trait isolates the use case from filesystem and cryptographic +parsers. Its native adapter snapshots bounded JKS/PKCS12 files and returns public +certificate metadata and explicit key-check statuses. The frontend injects a +`KeystoreExplorerService`, validates IPC and invalidates reports when credentials +change. See [Keystore explorer](features/keystore-explorer.md). + +APK signing lives in `features/signing`. Its use case injects an `ApkSigner`, an +owned `SignedArtifact` and a destination picker, enforcing sign-before-dialog +ordering and cleanup on cancellation. Native adapters read JKS/PKCS12, rebuild +aligned ZIP entries, sign APK v2 and publish atomically without replacement. +The frontend injects a validated `SigningService`; credentials are not persisted. +See [APK signing](features/signing.md) for supported formats and verification. + +Application settings live in `features/settings`. Vue injects a validated +`SettingsService`; native commands atomically persist preferences off the UI thread +and open fixed GitHub destinations through the existing opener plugin. The native +Sentry client owns the transport and an atomic consent gate shared by Rust panics +and Vue error events forwarded over IPC. Successful settings writes update this +gate immediately. Local diagnostics use `log` and `flexi_logger` with asynchronous +file writes, size-based rotation and bounded retention. Native command boundaries +record operation outcomes; a narrow frontend adapter forwards fixed error events. +Settings opens the same native log directory. See [Local logs](features/logging.md). +Release builds register the optional Sparkle plugin. A settings command delegates +manual checks to its native UI; ordinary development builds report unavailability. +The historical bundle identity, public key and feed are retained. See +[Build and release](releases.md). +See [Settings](features/settings.md). + +Each feature will live under `src/features//` on the frontend and +`src-tauri/src/features//` in Rust. Introduce layers when they have a +concrete responsibility: + +| Responsibility | Frontend | Backend | +| -------------------------- | ------------------------------ | -------------------------- | +| Presentation / entry point | Vue components and composables | Thin Tauri commands | +| Application | Interaction orchestration | Use cases | +| Domain | Models and service contracts | Models, rules and traits | +| Infrastructure | IPC adapter | USB, files and persistence | + +Dependencies point toward contracts rather than external implementations. The +composition root selects and injects concrete implementations. Rust business logic +remains testable independently of the Tauri runtime. + +Device discovery uses a Rust `DeviceRepository` trait and a TypeScript +`DeviceService` interface. The composition roots (`src-tauri/src/lib.rs` and +`src/main.ts`) choose the real adapters. Tests inject fake repositories or services. +Explicit development-only demo scenarios replace IPC and are visibly labeled. +A USB error never silently turns into demo data. + +Rust's serialized DTO defines the IPC wire contract. For these small commands we +use a small explicit TypeScript interface, runtime response validation and a Rust +serialization test instead of introducing a type-generation toolchain. Revisit +generation as commands grow. An unchecked generic on `invoke` is not sufficient: +the adapter validates required fields and unique connection IDs before returning +data to the presentation layer. + +## 3. Tests and simulations + +- **Rust unit tests**: business rules with injected dependencies. +- **Rust integration tests**: adapters with temporary files and fixtures. +- **Vitest**: composables and services with controlled results and failures. +- **Vue Test Utils**: visible behavior and user interactions. +- **Browser journeys**: introduce these with the first interactive workflows. +- **Hardware checks**: document USB verification separately; it must never be + required to run the normal test suite. + +A fake is a simplified working implementation. A mock verifies an interaction. +Prefer fakes for demo scenarios, and use mocks when the interaction itself matters. + +Test empty, loading, success and error states, then relevant concurrency scenarios: +disconnection, stale results and cancellation. Tests should validate behavior +rather than duplicate implementation details. + +## 4. Initial decisions + +- Vue Composition API and strict TypeScript. +- No global store or router until there is a concrete need. +- Tailwind for styling; shared components for recurring patterns. +- Dark application theme with semantic color tokens in `src/styles.css`: charcoal + canvas and surfaces, mint primary accent `#7CFFB2`, muted text, warning and error + colors. Use tokens rather than feature-specific palettes. The desktop shell has + a left device sidebar and a main workspace; below 768 px they stack vertically. +- Preserve standard keyboard behavior, visible focus indicators and reduced-motion + support. The keystore format uses a themed, accessible select-only combobox; + the device picker retains its native select. The document uses a dark color + scheme for native form controls. +- English for all UI text, accessibility labels, documentation and comments. +- No native plugin or permission without a feature that requires it. +- Run blocking operations outside the UI thread when they are introduced. +- Structured business errors translated into understandable UI messages. +- Formatting, lint, builds and tests run in CI without a phone. +- macOS distribution uses a universal Tauri bundle, ad-hoc signing and the existing + Sparkle feed. Quality CI verifies the release bundle; only `main` publishes. + +## 5. Feature definition of done + +1. Expected behavior and failures are defined. +2. External dependencies are replaceable. +3. Meaningful tests pass without a phone. +4. Simulated scenarios are reproducible. +5. Behavior, limitations and test commands are documented. +6. Quality checks have been run and their results reported. diff --git a/docs/features/adb.md b/docs/features/adb.md new file mode 100644 index 00000000..c27d6701 --- /dev/null +++ b/docs/features/adb.md @@ -0,0 +1,128 @@ +# ADB connection and Android information + +## Behavior + +Selecting a USB device automatically starts an authenticated ADB session, including +the default selection of the first discovered device at startup or refresh. The +phone may display **Allow USB debugging**. Unlock it and accept the prompt; choose +to remember this host on the phone if desired. Authorization waits up to 30 seconds. +If the prompt is denied or left unanswered, the interface reports **Authorization +required** and offers **Retry ADB connection**. A missing handshake times out +separately, rather than assuming the user rejected authentication. + +The overview displays these read-only values: + +- Android manufacturer and model +- Android version and API level +- Security patch date and build identifier +- CPU ABIs (with the legacy single-ABI property as a fallback) +- Battery percentage, normalized using the device's battery scale, and charging status + +Properties come from `getprop`; battery data comes from `dumpsys battery`. No shell +command is supplied by the frontend. Missing values are **Unavailable**. If only +the battery command is unsupported, properties remain visible with a warning. +Transport failures clear the snapshot and release the session. USB descriptor +information remains visible and is never presented as Android system properties. + +The bottom bar displays USB as the transport and the ADB state separately: +**Connecting…**, **Connected**, **Authorization required**, **Disconnected**, or +**Unavailable**. The app retains an authenticated session while that device is +selected. **Refresh Android info** reads a new snapshot through that session. +Changing selection releases the old session; clearing selection releases USB. +Requests are serialized, and late results never overwrite a newer selection. + +There is no periodic polling or hotplug subscription. Information and connection +state reflect the last request. Use either refresh button to check a disconnection; +the sidebar's **Refresh** also updates the list of USB attachments. + +## Architecture and identity + +```text +App selection -> useAdb -> AdbService interface -> validated Tauri IPC + -> read_android_info -> Rust AdbService -> AdbConnector / AdbSession traits + -> UsbAdbConnector -> rsadb -> nusb -> device adbd +``` + +`rsadb` handles the ADB wire protocol, RSA authorization and shell streams. +`nusb` handles the ADB USB interface. The existing discovery adapter remains +`rusb`/libusb. No Android SDK, external adb executable or local ADB server is needed. + +Before connecting, the adapter re-enumerates discovery devices and resolves the +requested connection ID. It requires a non-null serial unique among devices with +the same vendor/product IDs. It then requires exactly one matching ADB transport +device with those values. It never falls back to the first device, or to a model +name. A missing/ambiguous serial produces `identity_unavailable`; USB metadata +remains available. This conservative matching avoids mixing up identical phones +across the two USB libraries. IDs identify attachments and can be reused after +disconnection; they are not permanent device identities. + +The app creates a 2048-bit RSA host key at `/adb/adbkey` on first use. +Generation and filesystem access run on a blocking worker. The private key is +atomically persisted without replacing another instance's key, with owner-only +permissions on Unix. Subsequent sessions reuse it; an unreadable or corrupt key +produces a structured error instead of silently replacing the identity. Android +Tools uses its own key rather than modifying `~/.android/adbkey`. + +Native session changes are guarded by an async mutex. Enumeration/key preparation +run off the UI thread; opening USB and the handshake have a combined 40-second +deadline (including the 30-second authorization wait), and each shell command has +a 10-second deadline. A failed read drops the session. Frontend selection changes +wait for any in-flight bounded request, then act on the latest selection. Disposal +queues a disconnect after any outstanding request. + +## Limitations and errors + +- USB ADB only; no wireless pairing, emulators, fastboot, root operations or device + actions. RSA authorization is supported; TLS authentication is not. +- Another process, such as an Android Studio ADB server, may hold the USB interface. + An interface-claim error asks the user to close other clients and stop their ADB + server before retrying. The app does not kill processes or stop servers itself. +- Permissions and drivers still apply: Linux may need udev rules; Windows needs a + compatible USB driver. Native behavior on those platforms needs hardware checks. +- ADB requires a unique readable serial; discovery can still show devices without one. +- Battery formats and properties vary across Android versions and vendors. Empty + properties and unrecognized battery fields remain absent, never fabricated. +- Storage and RAM information are not included in this increment. + +## Simulations and automated verification + +Open `/?demo=devices` in development: + +- `DEMO-A`: successful connection, Android 16, battery 82% and charging. +- `DEMO-B`: authorization required. +- The third USB device: USB access failure. + +Every demo is visibly labeled. Native failures never switch to demo data. All +automated tests run without a phone, SDK, ADB server or native window: + +```bash +npm run check +npm run check:rust +npm run tauri build -- --no-bundle +``` + +Tests cover IPC validation, exact selection matching, missing/duplicate serials, +key reuse and permissions, authorization errors, session reuse/release, partial +battery failures, property parsing, malformed battery scales, retry, stale results, +view disposal and overview/status-bar integration. + +## Hardware-only verification + +These checks have not been performed during automated implementation: + +1. Connect a phone with USB debugging enabled. Select it, accept the authorization + prompt and compare all displayed values with Android Settings. +2. Restart the app and verify the same host key is reused. If the phone remembered + the key, no new authorization should be required. +3. Revoke USB debugging authorizations on the phone; deny or ignore the next prompt. + Verify the authorization message, then retry and accept. +4. Connect two identical models with different serials; switch between them and + confirm the information and status bar always follow the selection. +5. Switch or clear selection during authorization. Confirm no late data appears + for the old phone and its USB interface is released when the request completes. +6. Disconnect during a read and after a successful read. Refresh and confirm stale + Android information is cleared, then reconnect and retry. +7. Run another ADB server that holds the interface. Confirm a useful access error; + close/stop that server and retry successfully. +8. Refresh battery information and test a phone with missing vendor properties. + Missing fields should remain unavailable while other information is shown. diff --git a/docs/features/apk.md b/docs/features/apk.md new file mode 100644 index 00000000..724cbe4d --- /dev/null +++ b/docs/features/apk.md @@ -0,0 +1,130 @@ +# APK analysis + +Open **APK β†’ APK analysis** and drop one `.apk` onto the workspace or select +**Choose APK**. Processing is local, read-only and independent of device selection. +No Java, Android SDK, external executable or network upload is involved. + +## Report + +- Application icon, label, package and file name. +- APK size and application version (version name and version code). +- Debuggable status: **Yes**, **No**, or **Unavailable** for unresolved values. +- Minimum and maximum SDK. An absent minimum is shown as Android's default API 1; + an absent maximum is **No maximum declared**. The target SDK is not a maximum. +- Available v1/v2/v3/v3.1 and source-stamp certificates: subject, issuer, validity, + serial, algorithm and SHA-1/SHA-256 fingerprints. Identical certificates are + grouped across signing schemes; source stamps remain separate from APK signers. +- A separate cryptographic signature status: **Verified**, **Invalid**, + **Not verified**, or **Unsigned**, with the checked signing schemes. + +The screen is a compact application card followed by signature details. The first +certificate is expanded and additional certificates can be opened individually. +Missing version values display **Unavailable**. An absent debuggable attribute +uses Android's default, **No**. This flag is not proof that an APK is a release build. +Resource resolution is best-effort and does not enumerate all localized variants. + +### Application icons + +The resolved application icon supports PNG, JPEG and WebP, plus binary XML and +text XML Android VectorDrawable and adaptive icons. Vector paths, groups/transforms, +clipping, fill/stroke, ARGB colors, alpha, tint and linear/radial gradients are +rendered locally. Adaptive icons combine their background and foreground, resolve +resource IDs/aliases and use a circular mask over Android's 72dp viewport within +the 108dp layer canvas. Percentage insets are supported. Launcher masks vary by +device, so this is a preview, not a reproduction of every launcher. + +Images are limited to 2 MiB and 2048Γ—2048 source pixels. Vector previews are 192Γ—192 +PNG images. Resource recursion is limited to 16 levels, drawable trees to 2,048 +nodes and total drawable input to 8 MiB. Unsupported drawables (including animations, +trim paths, sweep gradients and theme-dependent colors), missing resources and +decode failures use the generic package icon rather than a partial rendering. +No unrelated archive image is substituted. + +### Signature verification + +Certificate reading and cryptographic verification are independent operations. +The verifier checks v2/v3/v3.1 signing blocks using the original signed bytes: + +- APK content digests, including the ZIP entries, central directory and adjusted EOCD. +- Signer signatures using RSA PKCS#1/PSS, ECDSA or DSA and SHA-256/SHA-512 as defined + by the supported APK algorithm IDs. +- Matching certificate/public keys, signature/digest algorithm lists, signed SDK + ranges for v3/v3.1, and v2 declarations requiring a v3 block. + +Every signer in every recognized block must pass for **Verified**. Invalid +signatures/content/structure produce **Invalid**; unsupported algorithms/structures +and unreadable verification input produce **Not verified**. APKs with only JAR/v1 +signatures are explicitly **Not verified**; their certificates remain available. +**Unsigned** means no modern signing block or JAR signature entry was detected. + +This checks cryptographic file integrity, not publisher trust or Android installation +compatibility. It does not implement JAR/v1 verification, v4 `.idsig`, source-stamp +verification, certificate revocation/trust or Android's complete SDK-dependent +signer selection, signature rotation lineage and downgrade policies. It is not a +replacement for the full Android platform verifier. Unreadable certificates can +still produce a separate report note even when a verification result is available. + +The tool does not decompile DEX, analyze behavior, scan for malware, extract files, +or support AAB/XAPK/APKM containers. Split APKs are inspected individually and are +not merged into a complete installed application. + +## Implementation + +Rust `features/apk` exposes `ApkInspector` and a validated analysis entry point. +The native adapter uses `apk-info` for binary XML/resources/certificates, `zip` for +the inventory, and OpenSSL for hashing and cryptographic verification. A bounded +signing-block parser preserves signed bytes; `apksig` computes APK chunked content +digests. It parses a temporary snapshot so the hash +and metadata describe the same bytes. The snapshot is removed automatically. +The analysis contract retains the detailed manifest and inventory internally; +the presentation selects the compact summary. The icon renderer resolves resources +through an injectable boundary, converts supported Android nodes to generated SVG, +and uses `resvg` to rasterize them to PNG. APK SVG/XML is never injected into the UI. +Raster icon bytes are read with a size bound and returned as a validated inline image. +The CSP allows `data:` images +for these previews; no filesystem permission or remote image loading is needed. +Tauri commands run blocking operations on worker threads; the file dialog reuses +the existing native dialog plugin. Frontend event permissions allow native drop +listener registration and cleanup, without granting filesystem APIs to the webview. + +Limits are 1 GiB compressed input, 64 MiB total declared manifest/resource/META-INF +metadata, and 100,000 archive entries. Decoded manifest output is also limited to +64 MiB. Signing blocks are limited to 16 MiB. These are interactive-analysis limits, +not a sandbox guarantee. Corrupt +archives/manifests fail explicitly; certificate failures can yield a partial report. + +Vue receives an injected `ApkService`; the IPC adapter validates nested report +fields. The workspace preserves results across navigation, unregisters inactive +drop listeners, and ignores stale analysis responses. Selecting a replacement +clears the previous report. Already-started native work finishes in the background. +Canceling the file dialog preserves the current result. + +## Verification + +`npm run check` and `npm run check:rust` exercise generated binary-manifest APKs, +certificate extraction, missing/corrupt/oversized inputs, report serialization, +valid and modified v2/v3/v3.1 signatures, mismatched certificate keys, unsupported +algorithms, binary XML vectors, adaptive compositing, gradients, clipping and alpha, +IPC validation, bounded icon previews/fallbacks, summary defaults, certificate +grouping, chooser cancellation, drag/drop validation, failures/retry, stale +responses and listener cleanup. `npm run test:e2e` includes the browser demo journey. +No phone or SDK is needed. `/?demo=empty` provides an explicitly simulated report; +`/?demo=error` simulates analysis failure and neither reads a local file. + +Desktop-only checks (manual): + +1. Run `npm run tauri dev`, open APK analysis without a connected phone. +2. Choose a known real APK, cancel a second dialog, and confirm the report remains. +3. Drop a real APK from Finder/Explorer, then a different APK; confirm the latest + report wins. Try a non-APK and multiple files together. +4. Compare manifest values and certificate fingerprints against known build output; + exercise resource-based labels and v1/v2/v3-signed packages. +5. Navigate away and drop a file; return and confirm the old report is preserved. +6. Check raster, vector and adaptive-icon APKs; compare their previews with a launcher. +7. Confirm absent maximum SDK is not replaced with target SDK and absent debuggable + is shown as No. Confirm keyboard navigation and long certificate fields remain usable. +8. Compare v2/v3 signature results against a known signed build and an independently + verified APK. Modify a signed ZIP entry and confirm **Invalid**. Check a v1-only + APK reports **Not verified**, rather than Verified or Unsigned. + +Native OS dialogs and OS file drops cannot be verified by the Chromium demo suite. diff --git a/docs/features/devices.md b/docs/features/devices.md new file mode 100644 index 00000000..f40cde6b --- /dev/null +++ b/docs/features/devices.md @@ -0,0 +1,111 @@ +# Device discovery and selection + +## Behavior + +- Discover connected Android USB devices at startup and on **Refresh**. +- Choose a device from the left sidebar dropdown. The main workspace displays + the selected connection's details or a getting-started screen when none is selected. +- Match the ADB interface class/subclass/protocol `ff/42/01`. +- Show each physical USB device once, even if it exposes multiple interfaces. +- Use USB product strings as names, falling back to **Android device**. +- Display serial numbers when available and always include the connection ID. +- Automatically select the first device in discovery order when there is no valid + selection, including startup and refresh. Allow choosing another device manually. +- Preserve the selected ID across successful refreshes while it remains present. +- Clear devices and selection on discovery failure, and allow retry. +- If the selected connection disappears, select the first remaining device, or + clear selection when the list is empty. +- Disable refresh and selection during discovery. Ignore stale asynchronous results + and results received after the owning Vue scope has been disposed. + +Selection is owned by the feature's composable and is not persisted to disk. +The application forwards the selection to the separate [ADB feature](adb.md), +which authenticates and reads Android information. Discovery itself never claims +the ADB interface. + +## Data flow + +```text +DevicePicker.vue + -> useDevices (loading, errors, selection, request lifetime) + -> DeviceService interface + -> Tauri service adapter (IPC response validation) + -> list_devices command (spawn_blocking) + -> Rust DeviceService (deterministic ordering) + -> DeviceRepository trait + -> UsbDeviceRepository (rusb / libusb) +``` + +The domain and application code have no dependency on Tauri or a USB library. +Serde serialization is used at the Rust boundary. The USB adapter does not claim +interfaces, execute shell commands or invoke an external `adb` process. + +USB metadata reads have a 200 ms timeout per descriptor request. Missing or +inaccessible metadata is a per-device warning, not a discovery-wide failure. +Failure to initialize USB or enumerate the bus produces a structured +`usb_unavailable` error. A failed worker task produces an `internal` error. + +## Simulated dependencies + +Run `npm run dev` and open one of these URLs: + +| URL | Scenario | +| ------------------------------------- | ----------------------------------------------------------------------------------------- | +| `http://127.0.0.1:1420/?demo=devices` | Two identical models with different serials and a third device with inaccessible metadata | +| `http://127.0.0.1:1420/?demo=empty` | No connected devices | +| `http://127.0.0.1:1420/?demo=error` | USB discovery failure | + +Demo mode is explicitly labeled and development-only. A browser without the demo +parameter reports that the desktop runtime is required. Production uses real USB. + +Frontend tests inject services with controlled results, failures and pending +promises. Rust tests inject `FakeRepository` into the application service. No test +enumerates the machine's USB devices or opens a native window. + +## Known limitations + +- Only physical USB ADB interfaces are supported. Emulators, wireless ADB, fastboot + and MTP-only interfaces are not listed. +- Discovery does not check ADB authorization. An unauthorized phone may still + appear; selecting it starts the separate ADB authentication flow. +- Refresh is manual after the initial scan. There is no hotplug subscription or + background polling, so unplugging a phone is reflected on the next refresh. +- IDs combine USB bus, address, vendor and product IDs. They identify the current + attachment, not the permanent phone identity. A disconnected USB address may be + reused between scans; use the serial/label to confirm the intended device. +- Names come from USB descriptors, not Android's `getprop` model property. +- Devices disappearing before their descriptors can be read are skipped. If all + configuration descriptors are inaccessible, the ADB interface cannot be identified + and that device is skipped. Missing string metadata alone does not hide a device. +- Linux may require udev permissions. Windows needs a libusb-compatible driver for + metadata access. Native builds and hardware behavior on those OSes are not yet + verified by this milestone's macOS CI. + +## Verification + +```bash +npm run check +npm run check:rust +npm run tauri build -- --no-bundle +``` + +### Hardware-only checklist + +These checks must be performed manually; automated fakes do not prove USB hardware +compatibility. They have not been performed as part of the automated implementation. + +1. Start without a phone and verify the empty state. +2. Connect a phone with USB debugging enabled and click **Refresh**. +3. Verify the name/serial against the phone and select it. +4. Connect a second phone, ideally the same model. Verify both are distinct and + selecting either displays its own identity. +5. Refresh with both connected and verify the selection is retained. +6. Disconnect the selected phone and refresh. Verify the first remaining phone is + selected, or selection is cleared if none remain. +7. Disable USB debugging and confirm an MTP-only connection is not listed. +8. Where possible, deny USB metadata access and verify the device is retained with + a warning when its ADB configuration descriptors remain readable. +9. Run with Android Studio open and confirm enumeration remains usable. Discovery + does not claim USB interfaces or stop another application's ADB server. Selecting + a phone starts ADB, which can report an interface-claim failure if another client + already owns it. diff --git a/docs/features/keystore-explorer.md b/docs/features/keystore-explorer.md new file mode 100644 index 00000000..f1c9e8aa --- /dev/null +++ b/docs/features/keystore-explorer.md @@ -0,0 +1,79 @@ +# Keystore explorer + +The **APK & Keystore** navigation group includes a read-only **Keystore explorer**. +Choose a local file or enter its path, supply its store password and click +**Explore keystore**. The workspace and report survive navigation. Passwords are +held only in workspace memory, never persisted or logged. No private key bytes +cross IPC. Native request passwords and decrypted JKS key buffers are zeroized on +drop. Source files are never written. + +## Reports and credential checks + +- JKS: all aliases are sorted, with private-key or trusted-certificate types and + each entry's certificate chain. Successful loading checks the password-protected + store digest, including when the password is empty. +- JKS private keys initially show **Key password not checked**. Select an alias + and click **Verify key**. An empty key password reuses the store password. The + adapter decrypts the key and compares its public key to the leaf certificate. + Failure leaves the readable store metadata visible and does not declare the + store corrupt. Changing key credentials resets previous key-check results. +- PKCS12: OpenSSL exposes the first signing identity and additional certificates. + Private-key unlocking uses the store password and the public key is compared to + the certificate. Separate passwords and a complete multi-key inventory are not + supported. Additional certificates are not classified as trusted entries or + presented as a verified chain. Missing friendly names display **No alias available**. +- PKCS12 password protection and the integrity MAC are checked by OpenSSL when + present. MAC-less stores cannot establish store integrity; the UI therefore + reports that the store opened rather than asserting integrity verification. +- Certificates show subject, issuer, serial, validity dates, SHA-1 and SHA-256. + Trust, revocation, current validity and compatibility with a previously published + application are not validated. Dates are displayed as reported by OpenSSL. + +A wrong password and damaged authenticated data cannot always be distinguished. +Messages explicitly describe that ambiguity. Decode failures can also indicate +unsupported formats or encryption; legacy PKCS12 encryption unavailable in the +default OpenSSL provider is not enabled automatically. Empty stores are supported. + +## Architecture and limits + +`explorer.rs` defines serializable reports, structured errors, input validation and +the injectable `KeystoreInspector`. `explorer_native.rs` reads a regular file into +a bounded snapshot (16 MiB), detects JKS by its magic and otherwise tries PKCS12. +JKS lengths are checked before dependency allocations, with limits of 4096 entries +and 64 certificates per chain. Unknown versions, duplicate aliases and trailing +data are rejected. The JKS dependency supports UTF-8 aliases; Java modified UTF-8 +edge cases may be rejected as unsupported data. +`explorer_commands.rs` runs the use case on a blocking worker. The existing native +keystore picker and clipboard command are reused. + +The frontend has its own service contract and validated IPC adapter. Reports are +cleared when the path or store password changes, before new requests and after +errors. Request revisions prevent late responses from showing stale data. Dialog +cancellation preserves the current report. Controls are disabled during work. + +## Verification + +Run `npm run check` and `npm run check:rust`. Native tests generate real stores in +temporary directories, check multiple aliases and trusted entries, separate key +passwords, empty stores/passwords, unreadable files, truncated contents and digest +damage. They verify read-only behavior and IPC serialization. Vue tests cover +credential changes, retry, cancellation, stale results and limitations; IPC tests +reject malformed certificates and statuses. No Java, phone or native window is +required. + +Development browser demo: open `/?demo=empty`, navigate to **Keystore explorer**, +choose the simulated file and use `demo-password`. Use the same password for the +key or deliberately enter a different one. `/?demo=error` simulates an unlock +failure. Demo data is visibly labeled and never substitutes for real errors. + +Desktop checks (manual): + +1. Generate JKS and PKCS12 stores in the application and open each in the explorer. +2. Compare alias and fingerprint values with the generation result and, optionally, + `keytool -list -v -keystore ` (let keytool prompt for the password). +3. Open a multi-entry JKS with a trusted certificate and distinct key passwords; + check each key with correct and incorrect passwords. +4. Try an incorrect store password and a truncated copy of the file. Confirm that + no stale success report remains and that errors do not imply certain corruption. +5. Cancel file selection, navigate away and back, and check native clipboard/show + password controls. Confirm the source file remains unchanged. diff --git a/docs/features/keystore.md b/docs/features/keystore.md new file mode 100644 index 00000000..87cba633 --- /dev/null +++ b/docs/features/keystore.md @@ -0,0 +1,123 @@ +# Keystore generation + +## Scope and interaction + +**Generate keystore** replaces the planned APK generation destination. It creates +a new JKS (`.jks`) or PKCS12 (`.p12`) file with one RSA 2048-bit private key and a +self-signed SHA256withRSA certificate. No phone, Java, Android SDK, external tool, +network access or account is required at runtime. + +- Destination: an absolute path selected through the native save dialog or typed + manually. Missing extensions from the dialog are added. Changing format replaces + a `.jks`/`.p12` extension. Other mismatched extensions are rejected. +- Passwords: 6–128 printable ASCII characters, including spaces, preserved exactly. + This intentionally conservative constraint avoids Java/Android password encoding + differences. Show/hide and copy are icon buttons inside every password field, + including confirmations, with accessible labels and hover titles. The primary + password fields also offer a Generate icon: Web Crypto creates + 24 uniformly distributed characters (144 bits of entropy) and fills confirmation. +- JKS can use a distinct key password; the default reuses the store password. + Leaving the optional key password empty also reuses the store password, even + when Use keystore password is unchecked. The store password remains required. + PKCS12 always uses one password for both protections. Switching to a shared + password clears the previous distinct key password and confirmation. +- The format dropdown uses the shared `SelectField.vue` widget with themed options, + descriptions and a selected checkmark. It supports arrow keys, Home/End, + typeahead, Enter/Space, Escape, Tab and outside-click dismissal. It closes when + disabled or navigating away; keyboard focus stays on the combobox trigger. +- Alias: defaults to `upload`, with 1–64 lowercase ASCII letters, digits, `.`, `_` + or `-`. This avoids Java's case-insensitive alias surprises and JKS modified-UTF + encoding ambiguity. +- Validity: 1–100 calendar years, default 30, with a UTC expiration date preview. + February 29 clamps to February 28 when necessary. The actual date is computed + again at generation time. +- Identity: a nonempty common name is required by this form; organization, + organizational unit, city, state and two-letter country code are optional. + Identity strings support UTF-8, at most 128 bytes per field, without control + characters. Country codes are normalized to uppercase; membership in an ISO + country list is not checked. These values appear in the public certificate. + +Submission validates locally and again in Rust. While choosing a location or +generating, the form is disabled and duplicate submissions are ignored. Cancellation +of the save dialog preserves the previous path. Failures retain inputs for retry. +The result displays the destination, format, alias, expiry and copyable SHA-1/SHA-256 +certificate fingerprints. Show in folder uses the operating system's file manager. +SHA-1 is an identifier for integrations, not the certificate signature algorithm. + +The screen is cached during navigation so an ongoing operation and its result are +not lost. Form passwords remain in application memory until edited or the window +closes; they are never saved in settings or local storage. Copy explicitly writes +to the system clipboard and does not automatically clear it. Native request +password strings are zeroized on drop; JavaScript/IPC/library copies cannot be +guaranteed to be erased. No secret values are included in errors or logs. + +## Implementation + +- Frontend: `src/features/keystore` contains service contracts, validation, adapters + and the workspace. `PasswordField.vue` is a shared widget. UI components use the + injected `KeystoreService`, never direct IPC. +- Rust: `src-tauri/src/features/keystore` contains the request/result/error contract, + validation/use case, `KeystoreEncoder` and `KeystoreFiles` traits, native adapters + and thin commands. Cryptography and writes run on a blocking worker. +- Vendored OpenSSL generates RSA keys, certificates and PKCS12; `jks` encodes JKS + with its random feature explicitly enabled. JKS uses its legacy Java-defined + password protection for compatibility. No manual cryptography is implemented. +- A sibling temporary file is synced then published without replacement. Existing + files, directories and symlinks are rejected, including a destination created + after the initial check. Failed operations clean up the temporary file. Unix + files have owner-only permissions; Windows uses the destination folder's ACLs. +- Tauri dialog, clipboard and opener plugins are accessed through feature commands + on the Rust side. No generic frontend plugin permissions are added. + +Only new stores are supported: no import, modification, additional keys, APK signing +or configurable algorithms. Generated files have been tested against Java 21; +compatibility with every older JDK, Gradle version or Android signing tool is not +claimed. Native dialogs, clipboard access and file-manager reveal require desktop +verification on each supported operating system. + +## Verification + +```bash +npm run check +npm run check:rust +npx playwright install chromium +npm run test:e2e +``` + +Rust behavior tests inject failing/fake dependencies and use temporary directories. +Native integration tests decrypt both formats, reject wrong passwords, verify RSA +key/certificate correspondence and self-signature, check fingerprints and Unix file +permissions. Existing-file/race tests ensure no replacement or temporary-file leak. +Frontend tests exercise validation, shared/distinct passwords, generation, clipboard +failure, dialog cancellation, PKCS12 switching, busy state, retry and IPC validation. +Playwright covers the full simulated journey, navigation persistence, clipboard, +narrow viewport layout and a failed write. + +Optional Java interoperability check (not part of the normal suite): + +```bash +cargo test --manifest-path src-tauri/Cargo.toml keytool_can_import --locked -- --ignored +``` + +This generates disposable stores and asks `keytool` to import their private keys +into another PKCS12 file, then decrypts the imported result. It needs `keytool` on +PATH but no Android SDK. Test passwords are fixed disposable fixtures. + +### Desktop checks + +1. Run `npm run tauri dev`; open Generate keystore without a phone. +2. Choose and cancel destinations; verify the native dialog and both extensions. +3. Generate/show/hide/copy passwords, including a distinct JKS key password. Paste + into a disposable editor to check the native clipboard. +4. Generate both formats; confirm displayed fingerprints and Show in folder. +5. Try an existing file and a nonexistent/unwritable folder; verify understandable + errors, no file replacement and successful retry at a new destination. +6. Navigate away during generation and return; verify the result remains available. + +### Explicit demos + +Development-only `?demo=empty`/`?demo=devices` use deterministic simulated results; +`?demo=error` reports a simulated write failure. The keystore screen labels these +scenarios, creates no file and disables Show in folder. Clipboard actions still +copy the chosen value through the browser clipboard. A normal browser session +without a demo shows a native-runtime-required message rather than fake success. diff --git a/docs/features/logging.md b/docs/features/logging.md new file mode 100644 index 00000000..89cc5012 --- /dev/null +++ b/docs/features/logging.md @@ -0,0 +1,69 @@ +# Local application logs + +Open **Settings β†’ Application logs β†’ Open logs folder** to open the native file +manager. Failures are shown inline and can be retried. The action works without a +phone and is simulated explicitly in development demos. + +## Storage and retention + +The Rust composition root initializes `log` with `flexi_logger` in Tauri's +`app_log_dir()` before starting device services. The native command resolves the +same directory; it accepts no path from the webview. For the current app identifier: + +- macOS: `~/Library/Logs/com.thomasbernard.androidtools` +- Windows: the app's local data directory, under `logs` +- Linux: the app's local data directory, under `logs` + +`android-tools_rCURRENT.log` is appended across restarts. Rotation uses numbered +archives at **5 MiB**, retaining **5 archives plus the current file** (approximately +30 MiB). Rotation happens at record boundaries, so a file can exceed the threshold +by one record. Cleanup also runs when the logger starts. No external log-rotation +daemon is needed. Concurrent application processes sharing this directory are not +supported by the file writer. + +Writes run on a background thread. Normal application exit drains and shuts down +the writer. A forced kill, power loss or native crash can lose queued entries. +Initialization failures fail desktop startup rather than silently promising logs +that cannot be written; subsequent writer failures are reported by the library to +stderr. File access follows the current user's filesystem permissions. + +## Events and privacy + +Each line includes an RFC 3339 UTC timestamp, level and Rust target. The level is +`INFO` for application targets; third-party dependency logging is disabled. This +deliberate filter avoids verbose USB/network/crypto internals and accidental +credential leakage. Local logging is always active, independently of Sentry consent, +and does not itself send data over the network. + +Logged events include startup/version, normal exit, USB discovery, ADB information +reads/disconnection, APK analysis/signing, keystore generation and log-folder opening. +Operations record success/failure and elapsed milliseconds. A successfully completed +signing command also includes user cancellation; it does not imply a file was saved. +Rust panics record their source location, while preserving the existing panic hook. +Vue errors, unhandled JavaScript errors and promise rejections record only a fixed +event identifier through a dedicated IPC adapter. Existing Vue error reporting is +preserved and logging failures cannot recursively produce unhandled rejections. + +Do not log request/response bodies, passwords, keys, clipboard values, user paths, +device serials, Android properties or arbitrary error payloads. In particular, +frontend exception messages and stacks are intentionally absent from local logs. +New Rust diagnostics should use `log::info!`, `log::warn!` or `log::error!` with +deliberately selected non-sensitive fields. Business logic remains independent of +the logger: operation instrumentation lives at the command boundary. + +## Verification + +`npm run check` and `npm run check:rust` cover rotation with small files, bounded +retention, restart append, dependency filtering, invalid destinations, IPC event +validation, Vue integration, folder-action errors, pending requests and retry. +Tests use temporary directories and injected services without a native window. + +Desktop checks (no phone required): + +1. Launch the app, open Settings and activate **Open logs folder** with the keyboard. +2. Verify the file manager opens the directory containing the active log. Refresh + devices and confirm a timestamped operation result appears, including failures. +3. Disable Sentry reporting and verify local events are still written. +4. Restart the app and verify the active file retains the previous session. +5. Trigger an unhandled error from the development webview inspector and verify + `frontend event=unhandled_error` appears without the exception payload. diff --git a/docs/features/settings.md b/docs/features/settings.md new file mode 100644 index 00000000..ec1aa287 --- /dev/null +++ b/docs/features/settings.md @@ -0,0 +1,93 @@ +# Application settings + +**Settings** is the last navigation item, pinned to the bottom of the desktop +sidebar. It works without a device and preserves the selected device/ADB session. + +- **Create GitHub issue** opens the project's issue creation page in the default + browser. **View on GitHub** opens the source repository. Native IPC accepts only + the two destination identifiers, not arbitrary URLs. Browser failures are shown + inline and can be retried. +- **Open logs folder** opens the local rotating-log directory in the native file + manager. Logs rotate at 5 MiB with 5 archives retained. Errors can be retried; + no arbitrary path is accepted over IPC. See [Local logs](logging.md). +- **Check for updates** opens Sparkle's native UI in installed macOS releases. + Sparkle also checks automatically, verifies downloads and handles installation. + Other platforms and unbundled development builds report unavailability rather + than pretending the application is up to date. See [Build and release](../releases.md). +- **Sentry crash reporting** saves a preference, initially off, in `settings.json` + inside Tauri's application configuration directory. Writes atomically replace + the file on a blocking worker. Failed writes retain the previous UI state; + malformed settings surface an error instead of silently resetting preferences. + +## Sentry error reporting + +Provide the same build-time variable used by the Flutter application: + +```bash +SENTRY_DSN='https://PUBLIC_KEY@HOST/PROJECT_ID' npm run tauri build -- --no-bundle +``` + +For local verification, supply it to `npm run tauri dev` instead. Cargo rebuilds +when the variable changes. The native SDK also accepts `SENTRY_DSN` at process +startup if no valid build-time DSN was supplied. The DSN is a public ingestion +identifier, not an auth token. Without a valid DSN, delivery is disabled without +preventing startup. +`SENTRY_AUTH_TOKEN` is not needed at runtime and must not be embedded in the app. + +The native client reads persisted consent before initialization; missing or corrupt +preferences mean reporting is off. An atomic gate checks every event before it is +queued. Successful saves immediately update it for both stacks, without restarting; +failed saves preserve the previous state. Events already queued or in flight when +the switch is disabled may still finish sending. Settings writes are serialized. + +`@sentry/vue` captures Vue errors, unhandled JavaScript errors and rejected promises. +Its browser transport is disabled: events go through a narrow IPC command to the +Rust `sentry` client, which also captures Rust panics. This keeps the DSN, consent, +release (`android-tools@`) and environment (`development` or +`production`) in one place, without widening the webview's network CSP. Malformed +or oversized frontend events are discarded; reporting failures do not break the UI. +Browser-only mode and frontend demo scenarios do not initialize the Vue SDK. + +Unlike Flutter `main`, this integration does not collect replays or screenshots. +Component props, automatic breadcrumbs, sessions and performance tracing are also +disabled. Error messages and stack traces can still contain application data. +Expected errors already handled and shown by feature services are not automatically +reported. Native signals/segfaults, errors before native setup, offline persistence +and Flutter preference migration are not supported. The release workflow uploads +native dSYM symbols when Sentry credentials are configured. JavaScript source-map +upload is not yet provided. + +## Architecture and verification + +Vue receives a `SettingsService` contract; its Tauri adapter validates preferences +and translates structured errors. Native commands delegate persistence to a +runtime-independent file adapter. `src-tauri/src/reporting.rs` owns native reporting +and the runtime gate; `src/shared/infrastructure/crashReporting.ts` owns the Vue +adapter and injectable event sender. No new plugin permission is needed. + +Development demos keep preferences in memory and simulate GitHub, folder and update actions. The +`error` scenario reports deterministic failures. Unit tests cover persistence, +corruption, write failures, IPC validation, navigation, pending operations, retry +and both link actions. Run `npm run check` and `npm run check:rust`. +Reporting tests use an in-memory Sentry transport and an injected IPC sender; no +Sentry project or network connection is required. They cover opt-in/out on both +stacks, restart, failed saves, corrupted settings, invalid payloads and Vue capture. + +Desktop checks (no phone required): + +1. Confirm Settings sits below all other navigation items at the bottom of the + sidebar, including after resizing; verify keyboard navigation and switch focus. +2. Toggle the preference, restart the app and verify it is restored. Repeat for + the opposite value. +3. Open both GitHub actions and verify the default browser reaches the expected + repository and new issue pages. +4. Open the logs folder and verify it contains the active application log. In an + installed release, check that **Check for updates** opens Sparkle's dialog; + in an ordinary development build, confirm the explicit unavailable error. +5. In a desktop development build with a test DSN, enable reporting and trigger + an unhandled error in the webview inspector (for example, + `setTimeout(() => { throw new Error('Sentry desktop verification'); }, 0)`). + Verify a JavaScript event arrives with the native release and environment. + Disable reporting and repeat with a distinct message: no new event should arrive. + Repeat after restarting for each preference. Delivery and symbolication against + a real Sentry project are manual checks, not part of the automated suite. diff --git a/docs/features/signing.md b/docs/features/signing.md new file mode 100644 index 00000000..7cbd1c2f --- /dev/null +++ b/docs/features/signing.md @@ -0,0 +1,72 @@ +# APK signing + +Open **APK β†’ APK signing**, enter or choose an APK and an existing JKS or PKCS12 +keystore, then provide its key alias and password. A separate JKS key password is +optional; leaving it empty reuses the keystore password. Click **Sign APK**. + +Signing completes locally before the native **Save signed APK** dialog opens. Its +suggested filename is the source basename with `-signed` before the extension: +`my.app.apk` becomes `my.app-signed.apk`. Choose a destination to publish the file. +The result shows its full path and **Show in folder**. Cancelling the dialog saves +nothing and cleans up temporary files; click **Sign APK** again to retry. +Existing files are never replaced, including the original APK and keystore. + +No phone, Java, Android SDK, external executable or network service is required. +The form and result survive navigation. Passwords stay in the in-memory form for +retry, are not persisted, and are excluded from Rust debug output. Rust-owned +password and private-key export buffers are erased on drop. JavaScript strings +cannot be reliably erased. + +## Supported inputs and output + +- APK Signature Scheme **v2**, RSA PKCS#1 v1.5 with SHA-256. Output requires + **Android 7.0 / API 24 or later**. No v1, v3, v4 or key-rotation lineage is emitted. +- JKS private-key entries with a matching X.509 certificate, including keystores + created by **Generate keystore**. PKCS12 supports one private key and certificate, + with a matching certificate friendly-name alias and a shared store/key password. + Multi-key PKCS12 files, absent friendly names and non-RSA keys are unsupported. +- Input files up to 1 GiB, 100,000 ZIP entries, 2 GiB total unpacked content and + 16 MiB keystores. Only stored/deflated, unencrypted ZIP entries are supported. + Inputs must contain `AndroidManifest.xml`; signing does not establish that the + application is otherwise valid or installable. +- Previous signing blocks and top-level JAR signatures under `META-INF` are removed. + Other entry content is retained. ZIP metadata/compressed bytes may change. + Stored entries are aligned to 4 bytes; stored `lib/**/*.so` entries to 16 KiB. + Signing does not change ELF segment alignment inside native libraries. +- The source is snapshotted and rebuilt in temporary storage. Provision space for + the input snapshot, rebuilt unsigned APK, signed APK and destination-side copy. + Temporary files are removed after success, cancellation or handled failure. +- The completed APK's RSA signature and content digest are checked before opening + the dialog. Publication uses a synced sibling temporary file and atomic + no-clobber creation. A write failure can be retried by signing again. + +## Architecture and verification + +`features/signing` owns the Rust request, errors, `ApkSigner`/`SignedArtifact` +contracts and sign-then-save use case. The native adapter loads keys with `jks` / +OpenSSL, rebuilds and aligns entries with `zip`, and signs with `apksig`. The thin +Tauri command executes blocking work off the UI thread and supplies the save dialog. + +Vue injects a `SigningService`; the IPC adapter validates nullable path responses +and translates structured errors. Explicit development demo scenarios (`?demo=empty` +or `?demo=devices`) simulate signing and saving; `?demo=error` simulates an unlock +failure. Simulations never create files or open a native dialog. + +Run `npm run check` and `npm run check:rust`. Tests exercise workflow ordering, +cancellation cleanup, naming, input errors, both generated keystore formats, +wrong passwords/alias, content retention, library alignment, re-signing, tampering, +independent OpenSSL signature verification, no-clobber publication, IPC validation +and form retry/duplicate submission behavior. They require no native window or SDK. + +Desktop verification (manual): + +1. Generate both JKS and PKCS12 keystores and sign a real APK with each. +2. Confirm the save dialog appears after signing with `name-signed.apk`; cancel + once and confirm no output, then retry into a new filename. +3. Check wrong passwords/alias fail before any save dialog; check an existing + destination is preserved and **Show in folder** reveals a successful result. +4. With Android SDK Build Tools available, independently run + `apksigner verify --verbose --print-certs --min-sdk-version 24 ` + and `zipalign -c -P 16 -v 4 `. +5. Install on an Android 7+ device. An installed application signed by another key + must be uninstalled before installing this differently signed package. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 00000000..408beb72 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,142 @@ +# macOS build and release + +`.github/workflows/release.yml` preserves the Flutter distribution contract: +push to `main`, a draft GitHub release with generated notes, a universal macOS +application, an ad-hoc signature, the historical DMG layout, Sentry native symbols, +a Sparkle-signed download, and the existing public appcast. Development branches +never publish. Manual dispatch is also restricted to `main`. + +Build and publication steps are explicit in the workflow. Small Bash helpers install +Sparkle and verify the bundle; `scripts/release.mjs` validates metadata and updates +XML using Node.js. No Python release script is required. + +## Preparing a version + +1. Set the same calendar SemVer in `src-tauri/tauri.conf.json`, `package.json` and + `src-tauri/Cargo.toml`. SemVer does not allow leading zeroes: use `2026.6.1`, + not `2026.06.1`. Update lockfiles through npm and Cargo when versions change. +2. Increment `bundle.macOS.bundleVersion` in `src-tauri/tauri.conf.json`. This is + Sparkle's independent, strictly increasing integer build number, formerly the + `+35` suffix in Flutter's `pubspec.yaml`. The first release from this rewrite is + `2026.10.3`, build `39`, following build `38` in the preserved appcast. +3. Update `CHANGELOG.md`, which remains the appcast release-notes destination. +4. Merge to `main`. Quality checks and the release pipeline run automatically. + +GitHub tags and download names retain the historical zero-padded month: +`2026.06.1` and `AndroidTools-2026.06.1-macos.dmg`. The installed application's +short version uses SemVer; Sparkle compares the integer build number. + +The workflow reads the latest appcast from `main`. An already advertised version +and build are a no-op; reused versions, reused builds and older builds fail. +Releases target the triggering commit, rather than a potentially newer branch tip. + +## GitHub configuration + +Reuse these existing secrets: + +| Secret | Purpose | +| --------------------- | -------------------------------------------------------------------------- | +| `SPARKLE_PRIVATE_KEY` | Required: signs the DMG using the existing Ed25519 key. | +| `SENTRY_DSN` | Optional: enables reporting in the installed app, subject to user consent. | +| `SENTRY_AUTH_TOKEN` | Optional, with the next two secrets: uploads native symbols. | +| `SENTRY_ORG` | Sentry organization. | +| `SENTRY_PROJECT` | Sentry project. | + +The workflow's `GITHUB_TOKEN` needs `contents: write` for releases and the appcast +commit. Branch protection must permit that appcast commit. No personal token or +Apple certificate is introduced. The release remains ad-hoc signed and not +notarized, matching Flutter; macOS can require approval on first manual installation. + +The existing repository, issue and appcast URLs are fixed in application/release +configuration. The old `REPOSITORY_URL`, `ISSUE_URL` and `APP_CAST_FILE_PATH` +repository variables are no longer consumed. The public appcast URL is unchanged: + +```text +https://raw.githubusercontent.com/ThomasBernard03/AndroidTools/refs/heads/main/appcast.xml +``` + +## Release sequence and retries + +1. Validate metadata and create/reuse a draft for the exact source commit. +2. Install Node 24, the repository's pinned Rust toolchain, both macOS targets and + Sparkle 2.9.6 (matching `tauri-plugin-sparkle-updater` 0.3.0). +3. Run frontend and Rust checks, including compilation and tests with Sparkle enabled. +4. Build `universal-apple-darwin` with `macos-updater` and + `src-tauri/tauri.release.conf.json`. Verify bundle identity, version/build, + framework presence, runtime search path, architectures and code signature. +5. If all Sentry credentials exist, upload both architectures' dSYM bundles, + associate commits and finalize `android-tools@`, matching the Rust SDK. + Missing credentials skip the upload; upload failures fail the job. JavaScript + source maps are not uploaded by this workflow. +6. Create `AndroidTools--macos.dmg` with the restored Flutter background, + icon positions and Applications shortcut. Sign it with Sparkle, then independently + verify its signature against the historical public key. The private key is held + in a restricted temporary file removed when the step exits. +7. Upload the DMG to the draft and transfer public metadata between jobs. +8. Generate the feed against the latest `main`, publish the release, then commit + the appcast. Existing feed entries are retained. Publishing the download first + prevents clients from seeing an update whose file is still private. + +Only one release workflow runs at a time. A failed build leaves a draft. Rerun the +same workflow to retry it. If publication succeeds but the appcast push fails, +rerunning **all jobs of that run** downloads the already-published DMG and repairs +the feed without rebuilding or replacing the public artifact. A release from a +different commit cannot be overwritten. If `main` has moved during the final push, +the push fails rather than overwriting that change; rerun all jobs to read the new +feed. The appcast-only bot commit does not trigger another release. + +## Sparkle continuity + +The bundle keeps `com.example.androidTools`, `Android Tools.app`, the historical +public key and feed URL. These values permit existing Flutter/Sparkle installations +to identify the replacement. The release includes Sparkle.framework, automatic +checks and **Settings β†’ Check for updates**, which opens Sparkle's native UI. +Sparkle verifies downloads and handles installation/relaunch. + +The framework is optional for normal development and unit tests. Unbundled builds +and other platforms return an explicit unavailable error for manual update checks. +Explicit browser demos simulate the action; they never request an update. + +Restoring the historical identifier also changes Tauri's application directories +from the provisional `com.thomasbernard.androidtools` identifier used during this +branch's development. Those provisional settings/ADB host keys are not migrated. +Flutter preference migration is also outside this release flow. + +## Local verification + +Normal checks require no Sparkle installation, phone, signing key or GitHub access: + +```bash +npm ci +npm run check +npm run check:rust +``` + +On macOS, verify the release bundle without publishing or accessing secrets: + +```bash +bash scripts/setup-sparkle.sh +export SPARKLE_FRAMEWORK_PATH="$PWD/src-tauri" +rustup target add aarch64-apple-darwin x86_64-apple-darwin +cargo test --manifest-path src-tauri/Cargo.toml --features macos-updater --locked \ + --config "target.$(rustc -vV | sed -n 's/^host: //p').runner=['env', 'DYLD_FRAMEWORK_PATH=$SPARKLE_FRAMEWORK_PATH']" +npm run tauri build -- --target universal-apple-darwin --features macos-updater --config src-tauri/tauri.release.conf.json -- --locked +bash scripts/verify-macos-bundle.sh +``` + +The Cargo runner sets the framework path on the test executable itself; setting it +only on the Cargo process is insufficient on some macOS environments. Quality CI +also builds and verifies the universal release bundle on pull requests, without +release secrets or publication. + +Manual distribution verification (not part of the hardware-free suite): + +- Mount the DMG and check its background, icon and Applications shortcut; install + and launch on both Intel and Apple Silicon. +- From a historical Flutter installation, check that Sparkle offers the new build, + validates the download, replaces the app and relaunches the Tauri version. +- In the installed Tauri release, use **Check for updates** and verify Sparkle's + up-to-date, network failure and subsequent signed-update dialogs. +- With a test Sentry project and reporting enabled, verify that native events match + the release and uploaded dSYM UUIDs. No secret-bearing publication is performed + by local tests. diff --git a/documentation/apk_inspector.png b/documentation/apk_inspector.png deleted file mode 100644 index 9d962ffe..00000000 Binary files a/documentation/apk_inspector.png and /dev/null differ diff --git a/documentation/apk_inspector_detail.png b/documentation/apk_inspector_detail.png deleted file mode 100644 index a943b4c3..00000000 Binary files a/documentation/apk_inspector_detail.png and /dev/null differ diff --git a/documentation/device_information.png b/documentation/device_information.png deleted file mode 100644 index 3841f858..00000000 Binary files a/documentation/device_information.png and /dev/null differ diff --git a/documentation/file_explorer.png b/documentation/file_explorer.png deleted file mode 100644 index d0b8c3f0..00000000 Binary files a/documentation/file_explorer.png and /dev/null differ diff --git a/documentation/logcat.png b/documentation/logcat.png deleted file mode 100644 index 0605c828..00000000 Binary files a/documentation/logcat.png and /dev/null differ diff --git a/documentation/macos_install_screenshot_1.png b/documentation/macos_install_screenshot_1.png deleted file mode 100644 index d9b50d6d..00000000 Binary files a/documentation/macos_install_screenshot_1.png and /dev/null differ diff --git a/documentation/macos_install_screenshot_2.png b/documentation/macos_install_screenshot_2.png deleted file mode 100644 index 5facf527..00000000 Binary files a/documentation/macos_install_screenshot_2.png and /dev/null differ diff --git a/documentation/macos_install_screenshot_3.png b/documentation/macos_install_screenshot_3.png deleted file mode 100644 index 0c32c916..00000000 Binary files a/documentation/macos_install_screenshot_3.png and /dev/null differ diff --git a/e2e/apk.spec.ts b/e2e/apk.spec.ts new file mode 100644 index 00000000..91d7de0a --- /dev/null +++ b/e2e/apk.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +test('inspects a simulated APK without a device and retains the report across navigation', async ({ + page, +}) => { + await page.goto('/?demo=empty'); + await page.getByRole('button', { name: 'APK analysis', exact: true }).click(); + await expect( + page.getByText('Demo mode β€” simulated APK analysis.'), + ).toBeVisible(); + await page.getByRole('button', { name: 'Choose APK', exact: true }).click(); + await expect( + page.getByText('com.example.demo', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('1.2.0 (12)', { exact: true })).toBeVisible(); + await expect(page.getByText('5.00 MiB', { exact: true })).toBeVisible(); + await expect( + page.getByText('No maximum declared', { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Signature', exact: true }), + ).toBeVisible(); + await expect(page.getByLabel('Signature verification status')).toHaveText( + 'Verified Β· v2', + ); + await expect( + page.getByText( + 'Simulated verification result. No real APK signature was checked.', + ), + ).toBeVisible(); + await expect(page.getByText('SHA256withRSA', { exact: true })).toBeVisible(); + await expect(page.getByText('Archive contents', { exact: true })).toHaveCount( + 0, + ); + await page + .getByRole('button', { name: 'Device overview', exact: true }) + .click(); + await page.getByRole('button', { name: 'APK analysis', exact: true }).click(); + await expect( + page.getByText('com.example.demo', { exact: true }), + ).toBeVisible(); +}); + +test('shows an explicit simulated analysis error', async ({ page }) => { + await page.goto('/?demo=error'); + await page.getByRole('button', { name: 'APK analysis', exact: true }).click(); + await page.getByRole('button', { name: 'Choose APK', exact: true }).click(); + await expect( + page.getByText('Simulated invalid APK. Choose another file to retry.'), + ).toBeVisible(); + await expect(page.getByLabel('APK report')).toHaveCount(0); +}); diff --git a/e2e/keystore.spec.ts b/e2e/keystore.spec.ts new file mode 100644 index 00000000..eeff4b35 --- /dev/null +++ b/e2e/keystore.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; + +test('creates a simulated keystore without a device and preserves the form across navigation', async ({ + page, + context, +}) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto('/?demo=empty'); + const navigation = page.getByRole('navigation', { name: 'Workspace' }); + await navigation.getByRole('button', { name: 'Generate keystore' }).click(); + await expect( + page.getByRole('heading', { name: 'Generate keystore' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Browse…' }).click(); + await page + .getByRole('button', { name: 'Generate keystore password', exact: true }) + .click(); + const password = await page + .getByLabel('Keystore password', { exact: true }) + .inputValue(); + expect(password).toHaveLength(24); + await expect( + page.getByLabel('Confirm password', { exact: true }), + ).toHaveValue(password); + await page + .getByRole('button', { name: 'Show keystore password', exact: true }) + .click(); + await expect( + page.getByLabel('Keystore password', { exact: true }), + ).toHaveAttribute('type', 'text'); + await page + .getByRole('button', { name: 'Hide keystore password', exact: true }) + .click(); + await page + .getByRole('button', { name: 'Copy keystore password', exact: true }) + .click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(password); + await page + .getByLabel('Name / Common name (CN)', { exact: true }) + .fill('Demo Signing'); + const format = page.getByRole('combobox', { name: 'Keystore format' }); + await format.focus(); + await format.press('ArrowDown'); + await expect( + page.getByRole('listbox', { name: 'Keystore format' }), + ).toBeVisible(); + await format.press('End'); + await format.press('Enter'); + await expect(format).toContainText('PKCS12'); + await expect(page.getByLabel('Save location')).toHaveValue( + '/demo/upload.p12', + ); + await page + .getByRole('main') + .getByRole('button', { name: 'Generate keystore', exact: true }) + .click(); + await expect( + page.getByRole('heading', { name: 'Simulated keystore generated' }), + ).toBeVisible(); + await navigation.getByRole('button', { name: 'Device overview' }).click(); + await navigation.getByRole('button', { name: 'Generate keystore' }).click(); + await expect( + page.getByRole('heading', { name: 'Simulated keystore generated' }), + ).toBeVisible(); + await expect( + page.getByLabel('Keystore password', { exact: true }), + ).toHaveValue(password); + await page.setViewportSize({ width: 390, height: 844 }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); +}); + +test('shows inline validation and an explicit simulated write failure', async ({ + page, +}) => { + await page.goto('/?demo=error'); + await page + .getByRole('navigation', { name: 'Workspace' }) + .getByRole('button', { name: 'Generate keystore' }) + .click(); + const submit = page + .getByRole('main') + .getByRole('button', { name: 'Generate keystore', exact: true }); + await submit.click(); + await expect(page.getByLabel('Save location')).toHaveAttribute( + 'aria-invalid', + 'true', + ); + await page.getByRole('button', { name: 'Browse…' }).click(); + await page + .getByRole('button', { name: 'Generate keystore password', exact: true }) + .click(); + await page + .getByLabel('Name / Common name (CN)', { exact: true }) + .fill('Demo Signing'); + await submit.click(); + await expect(page.getByRole('main').getByRole('alert')).toContainText( + 'Simulated failure', + ); + await expect(submit).toBeEnabled(); + await expect( + page.getByRole('heading', { name: 'Simulated keystore generated' }), + ).toHaveCount(0); +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..640127af --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,35 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import vue from 'eslint-plugin-vue'; +import globals from 'globals'; +import ts from 'typescript-eslint'; + +export default ts.config( + { + ignores: [ + 'dist/**', + 'coverage/**', + 'playwright-report/**', + 'test-results/**', + 'src-tauri/target/**', + 'src-tauri/gen/**', + 'src-tauri/Sparkle.framework/**', + 'src-tauri/sparkle-tools/**', + ], + }, + js.configs.recommended, + ...ts.configs.recommended, + ...vue.configs['flat/recommended'], + { + files: ['**/*.{ts,vue}'], + languageOptions: { + globals: globals.browser, + parserOptions: { parser: ts.parser }, + }, + }, + prettier, + { + files: ['scripts/*.mjs'], + languageOptions: { globals: globals.node }, + }, +); diff --git a/index.html b/index.html new file mode 100644 index 00000000..256a1a26 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Android Tools + + +
+ + + diff --git a/lib/features/apk_inspector/core/apk_inspector_module.dart b/lib/features/apk_inspector/core/apk_inspector_module.dart deleted file mode 100644 index ea5b15a8..00000000 --- a/lib/features/apk_inspector/core/apk_inspector_module.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:android_tools/features/apk_inspector/data/repositories/apk_repository_impl.dart'; -import 'package:android_tools/features/apk_inspector/data/repositories/recent_apk_repository_impl.dart'; -import 'package:android_tools/features/apk_inspector/domain/repositories/apk_repository.dart'; -import 'package:android_tools/features/apk_inspector/domain/repositories/recent_apk_repository.dart'; -import 'package:android_tools/features/apk_inspector/domain/usecases/get_recent_apks_usecase.dart'; -import 'package:android_tools/features/apk_inspector/domain/usecases/install_apk_usecase.dart'; -import 'package:android_tools/features/apk_inspector/domain/usecases/parse_apk_usecase.dart'; -import 'package:android_tools/features/apk_inspector/domain/usecases/save_recent_apk_usecase.dart'; -import 'package:android_tools/main.dart'; - -/// Dependency injection module for the APK Inspector feature -class ApkInspectorModule { - /// Configure all dependencies for the APK Inspector feature - static void configureDependencies() { - _registerRepositories(); - _registerUseCases(); - } - - static void _registerRepositories() { - getIt.registerLazySingleton( - () => ApkRepositoryImpl( - getIt.get(), // AaptClient - getIt.get(), // AdbClient - getIt.get(), // Logger - ), - ); - - getIt.registerLazySingleton( - () => RecentApkRepositoryImpl( - getIt.get(), // AppDatabase - getIt.get(), // Logger - ), - ); - } - - static void _registerUseCases() { - getIt.registerLazySingleton(() => ParseApkUsecase(getIt.get())); - getIt.registerLazySingleton(() => InstallApkUsecase(getIt.get())); - getIt.registerLazySingleton(() => GetRecentApksUsecase(getIt.get())); - getIt.registerLazySingleton(() => SaveRecentApkUsecase(getIt.get())); - } -} diff --git a/lib/features/apk_inspector/data/repositories/apk_repository_impl.dart b/lib/features/apk_inspector/data/repositories/apk_repository_impl.dart deleted file mode 100644 index b2a72ba3..00000000 --- a/lib/features/apk_inspector/data/repositories/apk_repository_impl.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'dart:io'; - -import 'package:aapt_dart/aapt_dart.dart' as aapt; -import 'package:adb_dart/adb_dart.dart'; -import 'package:logger/logger.dart'; -import 'package:path/path.dart' as p; - -import '../../domain/entities/apk_info.dart'; -import '../../domain/entities/apk_permission.dart'; -import '../../domain/entities/apk_signature.dart'; -import '../../domain/repositories/apk_repository.dart'; - -/// Repository implementation for APK inspection using AAPT and apksigner -class ApkRepositoryImpl implements ApkRepository { - final aapt.AaptClient _aaptClient; - final AdbClient _adbClient; - final Logger _logger; - - ApkRepositoryImpl( - this._aaptClient, - this._adbClient, - this._logger, - ); - - @override - Future parseApk(String apkPath) async { - _logger.i('Parsing APK: $apkPath'); - - final file = File(apkPath); - if (!await file.exists()) { - throw Exception('APK file not found: $apkPath'); - } - - final sizeInBytes = await file.length(); - final sizeInMB = sizeInBytes / (1024 * 1024); - - final apkInfo = await _aaptClient.getApkInfo(apkPath); - _logger.d('AAPT badging output parsed'); - - final permissions = apkInfo.permissions.map((name) { - final level = _categorizePermissionLevel(name); - return ApkPermission(name: name, level: level); - }).toList(); - - final resourcesOutput = await _aaptClient.dumpResources(apkPath); - final resourcesCount = _countResources(resourcesOutput); - - final dexCount = await _countDexFiles(apkPath); - final assetsCount = await _countAssets(apkPath); - - ApkSignature? signature; - try { - final signatureInfo = await _aaptClient.getApkSignature(apkPath); - if (signatureInfo != null) { - final isDebugKeystore = _isAndroidDebugKeystore( - issuer: signatureInfo.issuer, - ); - signature = ApkSignature( - scheme: signatureInfo.scheme, - sha256: signatureInfo.sha256, - issuer: signatureInfo.issuer, - validFrom: signatureInfo.validFrom, - validTo: signatureInfo.validTo, - algorithm: signatureInfo.algorithm, - keySize: signatureInfo.keySize, - isDebugKeystore: isDebugKeystore, - ); - } - } catch (e) { - _logger.w('Could not extract signature: $e'); - } - - return ApkInfo( - filePath: apkPath, - fileName: p.basename(apkPath), - sizeInMB: double.parse(sizeInMB.toStringAsFixed(1)), - packageName: apkInfo.packageName, - appLabel: apkInfo.applicationLabel ?? 'Unknown App', - version: apkInfo.versionName, - versionCode: apkInfo.versionCode, - minSdk: apkInfo.sdkVersion ?? 21, - targetSdk: apkInfo.targetSdkVersion ?? 31, - compileSdk: apkInfo.compileSdkVersion ?? apkInfo.targetSdkVersion ?? 31, - isDebuggable: apkInfo.isDebuggable, - abis: apkInfo.nativeLibraries, - localesCount: apkInfo.locales.length, - permissions: permissions, - signature: signature, - activitiesCount: apkInfo.launchableActivities.length, - servicesCount: apkInfo.services.length, - receiversCount: apkInfo.receivers.length, - providersCount: apkInfo.providers.length, - dexFilesCount: dexCount, - resourcesCount: resourcesCount, - assetsCount: assetsCount, - ); - } - - @override - Future installApk(String apkPath, String deviceId) async { - _logger.i('Installing APK: $apkPath to device: $deviceId'); - final apkFile = File(apkPath); - await _adbClient.installApplication(apkFile, deviceId); - _logger.i('APK installed successfully'); - } - - String _categorizePermissionLevel(String permissionName) { - const dangerousPermissions = [ - 'CAMERA', - 'RECORD_AUDIO', - 'READ_CONTACTS', - 'WRITE_CONTACTS', - 'READ_CALENDAR', - 'WRITE_CALENDAR', - 'READ_CALL_LOG', - 'WRITE_CALL_LOG', - 'PROCESS_OUTGOING_CALLS', - 'READ_PHONE_STATE', - 'CALL_PHONE', - 'USE_SIP', - 'SEND_SMS', - 'RECEIVE_SMS', - 'READ_SMS', - 'RECEIVE_WAP_PUSH', - 'RECEIVE_MMS', - 'READ_EXTERNAL_STORAGE', - 'WRITE_EXTERNAL_STORAGE', - 'ACCESS_FINE_LOCATION', - 'ACCESS_COARSE_LOCATION', - 'ACCESS_BACKGROUND_LOCATION', - 'BODY_SENSORS', - 'READ_MEDIA_IMAGES', - 'READ_MEDIA_VIDEO', - 'READ_MEDIA_AUDIO', - 'POST_NOTIFICATIONS', - 'BLUETOOTH_CONNECT', - 'BLUETOOTH_SCAN', - 'BLUETOOTH_ADVERTISE', - ]; - - final shortName = permissionName.split('.').last; - if (dangerousPermissions.contains(shortName)) { - return 'dangerous'; - } - - if (permissionName.contains('c2dm.permission') || - permissionName.contains('BIND_')) { - return 'signature'; - } - - return 'normal'; - } - - int _countResources(String resourcesOutput) { - final matches = RegExp(r"resource 0x[0-9a-f]+").allMatches(resourcesOutput); - return matches.length; - } - - Future _countDexFiles(String apkPath) async { - try { - final files = await _aaptClient.listApkFiles(apkPath); - return files.where((f) => RegExp(r'classes\d*\.dex').hasMatch(f)).length; - } catch (e) { - _logger.w('Could not count DEX files: $e'); - return 1; - } - } - - Future _countAssets(String apkPath) async { - try { - final files = await _aaptClient.listApkFiles(apkPath); - return files.where((f) => f.startsWith('assets/')).length; - } catch (e) { - _logger.w('Could not count assets: $e'); - return 0; - } - } - - bool _isAndroidDebugKeystore({required String issuer}) { - return issuer.contains('Android Debug'); - } -} diff --git a/lib/features/apk_inspector/data/repositories/recent_apk_repository_impl.dart b/lib/features/apk_inspector/data/repositories/recent_apk_repository_impl.dart deleted file mode 100644 index 574ecc37..00000000 --- a/lib/features/apk_inspector/data/repositories/recent_apk_repository_impl.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'dart:io'; - -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; -import 'package:android_tools/features/apk_inspector/domain/repositories/recent_apk_repository.dart'; -import 'package:android_tools/shared/data/datasources/local/app_database.dart'; -import 'package:drift/drift.dart'; -import 'package:logger/logger.dart'; - -class RecentApkRepositoryImpl implements RecentApkRepository { - final AppDatabase _database; - final Logger _logger; - - RecentApkRepositoryImpl(this._database, this._logger); - - @override - Future> getRecentApks({int limit = 10}) async { - _logger.d('Getting recent APKs with limit: $limit'); - - final recentApks = await (_database.select(_database.recentApkModel) - ..orderBy([ - (t) => OrderingTerm(expression: t.lastInspectedAt, mode: OrderingMode.desc), - ]) - ..limit(limit)) - .get(); - - final apkInfoList = []; - - for (final apk in recentApks) { - final file = File(apk.filePath); - if (await file.exists()) { - apkInfoList.add( - ApkInfo( - filePath: apk.filePath, - fileName: apk.fileName, - sizeInMB: apk.sizeInMB, - packageName: apk.packageName, - appLabel: apk.appLabel, - version: apk.version, - versionCode: apk.versionCode, - minSdk: apk.minSdk, - targetSdk: apk.targetSdk, - compileSdk: 0, - isDebuggable: false, - abis: [], - localesCount: 0, - permissions: [], - signature: null, - activitiesCount: 0, - servicesCount: 0, - receiversCount: 0, - providersCount: 0, - dexFilesCount: 0, - resourcesCount: 0, - assetsCount: 0, - ), - ); - } else { - _logger.w('APK file not found, removing from history: ${apk.filePath}'); - await removeRecentApk(apk.filePath); - } - } - - _logger.d('Found ${apkInfoList.length} valid recent APKs'); - return apkInfoList; - } - - @override - Future saveRecentApk(ApkInfo apkInfo) async { - _logger.d('Saving recent APK: ${apkInfo.packageName}'); - - await _database.transaction(() async { - final existing = await (_database.select(_database.recentApkModel) - ..where((t) => t.filePath.equals(apkInfo.filePath))) - .getSingleOrNull(); - - if (existing != null) { - await (_database.update(_database.recentApkModel) - ..where((t) => t.id.equals(existing.id))) - .write( - RecentApkModelCompanion( - lastInspectedAt: Value(DateTime.now()), - appLabel: Value(apkInfo.appLabel), - version: Value(apkInfo.version), - versionCode: Value(apkInfo.versionCode), - sizeInMB: Value(apkInfo.sizeInMB), - ), - ); - _logger.d('Updated existing recent APK entry'); - } else { - await _database.into(_database.recentApkModel).insert( - RecentApkModelCompanion.insert( - filePath: apkInfo.filePath, - fileName: apkInfo.fileName, - sizeInMB: apkInfo.sizeInMB, - packageName: apkInfo.packageName, - appLabel: apkInfo.appLabel, - version: apkInfo.version, - versionCode: apkInfo.versionCode, - minSdk: apkInfo.minSdk, - targetSdk: apkInfo.targetSdk, - lastInspectedAt: DateTime.now(), - ), - ); - _logger.d('Inserted new recent APK entry'); - - final count = await _database.recentApkModel.count().getSingle(); - if (count > 10) { - final oldestEntries = await (_database.select(_database.recentApkModel) - ..orderBy([ - (t) => OrderingTerm(expression: t.lastInspectedAt, mode: OrderingMode.asc), - ]) - ..limit(count - 10)) - .get(); - - for (final entry in oldestEntries) { - await (_database.delete(_database.recentApkModel) - ..where((t) => t.id.equals(entry.id))) - .go(); - } - - _logger.d('Removed ${oldestEntries.length} old entries to maintain limit'); - } - } - }); - } - - @override - Future removeRecentApk(String filePath) async { - _logger.d('Removing recent APK: $filePath'); - - await (_database.delete(_database.recentApkModel) - ..where((t) => t.filePath.equals(filePath))) - .go(); - } - - @override - Future clearRecentApks() async { - _logger.d('Clearing all recent APKs'); - - await _database.delete(_database.recentApkModel).go(); - } -} diff --git a/lib/features/apk_inspector/domain/entities/apk_info.dart b/lib/features/apk_inspector/domain/entities/apk_info.dart deleted file mode 100644 index 5dba4cf6..00000000 --- a/lib/features/apk_inspector/domain/entities/apk_info.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:dart_mappable/dart_mappable.dart'; - -import 'apk_permission.dart'; -import 'apk_signature.dart'; - -part 'apk_info.mapper.dart'; - -/// Complete metadata and information about an APK file -@MappableClass() -class ApkInfo with ApkInfoMappable { - /// Full path to the APK file on disk - final String filePath; - - /// File name of the APK - final String fileName; - - /// Size of the APK file in megabytes - final double sizeInMB; - - /// Android package name (e.g., com.example.app) - final String packageName; - - /// Application label/name - final String appLabel; - - /// Version name (e.g., 1.4.0) - final String version; - - /// Version code (integer) - final int versionCode; - - /// Minimum SDK version required - final int minSdk; - - /// Target SDK version - final int targetSdk; - - /// Compile SDK version - final int compileSdk; - - /// Whether the APK is debuggable - final bool isDebuggable; - - /// Supported ABIs (e.g., arm64-v8a, armeabi-v7a) - final List abis; - - /// Number of supported locales/languages - final int localesCount; - - /// List of permissions requested by the app - final List permissions; - - /// Signature information (null if unsigned) - final ApkSignature? signature; - - /// Number of activities in the manifest - final int activitiesCount; - - /// Number of services in the manifest - final int servicesCount; - - /// Number of broadcast receivers in the manifest - final int receiversCount; - - /// Number of content providers in the manifest - final int providersCount; - - /// Number of DEX files in the APK - final int dexFilesCount; - - /// Total number of resources - final int resourcesCount; - - /// Number of assets - final int assetsCount; - - const ApkInfo({ - required this.filePath, - required this.fileName, - required this.sizeInMB, - required this.packageName, - required this.appLabel, - required this.version, - required this.versionCode, - required this.minSdk, - required this.targetSdk, - required this.compileSdk, - required this.isDebuggable, - required this.abis, - required this.localesCount, - required this.permissions, - this.signature, - required this.activitiesCount, - required this.servicesCount, - required this.receiversCount, - required this.providersCount, - required this.dexFilesCount, - required this.resourcesCount, - required this.assetsCount, - }); -} diff --git a/lib/features/apk_inspector/domain/entities/apk_permission.dart b/lib/features/apk_inspector/domain/entities/apk_permission.dart deleted file mode 100644 index 98707bd4..00000000 --- a/lib/features/apk_inspector/domain/entities/apk_permission.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:dart_mappable/dart_mappable.dart'; - -part 'apk_permission.mapper.dart'; - -/// Represents a permission requested by an APK -@MappableClass() -class ApkPermission with ApkPermissionMappable { - /// Permission name (e.g., android.permission.INTERNET) - final String name; - - /// Permission protection level: dangerous, normal, signature, etc. - final String level; - - const ApkPermission({ - required this.name, - required this.level, - }); -} diff --git a/lib/features/apk_inspector/domain/entities/apk_signature.dart b/lib/features/apk_inspector/domain/entities/apk_signature.dart deleted file mode 100644 index 48ed336d..00000000 --- a/lib/features/apk_inspector/domain/entities/apk_signature.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:dart_mappable/dart_mappable.dart'; - -part 'apk_signature.mapper.dart'; - -/// Represents signature information for an APK -@MappableClass() -class ApkSignature with ApkSignatureMappable { - /// Signature scheme (e.g., v2, v3, v2 + v3) - final String scheme; - - /// SHA-256 fingerprint of the certificate - final String sha256; - - /// Certificate issuer (e.g., CN=Company, O=Organization, C=US) - final String issuer; - - /// Certificate validity start date - final String validFrom; - - /// Certificate validity end date - final String validTo; - - /// Signature algorithm (e.g., SHA256withRSA) - final String algorithm; - - /// Key size in bits (e.g., 2048) - final int keySize; - - /// Whether the APK is signed with the Android debug keystore - final bool isDebugKeystore; - - const ApkSignature({ - required this.scheme, - required this.sha256, - required this.issuer, - required this.validFrom, - required this.validTo, - required this.algorithm, - required this.keySize, - this.isDebugKeystore = false, - }); -} diff --git a/lib/features/apk_inspector/domain/repositories/apk_repository.dart b/lib/features/apk_inspector/domain/repositories/apk_repository.dart deleted file mode 100644 index 45b796b9..00000000 --- a/lib/features/apk_inspector/domain/repositories/apk_repository.dart +++ /dev/null @@ -1,16 +0,0 @@ -import '../entities/apk_info.dart'; - -/// Repository interface for APK inspection operations -abstract class ApkRepository { - /// Parse an APK file and extract all metadata - /// - /// Returns complete [ApkInfo] with all extracted data - /// Throws an exception if the APK is invalid or cannot be parsed - Future parseApk(String apkPath); - - /// Install an APK to a connected Android device - /// - /// [apkPath] is the local path to the APK file - /// [deviceId] is the ADB device identifier - Future installApk(String apkPath, String deviceId); -} diff --git a/lib/features/apk_inspector/domain/repositories/recent_apk_repository.dart b/lib/features/apk_inspector/domain/repositories/recent_apk_repository.dart deleted file mode 100644 index 3fb53715..00000000 --- a/lib/features/apk_inspector/domain/repositories/recent_apk_repository.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; - -abstract class RecentApkRepository { - /// Get list of recently inspected APKs, ordered by most recent first - Future> getRecentApks({int limit = 10}); - - /// Save an APK to the recent history - Future saveRecentApk(ApkInfo apkInfo); - - /// Remove an APK from the recent history by file path - Future removeRecentApk(String filePath); - - /// Clear all recent APKs - Future clearRecentApks(); -} diff --git a/lib/features/apk_inspector/domain/usecases/get_recent_apks_usecase.dart b/lib/features/apk_inspector/domain/usecases/get_recent_apks_usecase.dart deleted file mode 100644 index 5c7c7f22..00000000 --- a/lib/features/apk_inspector/domain/usecases/get_recent_apks_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; -import 'package:android_tools/features/apk_inspector/domain/repositories/recent_apk_repository.dart'; - -class GetRecentApksUsecase { - final RecentApkRepository _repository; - - GetRecentApksUsecase(this._repository); - - Future> call({int limit = 10}) async { - return _repository.getRecentApks(limit: limit); - } -} diff --git a/lib/features/apk_inspector/domain/usecases/install_apk_usecase.dart b/lib/features/apk_inspector/domain/usecases/install_apk_usecase.dart deleted file mode 100644 index 553a64d1..00000000 --- a/lib/features/apk_inspector/domain/usecases/install_apk_usecase.dart +++ /dev/null @@ -1,13 +0,0 @@ -import '../repositories/apk_repository.dart'; - -/// Use case for installing an APK to a connected device -class InstallApkUsecase { - final ApkRepository _repository; - - InstallApkUsecase(this._repository); - - /// Install APK from [apkPath] to the device with [deviceId] - Future call(String apkPath, String deviceId) { - return _repository.installApk(apkPath, deviceId); - } -} diff --git a/lib/features/apk_inspector/domain/usecases/parse_apk_usecase.dart b/lib/features/apk_inspector/domain/usecases/parse_apk_usecase.dart deleted file mode 100644 index 28b3c16e..00000000 --- a/lib/features/apk_inspector/domain/usecases/parse_apk_usecase.dart +++ /dev/null @@ -1,14 +0,0 @@ -import '../entities/apk_info.dart'; -import '../repositories/apk_repository.dart'; - -/// Use case for parsing an APK file and extracting metadata -class ParseApkUsecase { - final ApkRepository _repository; - - ParseApkUsecase(this._repository); - - /// Parse the APK at [apkPath] and return complete metadata - Future call(String apkPath) { - return _repository.parseApk(apkPath); - } -} diff --git a/lib/features/apk_inspector/domain/usecases/save_recent_apk_usecase.dart b/lib/features/apk_inspector/domain/usecases/save_recent_apk_usecase.dart deleted file mode 100644 index 94d5b982..00000000 --- a/lib/features/apk_inspector/domain/usecases/save_recent_apk_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; -import 'package:android_tools/features/apk_inspector/domain/repositories/recent_apk_repository.dart'; - -class SaveRecentApkUsecase { - final RecentApkRepository _repository; - - SaveRecentApkUsecase(this._repository); - - Future call(ApkInfo apkInfo) async { - return _repository.saveRecentApk(apkInfo); - } -} diff --git a/lib/features/apk_inspector/presentation/apk_inspector_bloc.dart b/lib/features/apk_inspector/presentation/apk_inspector_bloc.dart deleted file mode 100644 index f9384eb8..00000000 --- a/lib/features/apk_inspector/presentation/apk_inspector_bloc.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'dart:async'; - -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; -import 'package:android_tools/features/apk_inspector/domain/usecases/get_recent_apks_usecase.dart'; -import 'package:android_tools/features/apk_inspector/domain/usecases/install_apk_usecase.dart'; -import 'package:android_tools/features/apk_inspector/domain/usecases/parse_apk_usecase.dart'; -import 'package:android_tools/features/apk_inspector/domain/usecases/save_recent_apk_usecase.dart'; -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/usecases/listen_selected_device_usecase.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logger/logger.dart'; - -part 'apk_inspector_event.dart'; -part 'apk_inspector_state.dart'; -part 'apk_inspector_bloc.mapper.dart'; - -/// BLoC managing the state and logic for the APK Inspector feature -class ApkInspectorBloc extends Bloc { - final ParseApkUsecase _parseApkUsecase = getIt.get(); - final InstallApkUsecase _installApkUsecase = getIt.get(); - final GetRecentApksUsecase _getRecentApksUsecase = getIt.get(); - final SaveRecentApkUsecase _saveRecentApkUsecase = getIt.get(); - final ListenSelectedDeviceUsecase _listenSelectedDeviceUsecase = getIt.get(); - final Logger _logger = getIt.get(); - - ApkInspectorBloc() : super(ApkInspectorState.initial()) { - on(_onSelectApkFile); - on(_onResetView); - on(_onInstallApk); - on(_onLoadRecentApks); - on(_onSelectRecentApk); - on(_onScreenAppearing); - - add(OnLoadRecentApks()); - add(OnScreenAppearing()); - } - - Future _onSelectApkFile( - OnSelectApkFile event, - Emitter emit, - ) async { - _logger.i('Selected APK file: ${event.apkPath}'); - - // Start parsing - emit( - state.copyWith(status: ApkInspectorStatus.parsing, errorMessage: null), - ); - - try { - // Parse the APK - final apkInfo = await _parseApkUsecase(event.apkPath); - - // Emit ready state with parsed info - emit(state.copyWith(status: ApkInspectorStatus.ready, apkInfo: apkInfo)); - - _logger.i('APK parsed successfully: ${apkInfo.packageName}'); - - // Save to recent APKs - try { - await _saveRecentApkUsecase(apkInfo); - _logger.d('Saved APK to recent history'); - - final updatedRecentApks = await _getRecentApksUsecase(); - emit(state.copyWith(recentApks: updatedRecentApks)); - } catch (e) { - _logger.w('Failed to save recent APK: $e'); - } - } catch (e, stackTrace) { - _logger.e('Error parsing APK', error: e, stackTrace: stackTrace); - - emit( - state.copyWith( - status: ApkInspectorStatus.error, - errorMessage: 'Failed to parse APK: ${e.toString()}', - ), - ); - } - } - - void _onResetView(OnResetView event, Emitter emit) { - _logger.i('Resetting APK Inspector view'); - - emit( - state.copyWith( - status: ApkInspectorStatus.idle, - apkInfo: null, - errorMessage: null, - ), - ); - } - - Future _onInstallApk( - OnInstallApk event, - Emitter emit, - ) async { - if (state.apkInfo == null) { - _logger.w('No APK info available, cannot install'); - return; - } - - _logger.i('Installing APK to device: ${event.deviceId}'); - - emit(state.copyWith(status: ApkInspectorStatus.installing)); - - try { - // Install the APK - await _installApkUsecase(state.apkInfo!.filePath, event.deviceId); - - emit(state.copyWith(status: ApkInspectorStatus.installed)); - - _logger.i('APK installed successfully'); - - // Reset to ready state after 2 seconds to show the install button again - await Future.delayed(const Duration(seconds: 2)); - if (!emit.isDone && state.status == ApkInspectorStatus.installed) { - emit(state.copyWith(status: ApkInspectorStatus.ready)); - } - } catch (e, stackTrace) { - _logger.e('Error installing APK', error: e, stackTrace: stackTrace); - - if (!emit.isDone) { - // Extract user-friendly error message - String errorMessage = e.toString(); - if (errorMessage.contains('INSTALL_FAILED_INSUFFICIENT_STORAGE')) { - errorMessage = 'Insufficient storage on device'; - } else if (errorMessage.contains('INSTALL_FAILED_ALREADY_EXISTS')) { - errorMessage = 'App already installed'; - } else if (errorMessage.contains('device') && - errorMessage.contains('not found')) { - errorMessage = 'Device not found'; - } else if (errorMessage.contains('Failed to install APK:')) { - // Keep only the part after "Failed to install APK:" - errorMessage = errorMessage - .substring(errorMessage.indexOf('Failed to install APK:') + 22) - .trim(); - } - - emit( - state.copyWith( - status: ApkInspectorStatus.error, - errorMessage: errorMessage, - ), - ); - - // Reset to ready state after 3 seconds to show the install button again - await Future.delayed(const Duration(seconds: 3)); - if (!emit.isDone && state.status == ApkInspectorStatus.error) { - emit( - state.copyWith( - status: ApkInspectorStatus.ready, - errorMessage: null, - ), - ); - } - } - } - } - - Future _onLoadRecentApks( - OnLoadRecentApks event, - Emitter emit, - ) async { - _logger.d('Loading recent APKs'); - - try { - final recentApks = await _getRecentApksUsecase(); - emit(state.copyWith(recentApks: recentApks)); - _logger.d('Loaded ${recentApks.length} recent APKs'); - } catch (e, stackTrace) { - _logger.e('Error loading recent APKs', error: e, stackTrace: stackTrace); - } - } - - Future _onSelectRecentApk( - OnSelectRecentApk event, - Emitter emit, - ) async { - _logger.i('Selected recent APK: ${event.apkPath}'); - - add(OnSelectApkFile(apkPath: event.apkPath)); - } - - Future _onScreenAppearing( - OnScreenAppearing event, - Emitter emit, - ) async { - await emit.onEach( - _listenSelectedDeviceUsecase(), - onData: (device) { - emit(state.copyWith(selectedDevice: device)); - _logger.d('Selected device updated: ${device?.deviceId ?? "none"}'); - }, - ); - } -} diff --git a/lib/features/apk_inspector/presentation/apk_inspector_event.dart b/lib/features/apk_inspector/presentation/apk_inspector_event.dart deleted file mode 100644 index 646ed1be..00000000 --- a/lib/features/apk_inspector/presentation/apk_inspector_event.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of 'apk_inspector_bloc.dart'; - -/// Base class for all APK Inspector events -sealed class ApkInspectorEvent {} - -/// Event triggered when user selects an APK file (via picker or drag-drop) -class OnSelectApkFile extends ApkInspectorEvent { - final String apkPath; - - OnSelectApkFile({required this.apkPath}); -} - -/// Event triggered to reset the view back to the import screen -class OnResetView extends ApkInspectorEvent {} - -/// Event triggered to install the current APK to a device -class OnInstallApk extends ApkInspectorEvent { - final String deviceId; - - OnInstallApk({required this.deviceId}); -} - -/// Event triggered to load recent APKs -class OnLoadRecentApks extends ApkInspectorEvent {} - -/// Event triggered when user selects a recent APK from the list -class OnSelectRecentApk extends ApkInspectorEvent { - final String apkPath; - - OnSelectRecentApk({required this.apkPath}); -} - -/// Event triggered when the screen appears to start listening to device changes -class OnScreenAppearing extends ApkInspectorEvent {} diff --git a/lib/features/apk_inspector/presentation/apk_inspector_screen.dart b/lib/features/apk_inspector/presentation/apk_inspector_screen.dart deleted file mode 100644 index b15534e2..00000000 --- a/lib/features/apk_inspector/presentation/apk_inspector_screen.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:android_tools/features/apk_inspector/presentation/apk_inspector_bloc.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/apk_import_view.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/apk_parsing_view.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/apk_report_view.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -/// Main screen for the APK Inspector feature -/// -/// Routes between different views based on the current state: -/// - idle: Shows import view -/// - parsing: Shows parsing progress -/// - ready/installing/installed: Shows APK report -class ApkInspectorScreen extends StatelessWidget { - const ApkInspectorScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ApkInspectorBloc(), - child: Scaffold( - body: BlocBuilder( - builder: (context, state) { - return switch (state.status) { - ApkInspectorStatus.idle => const ApkImportView(), - ApkInspectorStatus.parsing => const ApkParsingView(), - ApkInspectorStatus.ready || - ApkInspectorStatus.installing || - ApkInspectorStatus.installed || - ApkInspectorStatus.error => - const ApkReportView(), - }; - }, - ), - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/apk_inspector_state.dart b/lib/features/apk_inspector/presentation/apk_inspector_state.dart deleted file mode 100644 index 4eb7df3c..00000000 --- a/lib/features/apk_inspector/presentation/apk_inspector_state.dart +++ /dev/null @@ -1,42 +0,0 @@ -part of 'apk_inspector_bloc.dart'; - -/// Status of the APK Inspector feature -enum ApkInspectorStatus { - /// Initial state, showing import view - idle, - - /// Parsing APK file - parsing, - - /// APK parsed successfully, showing report - ready, - - /// Installing APK to device - installing, - - /// APK installed successfully - installed, - - /// Error occurred - error, -} - -/// State for the APK Inspector feature -@MappableClass() -class ApkInspectorState with ApkInspectorStateMappable { - final ApkInspectorStatus status; - final ApkInfo? apkInfo; - final String? errorMessage; - final List recentApks; - final DeviceEntity? selectedDevice; - - const ApkInspectorState({ - this.status = ApkInspectorStatus.idle, - this.apkInfo, - this.errorMessage, - this.recentApks = const [], - this.selectedDevice, - }); - - factory ApkInspectorState.initial() => const ApkInspectorState(); -} diff --git a/lib/features/apk_inspector/presentation/widgets/apk_drop_zone.dart b/lib/features/apk_inspector/presentation/widgets/apk_drop_zone.dart deleted file mode 100644 index a7cfad62..00000000 --- a/lib/features/apk_inspector/presentation/widgets/apk_drop_zone.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:android_tools/shared/presentation/widgets/info_card.dart'; -import 'package:desktop_drop/desktop_drop.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; - -/// Drag-and-drop zone widget for importing APK files -/// -/// Supports both drag-and-drop and file picker (browse) functionality -class ApkDropZone extends StatefulWidget { - final Function(String apkPath) onApkSelected; - - const ApkDropZone({super.key, required this.onApkSelected}); - - @override - State createState() => _ApkDropZoneState(); -} - -class _ApkDropZoneState extends State { - bool _isDragging = false; - - Future _pickFile() async { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['apk', 'apks', 'aab'], - dialogTitle: 'Select APK file', - ); - - if (result != null && result.files.isNotEmpty) { - final filePath = result.files.first.path; - if (filePath != null) { - widget.onApkSelected(filePath); - } - } - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return DropTarget( - onDragEntered: (_) { - setState(() => _isDragging = true); - }, - onDragExited: (_) { - setState(() => _isDragging = false); - }, - onDragDone: (details) { - setState(() => _isDragging = false); - - final file = details.files.firstOrNull; - if (file != null && file.path.endsWith('.apk')) { - widget.onApkSelected(file.path); - } - }, - child: InfoCard( - padding: EdgeInsets.zero, - borderColor: _isDragging - ? colorScheme.primary - : colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderWidth: 2, - strokeAlign: BorderSide.strokeAlignInside, - borderRadius: 10, - backgroundColor: _isDragging - ? colorScheme.primary.withValues(alpha: 0.1) - : colorScheme.surfaceContainer, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: colorScheme.primary.withValues(alpha: 0.12), - border: Border.all( - color: colorScheme.primary.withValues(alpha: 0.3), - ), - ), - child: Icon( - Icons.inventory_2_outlined, - color: colorScheme.primary, - size: 24, - ), - ), - const SizedBox(height: 16), - Text( - 'Drop an APK here', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 4), - Text( - 'or browse a file from your computer', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(height: 18), - FilledButton.icon( - onPressed: _pickFile, - icon: const Icon(Icons.upload_outlined, size: 18), - label: const Text('Browse files'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - ), - ), - const SizedBox(height: 24), - Text( - 'accepts .apk Β· .apks Β· .aab', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.6, - ), - fontFamily: 'monospace', - fontSize: 10.5, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/apk_hero_card.dart b/lib/features/apk_inspector/presentation/widgets/apk_hero_card.dart deleted file mode 100644 index 4c265ee0..00000000 --- a/lib/features/apk_inspector/presentation/widgets/apk_hero_card.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; -import 'package:android_tools/features/apk_inspector/presentation/apk_inspector_bloc.dart'; -import 'package:android_tools/shared/presentation/widgets/info_badge.dart'; -import 'package:android_tools/shared/presentation/widgets/info_card.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -/// Hero card displaying APK summary information -/// -/// Shows app icon, name, version, badges, and install button -class ApkHeroCard extends StatelessWidget { - final ApkInfo apkInfo; - - const ApkHeroCard({ - super.key, - required this.apkInfo, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return InfoCard( - padding: const EdgeInsets.all(18), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // App icon (letter avatar for now) - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: colorScheme.primary, - ), - child: Center( - child: Text( - apkInfo.appLabel.isNotEmpty ? apkInfo.appLabel[0].toUpperCase() : 'A', - style: const TextStyle( - color: Colors.white, - fontSize: 28, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - - const SizedBox(width: 16), - - // App info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - apkInfo.appLabel, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - ), - const SizedBox(height: 2), - Text( - '${apkInfo.packageName} Β· ${apkInfo.version}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - fontFamily: 'monospace', - ), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 8), - - // Badges - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - if (apkInfo.signature != null) ...[ - InfoBadge( - label: 'signed ${apkInfo.signature!.scheme}', - icon: Icons.check, - color: const Color(0xFF3FCF8E), - ), - if (apkInfo.signature!.isDebugKeystore) - InfoBadge( - label: 'debug keystore', - icon: Icons.warning_amber_rounded, - color: const Color(0xFFE8B339), - ), - ], - InfoBadge( - label: 'min SDK ${apkInfo.minSdk}', - color: const Color(0xFF5AA9FF), - ), - InfoBadge( - label: 'target ${apkInfo.targetSdk}', - color: const Color(0xFF5AA9FF), - ), - InfoBadge( - label: apkInfo.isDebuggable ? 'debuggable' : 'release', - color: apkInfo.isDebuggable - ? const Color(0xFFE8B339) - : const Color(0xFF3FCF8E), - ), - InfoBadge(label: '${apkInfo.sizeInMB} MB'), - InfoBadge(label: '${apkInfo.abis.length} ABIs'), - ], - ), - ], - ), - ), - - const SizedBox(width: 16), - - // Install area - BlocBuilder( - builder: (context, state) { - return SizedBox( - width: 220, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (state.status == ApkInspectorStatus.ready) ...[ - Text( - state.selectedDevice != null - ? 'Install on ${state.selectedDevice!.name}' - : 'No device selected', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - onPressed: state.selectedDevice != null - ? () { - context.read().add( - OnInstallApk( - deviceId: state.selectedDevice!.deviceId, - ), - ); - } - : null, - child: const Text('Install APK'), - ), - ], - ), - ], - if (state.status == ApkInspectorStatus.installing) ...[ - Text( - 'Installing…', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(height: 8), - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2.5, - ), - ), - ], - if (state.status == ApkInspectorStatus.installed) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFF3FCF8E).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.check_circle, - color: Color(0xFF3FCF8E), - size: 16, - ), - const SizedBox(width: 6), - Text( - 'Installed successfully', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: const Color(0xFF3FCF8E), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ], - ), - ], - if (state.status == ApkInspectorStatus.error && state.errorMessage != null) ...[ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.error_outline, - color: colorScheme.error, - size: 16, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - state.errorMessage!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.error, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ], - ], - ), - ); - }, - ), - ], - ), - ); - } -} - diff --git a/lib/features/apk_inspector/presentation/widgets/apk_import_view.dart b/lib/features/apk_inspector/presentation/widgets/apk_import_view.dart deleted file mode 100644 index 1f17f24f..00000000 --- a/lib/features/apk_inspector/presentation/widgets/apk_import_view.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:android_tools/features/apk_inspector/presentation/apk_inspector_bloc.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/apk_drop_zone.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/recent_apk_item.dart'; -import 'package:android_tools/shared/presentation/widgets/info_card.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -/// Import view for the APK Inspector -/// -/// Displays a drop zone for importing APK files and a list of recent APKs -class ApkImportView extends StatelessWidget { - const ApkImportView({super.key}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Column( - children: [ - // Main content area - Expanded( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Drop zone area - Expanded( - child: ApkDropZone( - onApkSelected: (apkPath) { - context.read().add( - OnSelectApkFile(apkPath: apkPath), - ); - }, - ), - ), - - const SizedBox(width: 16), - - // Recent APKs area - SizedBox( - width: 260, - child: InfoCard( - padding: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: colorScheme.surfaceContainerHighest - .withValues(alpha: 0.15), - ), - ), - ), - child: Text( - 'Recent', - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(10), - child: BlocBuilder( - builder: (context, state) { - if (state.recentApks.isEmpty) { - return Center( - child: Text( - 'No recent APKs', - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.6), - ), - ), - ); - } - - return ListView.separated( - itemCount: state.recentApks.length, - separatorBuilder: (context, index) => - Divider( - height: 1, - color: colorScheme.surfaceContainerHighest - .withValues(alpha: 0.1), - ), - itemBuilder: (context, index) { - final apk = state.recentApks[index]; - return RecentApkItem( - apkInfo: apk, - onTap: () { - context.read().add( - OnSelectRecentApk( - apkPath: apk.filePath, - ), - ); - }, - ); - }, - ); - }, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/apk_parsing_view.dart b/lib/features/apk_inspector/presentation/widgets/apk_parsing_view.dart deleted file mode 100644 index 56e02d75..00000000 --- a/lib/features/apk_inspector/presentation/widgets/apk_parsing_view.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Parsing progress view for APK Inspector -/// -/// Shows animated progress bar and step-by-step checklist -class ApkParsingView extends StatelessWidget { - const ApkParsingView({super.key}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Column( - children: [ - // Top bar - Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainer, - border: Border( - bottom: BorderSide( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - children: [ - Text( - 'Parsing APK…', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - - // Centered progress content - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - 'Reading APK archive…', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ], - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/apk_report_view.dart b/lib/features/apk_inspector/presentation/widgets/apk_report_view.dart deleted file mode 100644 index 0f470731..00000000 --- a/lib/features/apk_inspector/presentation/widgets/apk_report_view.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:android_tools/features/apk_inspector/presentation/apk_inspector_bloc.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/apk_hero_card.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/components_panel.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/manifest_panel.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/permissions_panel.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/signature_panel.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -/// APK report view displaying comprehensive APK information -/// -/// Shows breadcrumb navigation, hero card, and panels for signature, -/// permissions, manifest, and components -class ApkReportView extends StatelessWidget { - const ApkReportView({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final apkInfo = state.apkInfo; - - if (apkInfo == null) { - return const Center( - child: Text('No APK data available'), - ); - } - - final colorScheme = Theme.of(context).colorScheme; - - return Column( - children: [ - // Top bar with breadcrumb navigation - Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainer, - border: Border( - bottom: BorderSide( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - children: [ - // Breadcrumb back button - InkWell( - onTap: () { - context.read().add(OnResetView()); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.chevron_left, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - Text( - 'APK Inspector', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - - const SizedBox(width: 6), - - Text( - '/', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), - ), - ), - - const SizedBox(width: 6), - - Expanded( - child: Text( - apkInfo.fileName, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - - // Body with scrollable content - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Hero card - ApkHeroCard(apkInfo: apkInfo), - - const SizedBox(height: 16), - - // Panels grid - LayoutBuilder( - builder: (context, constraints) { - // Calculate number of columns based on width - final availableWidth = constraints.maxWidth; - final minCardWidth = 360.0; - final columns = (availableWidth / (minCardWidth + 12)).floor().clamp(1, 4); - - return Wrap( - spacing: 12, - runSpacing: 12, - children: [ - // Signature panel - SizedBox( - width: (availableWidth - (12 * (columns - 1))) / columns, - child: SignaturePanel(signature: apkInfo.signature), - ), - - // Manifest panel - SizedBox( - width: (availableWidth - (12 * (columns - 1))) / columns, - child: ManifestPanel(apkInfo: apkInfo), - ), - - // Components panel - SizedBox( - width: (availableWidth - (12 * (columns - 1))) / columns, - child: ComponentsPanel(apkInfo: apkInfo), - ), - - // Permissions panel - SizedBox( - width: (availableWidth - (12 * (columns - 1))) / columns, - child: PermissionsPanel(permissions: apkInfo.permissions), - ), - ], - ); - }, - ), - ], - ), - ), - ), - - // Footer - Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainer, - border: Border( - top: BorderSide( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5), - child: Row( - children: [ - Text( - 'APK parsed Β· ${apkInfo.permissions.length} permissions Β· ' - '${apkInfo.activitiesCount + apkInfo.servicesCount + apkInfo.receiversCount + apkInfo.providersCount} components', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const Spacer(), - Text( - 'aapt dump badging Β· apksigner verify', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - fontFamily: 'monospace', - ), - ), - ], - ), - ), - ], - ); - }, - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/component_bar.dart b/lib/features/apk_inspector/presentation/widgets/component_bar.dart deleted file mode 100644 index 79fa7f72..00000000 --- a/lib/features/apk_inspector/presentation/widgets/component_bar.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Component bar widget showing count with visual bar -class ComponentBar extends StatelessWidget { - final String label; - final int count; - final int maxCount; - - const ComponentBar({ - super.key, - required this.label, - required this.count, - required this.maxCount, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final percentage = (count / maxCount).clamp(0.0, 1.0); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - SizedBox( - width: 80, - child: Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Container( - height: 4, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: percentage, - child: Container( - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ), - ), - const SizedBox(width: 10), - SizedBox( - width: 30, - child: Text( - count.toString(), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - textAlign: TextAlign.right, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/component_key_value_row.dart b/lib/features/apk_inspector/presentation/widgets/component_key_value_row.dart deleted file mode 100644 index 1674b91c..00000000 --- a/lib/features/apk_inspector/presentation/widgets/component_key_value_row.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Key-value row widget for additional component info -class ComponentKeyValueRow extends StatelessWidget { - final String label; - final String value; - - const ComponentKeyValueRow({ - super.key, - required this.label, - required this.value, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - padding: const EdgeInsets.symmetric(vertical: 6), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - ), - ), - child: Row( - children: [ - Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - ), - ), - const Spacer(), - Text( - value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/components_panel.dart b/lib/features/apk_inspector/presentation/widgets/components_panel.dart deleted file mode 100644 index 1062101f..00000000 --- a/lib/features/apk_inspector/presentation/widgets/components_panel.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/component_bar.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/component_key_value_row.dart'; -import 'package:android_tools/shared/presentation/widgets/info_panel.dart'; -import 'package:flutter/material.dart'; - -/// Panel displaying APK components information -class ComponentsPanel extends StatelessWidget { - final ApkInfo apkInfo; - - const ComponentsPanel({ - super.key, - required this.apkInfo, - }); - - @override - Widget build(BuildContext context) { - return InfoPanel( - title: 'Components', - child: Column( - children: [ - ComponentBar( - label: 'Activities', - count: apkInfo.activitiesCount, - maxCount: 50, - ), - ComponentBar( - label: 'Services', - count: apkInfo.servicesCount, - maxCount: 50, - ), - ComponentBar( - label: 'Receivers', - count: apkInfo.receiversCount, - maxCount: 50, - ), - ComponentBar( - label: 'Providers', - count: apkInfo.providersCount, - maxCount: 50, - ), - - // Divider - const SizedBox(height: 10), - Container( - height: 1, - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.15), - ), - const SizedBox(height: 10), - - // Additional info - ComponentKeyValueRow(label: 'DEX files', value: apkInfo.dexFilesCount.toString()), - ComponentKeyValueRow(label: 'Resources', value: apkInfo.resourcesCount.toString()), - ComponentKeyValueRow(label: 'Assets', value: apkInfo.assetsCount.toString()), - ], - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/manifest_key_value_row.dart b/lib/features/apk_inspector/presentation/widgets/manifest_key_value_row.dart deleted file mode 100644 index d0d00289..00000000 --- a/lib/features/apk_inspector/presentation/widgets/manifest_key_value_row.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Key-value row widget for manifest and signature details -class ManifestKeyValueRow extends StatelessWidget { - final String label; - final String value; - - const ManifestKeyValueRow({ - super.key, - required this.label, - required this.value, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - padding: const EdgeInsets.symmetric(vertical: 6), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - ), - ), - child: Row( - children: [ - Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - textAlign: TextAlign.right, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/manifest_panel.dart b/lib/features/apk_inspector/presentation/widgets/manifest_panel.dart deleted file mode 100644 index 73c9db9a..00000000 --- a/lib/features/apk_inspector/presentation/widgets/manifest_panel.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/manifest_key_value_row.dart'; -import 'package:android_tools/shared/presentation/widgets/info_panel.dart'; -import 'package:flutter/material.dart'; - -/// Panel displaying APK manifest information -class ManifestPanel extends StatelessWidget { - final ApkInfo apkInfo; - - const ManifestPanel({ - super.key, - required this.apkInfo, - }); - - @override - Widget build(BuildContext context) { - return InfoPanel( - title: 'Manifest', - child: Column( - children: [ - ManifestKeyValueRow(label: 'Package', value: apkInfo.packageName), - ManifestKeyValueRow(label: 'Version name', value: apkInfo.version), - ManifestKeyValueRow(label: 'Version code', value: apkInfo.versionCode.toString()), - ManifestKeyValueRow(label: 'Min SDK', value: '${apkInfo.minSdk} (Android ${_getAndroidVersion(apkInfo.minSdk)})'), - ManifestKeyValueRow(label: 'Target SDK', value: '${apkInfo.targetSdk} (Android ${_getAndroidVersion(apkInfo.targetSdk)})'), - ManifestKeyValueRow(label: 'Compile SDK', value: apkInfo.compileSdk.toString()), - ManifestKeyValueRow(label: 'ABIs', value: apkInfo.abis.join(', ')), - ManifestKeyValueRow(label: 'Locales', value: '${apkInfo.localesCount} languages'), - ], - ), - ); - } - - String _getAndroidVersion(int sdkVersion) { - return switch (sdkVersion) { - >= 35 => '15', - 34 => '14', - 33 => '13', - 32 || 31 => '12', - 30 => '11', - 29 => '10', - 28 => '9', - 27 || 26 => '8', - 25 || 24 => '7', - 23 => '6', - 22 || 21 => '5', - _ => sdkVersion.toString(), - }; - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/parsing_step.dart b/lib/features/apk_inspector/presentation/widgets/parsing_step.dart deleted file mode 100644 index 0119f579..00000000 --- a/lib/features/apk_inspector/presentation/widgets/parsing_step.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Individual parsing step indicator -class ParsingStep extends StatelessWidget { - final String label; - final bool isComplete; - - const ParsingStep({ - super.key, - required this.label, - required this.isComplete, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Text( - isComplete ? 'βœ“' : '…', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: colorScheme.onSurfaceVariant, - height: 1.7, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/permission_legend.dart b/lib/features/apk_inspector/presentation/widgets/permission_legend.dart deleted file mode 100644 index 4d822258..00000000 --- a/lib/features/apk_inspector/presentation/widgets/permission_legend.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Permission legend item -class PermissionLegend extends StatelessWidget { - final Color color; - final int count; - final String label; - - const PermissionLegend({ - super.key, - required this.color, - required this.count, - required this.label, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - ), - ), - const SizedBox(width: 4), - Text( - count.toString(), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 4), - Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - ), - ), - ], - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/permission_row.dart b/lib/features/apk_inspector/presentation/widgets/permission_row.dart deleted file mode 100644 index 6f9ed403..00000000 --- a/lib/features/apk_inspector/presentation/widgets/permission_row.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_permission.dart'; -import 'package:flutter/material.dart'; - -/// Individual permission row -class PermissionRow extends StatelessWidget { - final ApkPermission permission; - final Color color; - - const PermissionRow({ - super.key, - required this.permission, - required this.color, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - padding: const EdgeInsets.symmetric(vertical: 5), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - ), - ), - child: Row( - children: [ - Container( - width: 5, - height: 5, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - permission.name, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: colorScheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Text( - permission.level.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - letterSpacing: 0.5, - fontSize: 10, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/permissions_panel.dart b/lib/features/apk_inspector/presentation/widgets/permissions_panel.dart deleted file mode 100644 index 1c5d6e83..00000000 --- a/lib/features/apk_inspector/presentation/widgets/permissions_panel.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_permission.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/permission_legend.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/permission_row.dart'; -import 'package:android_tools/shared/presentation/widgets/info_panel.dart'; -import 'package:flutter/material.dart'; - -/// Panel displaying APK permissions -class PermissionsPanel extends StatelessWidget { - final List permissions; - - const PermissionsPanel({ - super.key, - required this.permissions, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - final dangerousCount = permissions.where((p) => p.level == 'dangerous').length; - final normalCount = permissions.where((p) => p.level == 'normal').length; - final signatureCount = permissions.where((p) => p.level == 'signature').length; - - return InfoPanel( - title: 'Permissions', - trailing: Text( - '${permissions.length} total', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - fontFamily: 'monospace', - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Legend - Row( - children: [ - PermissionLegend( - color: const Color(0xFFEF6F6C), - count: dangerousCount, - label: 'Dangerous', - ), - const SizedBox(width: 12), - PermissionLegend( - color: colorScheme.onSurfaceVariant, - count: normalCount, - label: 'Normal', - ), - const SizedBox(width: 12), - PermissionLegend( - color: const Color(0xFF5AA9FF), - count: signatureCount, - label: 'Signature', - ), - ], - ), - - const SizedBox(height: 10), - - // Permission list - ...permissions.map((permission) { - final color = _getPermissionColor(permission.level); - return PermissionRow( - permission: permission, - color: color, - ); - }), - ], - ), - ); - } - - Color _getPermissionColor(String level) { - return switch (level) { - 'dangerous' => const Color(0xFFEF6F6C), - 'signature' => const Color(0xFF5AA9FF), - _ => const Color(0xFF6B707A), - }; - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/recent_apk_item.dart b/lib/features/apk_inspector/presentation/widgets/recent_apk_item.dart deleted file mode 100644 index faf5a756..00000000 --- a/lib/features/apk_inspector/presentation/widgets/recent_apk_item.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_info.dart'; -import 'package:flutter/material.dart'; - -class RecentApkItem extends StatelessWidget { - final ApkInfo apkInfo; - final VoidCallback onTap; - - const RecentApkItem({ - super.key, - required this.apkInfo, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - apkInfo.appLabel, - style: textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - apkInfo.packageName, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.8), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - apkInfo.version, - style: textTheme.labelSmall?.copyWith( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), - fontSize: 10, - ), - ), - ), - const SizedBox(width: 6), - Text( - '${apkInfo.sizeInMB} MB', - style: textTheme.labelSmall?.copyWith( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), - fontSize: 10, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/apk_inspector/presentation/widgets/signature_panel.dart b/lib/features/apk_inspector/presentation/widgets/signature_panel.dart deleted file mode 100644 index b5992d73..00000000 --- a/lib/features/apk_inspector/presentation/widgets/signature_panel.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:android_tools/features/apk_inspector/domain/entities/apk_signature.dart'; -import 'package:android_tools/features/apk_inspector/presentation/widgets/manifest_key_value_row.dart'; -import 'package:android_tools/shared/presentation/widgets/info_panel.dart'; -import 'package:flutter/material.dart'; - -/// Panel displaying APK signature information -class SignaturePanel extends StatelessWidget { - final ApkSignature? signature; - - const SignaturePanel({ - super.key, - required this.signature, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - if (signature == null) { - return InfoPanel( - title: 'Signature', - trailing: Row( - children: [ - Icon( - Icons.block, - size: 14, - color: colorScheme.error, - ), - const SizedBox(width: 3), - Text( - 'unsigned', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.error, - fontFamily: 'monospace', - ), - ), - ], - ), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.error.withValues(alpha: 0.08), - border: Border.all( - color: colorScheme.error.withValues(alpha: 0.3), - ), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 1), - child: Icon( - Icons.warning_amber_rounded, - size: 16, - color: colorScheme.error, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'This APK is not signed. Unsigned APKs cannot be installed ' - 'on devices and should not be distributed.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.error, - height: 1.4, - ), - ), - ), - ], - ), - ), - ); - } - - final sig = signature!; - - return InfoPanel( - title: 'Signature', - trailing: Row( - children: [ - if (sig.isDebugKeystore) ...[ - Icon( - Icons.warning_amber_rounded, - size: 14, - color: const Color(0xFFE8B339), - ), - const SizedBox(width: 3), - Text( - 'debug', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: const Color(0xFFE8B339), - fontFamily: 'monospace', - ), - ), - ] else ...[ - Container( - width: 5, - height: 5, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xFF3FCF8E), - ), - ), - const SizedBox(width: 4), - Text( - 'verified', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: const Color(0xFF3FCF8E), - fontFamily: 'monospace', - ), - ), - ], - ], - ), - child: Column( - children: [ - if (sig.isDebugKeystore) - Container( - padding: const EdgeInsets.all(10), - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - color: const Color(0xFFE8B339).withValues(alpha: 0.08), - border: Border.all( - color: const Color(0xFFE8B339).withValues(alpha: 0.3), - ), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(top: 1), - child: Icon( - Icons.warning_amber_rounded, - size: 16, - color: Color(0xFFE8B339), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'This APK is signed with the Android debug keystore. ' - 'Debug certificates are not meant for production distribution.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: const Color(0xFFE8B339), - height: 1.4, - ), - ), - ), - ], - ), - ), - ManifestKeyValueRow(label: 'Scheme', value: sig.scheme), - ManifestKeyValueRow(label: 'Algorithm', value: sig.algorithm), - ManifestKeyValueRow(label: 'Key size', value: '${sig.keySize}-bit'), - ManifestKeyValueRow(label: 'Issuer', value: sig.issuer), - ManifestKeyValueRow(label: 'Valid from', value: sig.validFrom), - ManifestKeyValueRow(label: 'Valid to', value: sig.validTo), - - // SHA-256 fingerprint box - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - border: Border.all( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - borderRadius: BorderRadius.circular(4), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'SHA-256 FINGERPRINT', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.surfaceContainerHighest, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 4), - SelectableText( - sig.sha256, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - height: 1.55, - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/file_explorer/core/file_explorer_module.dart b/lib/features/file_explorer/core/file_explorer_module.dart deleted file mode 100644 index 09c06104..00000000 --- a/lib/features/file_explorer/core/file_explorer_module.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:android_tools/features/file_explorer/data/file_repository_impl.dart'; -import 'package:android_tools/features/file_explorer/domain/repositories/file_repository.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/create_directory_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/delete_file_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/download_file_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/download_file_to_cache_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/list_files_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/upload_files_usecase.dart'; -import 'package:android_tools/main.dart'; - -class FileExplorerModule { - static void configureDependencies() { - _registerRepositories(); - _registerUseCases(); - } - - static void _registerRepositories() { - getIt.registerLazySingleton( - () => GeneralFileRepositoryImpl(getIt.get(), getIt.get()), - ); - } - - static void _registerUseCases() { - getIt.registerLazySingleton(() => ListFilesUsecase(getIt.get())); - getIt.registerLazySingleton(() => UploadFilesUsecase(getIt.get())); - getIt.registerLazySingleton(() => DeleteFileUsecase(getIt.get())); - getIt.registerLazySingleton(() => DownloadFileUsecase(getIt.get())); - getIt.registerLazySingleton(() => CreateDirectoryUsecase(getIt.get())); - getIt.registerLazySingleton(() => DownloadFileToCacheUsecase(getIt.get())); - } -} diff --git a/lib/features/file_explorer/core/file_extension_helper.dart b/lib/features/file_explorer/core/file_extension_helper.dart deleted file mode 100644 index 9ac91887..00000000 --- a/lib/features/file_explorer/core/file_extension_helper.dart +++ /dev/null @@ -1,79 +0,0 @@ -class FileExtensionHelper { - static bool isTextExtension(String extension) { - const textExtensions = [ - '.txt', - '.json', - '.xml', - '.yaml', - '.yml', - '.md', - '.log', - '.csv', - '.html', - '.css', - '.js', - '.ts', - '.dart', - '.java', - '.kt', - '.py', - '.rb', - '.sh', - '.gradle', - '.properties', - '.conf', - '.config', - '.ini', - ]; - - return textExtensions.contains(extension); - } - - static bool isImageExtension(String extension) { - const imageExtensions = [ - '.jpg', - '.jpeg', - '.png', - '.gif', - '.bmp', - '.webp', - '.svg', - '.ico', - ]; - - return imageExtensions.contains(extension); - } - - static bool isVideoExtension(String extension) { - const videoExtensions = [ - '.mp4', - '.avi', - '.mkv', - '.mov', - '.wmv', - '.flv', - '.webm', - '.m4v', - ]; - - return videoExtensions.contains(extension); - } - - static bool isAudioExtension(String extension) { - const audioExtensions = [ - '.mp3', - '.wav', - '.ogg', - '.m4a', - '.flac', - '.aac', - '.wma', - ]; - - return audioExtensions.contains(extension); - } - - static bool isPdfExtension(String extension) { - return extension == '.pdf'; - } -} diff --git a/lib/features/file_explorer/core/int_extensions.dart b/lib/features/file_explorer/core/int_extensions.dart deleted file mode 100644 index d830fcfd..00000000 --- a/lib/features/file_explorer/core/int_extensions.dart +++ /dev/null @@ -1,16 +0,0 @@ -extension ByteFormatter on int { - String toReadableBytes({int decimals = 2}) { - if (this < 0) return '0 B'; - - const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - double size = toDouble(); - int i = 0; - - while (size >= 1024 && i < suffixes.length - 1) { - size /= 1024; - i++; - } - - return '${size.toStringAsFixed(decimals)} ${suffixes[i]}'; - } -} diff --git a/lib/features/file_explorer/core/string_extensions.dart b/lib/features/file_explorer/core/string_extensions.dart deleted file mode 100644 index 9ba2d218..00000000 --- a/lib/features/file_explorer/core/string_extensions.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:path/path.dart' as p; - -extension StringExtensions on String { - bool isRootPath() { - final parentPath = p.dirname(this); - return parentPath == this; - } -} diff --git a/lib/features/file_explorer/data/file_repository_impl.dart b/lib/features/file_explorer/data/file_repository_impl.dart deleted file mode 100644 index 14eb7746..00000000 --- a/lib/features/file_explorer/data/file_repository_impl.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/file_explorer/domain/repositories/file_repository.dart'; -import 'package:android_tools/shared/domain/repositories/package_repository.dart'; - -class GeneralFileRepositoryImpl implements FileRepository { - final PackageRepository _packageRepository; - final AdbClient _adbClient; - - GeneralFileRepositoryImpl(this._packageRepository, this._adbClient); - - ({String package, String? subPath})? _parsePrivateAppPath(String path) { - if (!path.startsWith('data/data/')) return null; - - final parts = path.split('/'); - if (parts.length < 3) return null; - - final package = parts[2]; - final subPath = parts.length > 3 ? parts.sublist(3).join('/') : null; - - return (package: package, subPath: subPath); - } - - @override - Future> listFiles(String path, String deviceId) async { - if (path == 'data') { - return [ - FileEntry( - type: FileType.directory, - name: 'data', - permissions: 'drwx--x--x', - date: DateTime(1970), - size: 4096, - ), - ]; - } - - if (path == 'data/data') { - final packages = await _packageRepository.getAllPackages(deviceId); - return packages - .map( - (pkg) => FileEntry( - type: FileType.directory, - name: pkg, - permissions: 'drwx------', - date: DateTime(1970), - size: 4096, - ), - ) - .toList(); - } - - if (path.startsWith('data/data/')) { - final result = _parsePrivateAppPath(path); - if (result == null) { - return []; - } - - final filesResult = await _adbClient.listFiles( - result.subPath ?? "", - deviceId, - packageName: result.package, - ); - - return filesResult; - } - - final result = await _adbClient.listFiles(path, deviceId); - return result; - } - - @override - Future deleteFile(String filePath, String deviceId) async { - if (filePath.isEmpty) return; - if (filePath.startsWith('data/data/')) { - final result = _parsePrivateAppPath(filePath); - if (result == null) return; - - await _adbClient.deleteFile( - result.subPath ?? "", - deviceId, - packageName: result.package, - ); - return; - } - - await _adbClient.deleteFile(filePath, deviceId); - } - - @override - Future createDirectory( - String path, - String name, - String deviceId, - ) async { - if (path.isEmpty || name.isEmpty) return; - - if (path.startsWith('data/data/')) { - final result = _parsePrivateAppPath(path); - if (result == null) return; - - await _adbClient.createDirectory( - result.subPath ?? "", - name, - deviceId, - packageName: result.package, - ); - return; - } - - await _adbClient.createDirectory(path, name, deviceId); - } - - @override - Future downloadFile( - String filePath, - String destinationPath, - String deviceId, - ) async { - if (filePath.isEmpty || destinationPath.isEmpty) return; - - if (filePath.startsWith('data/data/')) { - final result = _parsePrivateAppPath(filePath); - if (result == null) return; - - await _adbClient.downloadFile( - result.subPath ?? "", - destinationPath, - deviceId, - packageName: result.package, - ); - return; - } - - await _adbClient.downloadFile(filePath, destinationPath, deviceId); - } - - @override - Future uploadFiles( - String filePath, - String destination, - String deviceId, - ) async { - if (filePath.isEmpty || destination.isEmpty) return; - - if (destination.startsWith('data/data/')) { - final result = _parsePrivateAppPath(destination); - if (result == null) return; - - await _adbClient.uploadFile( - filePath, - result.subPath ?? "", - deviceId, - packageName: result.package, - ); - return; - } - - await _adbClient.uploadFile(filePath, destination, deviceId); - } -} diff --git a/lib/features/file_explorer/domain/repositories/file_repository.dart b/lib/features/file_explorer/domain/repositories/file_repository.dart deleted file mode 100644 index cbddd11c..00000000 --- a/lib/features/file_explorer/domain/repositories/file_repository.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; - -abstract class FileRepository { - Future> listFiles(String path, String deviceId); - Future deleteFile(String filePath, String deviceId); - Future createDirectory(String path, String name, String deviceId); - Future downloadFile( - String filePath, - String destinationPath, - String deviceId, - ); - Future uploadFiles( - String filePath, - String destination, - String deviceId, - ); -} diff --git a/lib/features/file_explorer/domain/usecases/create_directory_usecase.dart b/lib/features/file_explorer/domain/usecases/create_directory_usecase.dart deleted file mode 100644 index ad80f2bd..00000000 --- a/lib/features/file_explorer/domain/usecases/create_directory_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:android_tools/features/file_explorer/domain/repositories/file_repository.dart'; - -class CreateDirectoryUsecase { - final FileRepository _fileRepository; - - CreateDirectoryUsecase(this._fileRepository); - - Future call(String path, String name, String deviceId) { - return _fileRepository.createDirectory(path, name, deviceId); - } -} diff --git a/lib/features/file_explorer/domain/usecases/delete_file_usecase.dart b/lib/features/file_explorer/domain/usecases/delete_file_usecase.dart deleted file mode 100644 index c89dd811..00000000 --- a/lib/features/file_explorer/domain/usecases/delete_file_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:android_tools/features/file_explorer/domain/repositories/file_repository.dart'; - -class DeleteFileUsecase { - final FileRepository _fileRepository; - - DeleteFileUsecase(this._fileRepository); - - Future call(String filePath, String deviceId) { - return _fileRepository.deleteFile(filePath, deviceId); - } -} diff --git a/lib/features/file_explorer/domain/usecases/download_file_to_cache_usecase.dart b/lib/features/file_explorer/domain/usecases/download_file_to_cache_usecase.dart deleted file mode 100644 index 6a448111..00000000 --- a/lib/features/file_explorer/domain/usecases/download_file_to_cache_usecase.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:io'; -import 'package:android_tools/features/file_explorer/domain/repositories/file_repository.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -class DownloadFileToCacheUsecase { - final FileRepository _fileRepository; - - DownloadFileToCacheUsecase(this._fileRepository); - - Future call( - String filePath, - String fileName, - String deviceId, - ) async { - final cacheDir = await getTemporaryDirectory(); - final localFilePath = p.join(cacheDir.path, 'file_preview', fileName); - - // Create directory if it doesn't exist - final directory = Directory(p.dirname(localFilePath)); - if (!await directory.exists()) { - await directory.create(recursive: true); - } - - await _fileRepository.downloadFile(filePath, localFilePath, deviceId); - - return localFilePath; - } -} diff --git a/lib/features/file_explorer/domain/usecases/download_file_usecase.dart b/lib/features/file_explorer/domain/usecases/download_file_usecase.dart deleted file mode 100644 index b8b3ea83..00000000 --- a/lib/features/file_explorer/domain/usecases/download_file_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:android_tools/features/file_explorer/domain/repositories/file_repository.dart'; - -class DownloadFileUsecase { - final FileRepository _fileRepository; - - DownloadFileUsecase(this._fileRepository); - - Future call(String filePath, String destinationPath, String deviceId) { - return _fileRepository.downloadFile(filePath, destinationPath, deviceId); - } -} diff --git a/lib/features/file_explorer/domain/usecases/list_files_usecase.dart b/lib/features/file_explorer/domain/usecases/list_files_usecase.dart deleted file mode 100644 index 853ffa33..00000000 --- a/lib/features/file_explorer/domain/usecases/list_files_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/file_explorer/domain/repositories/file_repository.dart'; - -class ListFilesUsecase { - final FileRepository _fileRepository; - - ListFilesUsecase(this._fileRepository); - - Future> call(String path, String deviceId) { - return _fileRepository.listFiles(path, deviceId); - } -} diff --git a/lib/features/file_explorer/domain/usecases/upload_files_usecase.dart b/lib/features/file_explorer/domain/usecases/upload_files_usecase.dart deleted file mode 100644 index 931c0e94..00000000 --- a/lib/features/file_explorer/domain/usecases/upload_files_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:android_tools/features/file_explorer/domain/repositories/file_repository.dart'; - -class UploadFilesUsecase { - final FileRepository _fileRepository; - - UploadFilesUsecase(this._fileRepository); - - Future call(String filePath, String destination, String deviceId) { - return _fileRepository.uploadFiles(filePath, destination, deviceId); - } -} diff --git a/lib/features/file_explorer/presentation/file_explorer_bloc.dart b/lib/features/file_explorer/presentation/file_explorer_bloc.dart deleted file mode 100644 index 264645fb..00000000 --- a/lib/features/file_explorer/presentation/file_explorer_bloc.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/file_explorer/core/string_extensions.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/create_directory_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/delete_file_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/download_file_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/list_files_usecase.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/upload_files_usecase.dart'; -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/usecases/listen_selected_device_usecase.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:file_picker/file_picker.dart' hide FileType; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logger/logger.dart'; -import 'package:path/path.dart' as p; - -part 'file_explorer_event.dart'; -part 'file_explorer_state.dart'; -part 'file_explorer_bloc.mapper.dart'; - -class FileExplorerBloc extends Bloc { - final Logger _logger = getIt.get(); - final ListFilesUsecase _listFilesUsecase = getIt.get(); - final ListenSelectedDeviceUsecase _listenSelectedDeviceUsecase = getIt.get(); - final UploadFilesUsecase _uploadFilesUsecase = getIt.get(); - final DownloadFileUsecase _downloadFileUsecase = getIt.get(); - final DeleteFileUsecase _deleteFileUsecase = getIt.get(); - final CreateDirectoryUsecase _createDirectoryUsecase = getIt.get(); - - FileExplorerBloc() : super(FileExplorerState()) { - on((event, emit) async { - await emit.onEach( - _listenSelectedDeviceUsecase(), - onData: (device) async { - emit(state.copyWith(path: "", device: device, files: [])); - - if (device == null) { - _logger.i("Selected device is null, can't get files"); - return; - } - - final files = await _listFilesUsecase(state.path, device.deviceId); - _logger.i('Fetched ${files.length} file(s) for first route'); - emit(state.copyWith(files: files.toList())); - }, - ); - }); - on((event, emit) async { - final device = state.device; - final path = p.join(state.path, event.fileEntry.name); - - if (device == null) { - _logger.w("Device is null, can't handle OnFileEntryTapped for $path"); - return; - } - - if (event.fileEntry.type == FileType.directory) { - final files = await _listFilesUsecase(path, device.deviceId); - emit(state.copyWith(files: files, path: path, selectedFile: null)); - _logger.i('Fetched ${files.length} file(s) for path $path'); - return; - } - if (event.fileEntry.type == FileType.file) { - emit( - state.copyWith( - selectedFile: state.selectedFile == event.fileEntry - ? null - : event.fileEntry, - ), - ); - } - }); - - on((event, emit) async { - final device = state.device; - - if (device == null) { - _logger.w("Device is null, can't go back"); - return; - } - - final currentPath = state.path; - - var parentPath = p.dirname(currentPath); - if (currentPath.isRootPath()) { - _logger.i("Already at root, can't go back further"); - return; - } - - if (parentPath == ".") { - parentPath = ""; - } - - emit(state.copyWith(isLoading: true)); - final files = await _listFilesUsecase(parentPath, device.deviceId); - emit( - state.copyWith( - files: files, - path: parentPath, - selectedFile: null, - isLoading: false, - ), - ); - - _logger.i('Fetched ${files.length} file(s) for path $parentPath'); - }); - on((event, emit) async { - final device = state.device; - if (device == null) { - _logger.w("Device is null, can't upload files"); - return; - } - - emit(state.copyWith(isLoading: true)); - _logger.i("Start uploading ${event.file} to ${state.path}"); - await _uploadFilesUsecase(event.file, state.path, device.deviceId); - _logger.i("Files uploaded refreshing files"); - add(OnRefreshFiles()); - }); - on((event, emit) async { - final device = state.device; - if (device == null) { - _logger.w("Device is null, can't refresh files"); - return; - } - emit(state.copyWith(isLoading: true)); - final files = await _listFilesUsecase(state.path, device.deviceId); - emit(state.copyWith(files: files, selectedFile: null, isLoading: false)); - }); - on((event, emit) async { - final device = state.device; - if (device == null) { - _logger.w("Device is null, can't download file"); - return; - } - final filePath = p.join(state.path, event.fileName); - final destinationPath = await FilePicker.platform.saveFile( - dialogTitle: 'Save file', - fileName: event.fileName, - ); - - if (destinationPath == null) { - _logger.i("User cancelled download"); - return; - } - emit(state.copyWith(isLoading: true)); - - _logger.i( - "Downloading file $filePath for device ${device.deviceId}, downloading in $destinationPath", - ); - await _downloadFileUsecase(filePath, destinationPath, device.deviceId); - add(OnRefreshFiles()); - }); - on((event, emit) async { - final device = state.device; - if (device == null) { - _logger.w("Device is null, can't delete file"); - return; - } - emit(state.copyWith(isLoading: true)); - final filePath = p.join(state.path, event.fileName); - _logger.i("Deleting file at $filePath for device ${device.deviceId}"); - await _deleteFileUsecase(filePath, device.deviceId); - add(OnRefreshFiles()); - }); - on((event, emit) async { - final device = state.device; - if (device == null) { - _logger.w("Device is null, can't create directory"); - return; - } - _logger.i( - "Creating directory ${event.name} at ${state.path} for device ${device.deviceId}", - ); - emit(state.copyWith(isLoading: true)); - await _createDirectoryUsecase(state.path, event.name, device.deviceId); - add(OnRefreshFiles()); - }); - on((event, emit) async { - final device = state.device; - if (device == null) { - _logger.w("Device is null, can't go to directory"); - return; - } - - final files = await _listFilesUsecase(event.path, device.deviceId); - emit(state.copyWith(files: files, path: event.path, selectedFile: null)); - _logger.i('Fetched ${files.length} file(s) for path ${event.path}'); - return; - }); - } -} diff --git a/lib/features/file_explorer/presentation/file_explorer_event.dart b/lib/features/file_explorer/presentation/file_explorer_event.dart deleted file mode 100644 index 6ff04488..00000000 --- a/lib/features/file_explorer/presentation/file_explorer_event.dart +++ /dev/null @@ -1,45 +0,0 @@ -part of 'file_explorer_bloc.dart'; - -sealed class FileExplorerEvent {} - -class OnAppearing extends FileExplorerEvent {} - -class OnFileEntryTapped extends FileExplorerEvent { - final FileEntry fileEntry; - - OnFileEntryTapped({required this.fileEntry}); -} - -class OnGoBack extends FileExplorerEvent {} - -class OnUploadFile extends FileExplorerEvent { - final String file; - - OnUploadFile({required this.file}); -} - -class OnRefreshFiles extends FileExplorerEvent {} - -class OnDeleteFile extends FileExplorerEvent { - final String fileName; - - OnDeleteFile({required this.fileName}); -} - -class OnDownloadFile extends FileExplorerEvent { - final String fileName; - - OnDownloadFile({required this.fileName}); -} - -class OnCreateDirectory extends FileExplorerEvent { - final String name; - - OnCreateDirectory({required this.name}); -} - -class OnGoToDirectory extends FileExplorerEvent { - final String path; - - OnGoToDirectory({required this.path}); -} diff --git a/lib/features/file_explorer/presentation/file_explorer_screen.dart b/lib/features/file_explorer/presentation/file_explorer_screen.dart deleted file mode 100644 index ac931fcc..00000000 --- a/lib/features/file_explorer/presentation/file_explorer_screen.dart +++ /dev/null @@ -1,505 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_explorer_bloc.dart'; -import 'package:android_tools/features/file_explorer/core/string_extensions.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_preview/file_preview_bloc.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_preview/file_preview_screen.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_entry_menu_result.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_explorer_app_bar.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_explorer_file_entry_item.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_explorer_drop_target.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_explorer_menus.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_explorer_breadcrumb.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/resizable_divider.dart'; -import 'package:file_picker/file_picker.dart' as file_picker; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class FileExplorerScreen extends StatefulWidget { - const FileExplorerScreen({super.key}); - - @override - State createState() => _FileExplorerScreenState(); -} - -class _FileExplorerScreenState extends State { - final bloc = FileExplorerBloc(); - final previewBloc = FilePreviewBloc(); - bool isDropping = false; - - // Search - final FocusNode _rootFocusNode = FocusNode(); - final FocusNode _searchFocusNode = FocusNode(); - final TextEditingController _searchController = TextEditingController(); - final ScrollController _scrollController = ScrollController(); - bool _showSearch = false; - List _matchingIndexes = []; - int _currentMatchIndex = -1; - String? _currentPath; - - // Panel resizing - double _leftPanelWidth = 0.5; // Fraction of total width (0.0 to 1.0) - - @override - void initState() { - super.initState(); - bloc.add(OnAppearing()); - - // Restore focus to search field when it loses focus - _searchFocusNode.addListener(() { - if (_showSearch && !_searchFocusNode.hasFocus) { - // Re-request focus after a short delay to avoid conflicts - Future.microtask(() { - if (_showSearch && mounted) { - _searchFocusNode.requestFocus(); - } - }); - } - }); - } - - @override - void dispose() { - bloc.close(); - previewBloc.close(); - _rootFocusNode.dispose(); - _searchFocusNode.dispose(); - _searchController.dispose(); - _scrollController.dispose(); - super.dispose(); - } - - void _openSearch({String initialText = ""}) { - setState(() { - _showSearch = true; - _searchController.text = initialText; - }); - _searchFocusNode.requestFocus(); - _updateMatches(); - } - - void _closeSearch() { - setState(() { - _showSearch = false; - _searchController.clear(); - _matchingIndexes.clear(); - _currentMatchIndex = -1; - }); - _rootFocusNode.requestFocus(); - } - - void _updateMatches() { - final query = _searchController.text.toLowerCase().trim(); - final files = bloc.state.files; - - if (query.isEmpty) { - setState(() { - _matchingIndexes.clear(); - _currentMatchIndex = -1; - }); - return; - } - - final matches = []; - for (int i = 0; i < files.length; i++) { - if (files.elementAt(i).name.toLowerCase().contains(query)) { - matches.add(i); - } - } - - setState(() { - _matchingIndexes = matches; - _currentMatchIndex = matches.isEmpty ? -1 : 0; - }); - - if (_currentMatchIndex >= 0) { - _scrollToMatch(_currentMatchIndex); - } - } - - void _goToNextMatch() { - if (_matchingIndexes.isEmpty) return; - - setState(() { - _currentMatchIndex = (_currentMatchIndex + 1) % _matchingIndexes.length; - }); - _scrollToMatch(_currentMatchIndex); - } - - void _goToPreviousMatch() { - if (_matchingIndexes.isEmpty) return; - - setState(() { - _currentMatchIndex = - (_currentMatchIndex - 1 + _matchingIndexes.length) % - _matchingIndexes.length; - }); - _scrollToMatch(_currentMatchIndex); - } - - void _scrollToMatch(int matchIndex) { - if (matchIndex < 0 || matchIndex >= _matchingIndexes.length) return; - - final fileIndex = _matchingIndexes[matchIndex]; - const itemHeight = 64.0; - final targetOffset = fileIndex * itemHeight; - - _scrollController.animateTo( - targetOffset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - } - - Future onUploadFiles(BuildContext context) async { - final result = await file_picker.FilePicker.platform.pickFiles( - dialogTitle: 'Choose file', - ); - if (result == null || result.files.isEmpty) { - return; - } - if (context.mounted) { - final firstFile = result.files.map((f) => f.path ?? "").firstOrNull; - if (firstFile != null && firstFile.isNotEmpty) { - context.read().add(OnUploadFile(file: firstFile)); - } - } - } - - Future onShowCreateDirectoryDialog(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (context) { - final controller = TextEditingController(); - return AlertDialog( - title: Text("Create new directory"), - content: TextFormField(controller: controller), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(controller.text); - }, - child: Text("Create"), - ), - ], - ); - }, - ); - - if (result == null || result.isEmpty) { - return; - } - if (context.mounted) { - context.read().add(OnCreateDirectory(name: result)); - } - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, - child: Focus( - focusNode: _rootFocusNode, - autofocus: true, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - - final isCtrlOrCmd = - HardwareKeyboard.instance.isControlPressed || - HardwareKeyboard.instance.isMetaPressed; - - // Cmd/Ctrl + F: Open search - if (isCtrlOrCmd && event.logicalKey == LogicalKeyboardKey.keyF) { - _openSearch(); - return KeyEventResult.handled; - } - - // Escape: Close search - if (event.logicalKey == LogicalKeyboardKey.escape && _showSearch) { - _closeSearch(); - return KeyEventResult.handled; - } - - // Enter: Next match when search is open - if (event.logicalKey == LogicalKeyboardKey.enter && _showSearch) { - _goToNextMatch(); - return KeyEventResult.handled; - } - - // Any printable character: Open search with that character - if (!_showSearch && - !isCtrlOrCmd && - event.character != null && - event.character!.isNotEmpty && - event.character!.codeUnitAt(0) >= 32) { - _openSearch(initialText: event.character!); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - child: Scaffold( - backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest, - appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: BlocBuilder( - builder: (context, state) { - return FileExplorerAppBar( - path: state.path.split("/").last, - onGoBack: state.path.isRootPath() - ? null - : () { - context.read().add(OnGoBack()); - }, - showSearch: _showSearch, - searchController: _searchController, - searchFocusNode: _searchFocusNode, - matchCount: _matchingIndexes.length, - currentMatchIndex: _currentMatchIndex, - onSearchNext: _goToNextMatch, - onSearchPrevious: _goToPreviousMatch, - onSearchClose: _closeSearch, - onSearchChanged: _updateMatches, - ); - }, - ), - ), - body: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - BlocBuilder( - builder: (context, state) { - return state.isLoading - ? LinearProgressIndicator() - : SizedBox.fromSize(size: Size.fromHeight(4)); - }, - ), - Expanded( - child: BlocBuilder( - builder: (context, state) { - final fileExplorerContent = FileExplorerDropTarget( - onFileDropped: (details) { - final firstFile = details.files - .map((f) => f.path) - .firstOrNull; - if (firstFile != null) { - context.read().add( - OnUploadFile(file: firstFile), - ); - } - }, - child: BlocBuilder( - builder: (context, state) { - // Check if path has changed and update search results - if (_currentPath != state.path) { - _currentPath = state.path; - // Update matches on next frame to avoid calling setState during build - WidgetsBinding.instance.addPostFrameCallback(( - _, - ) { - if (_showSearch && mounted) { - _updateMatches(); - } - }); - } - - return Column( - children: [ - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.deferToChild, - onSecondaryTapDown: (details) { - FileExplorerMenus.showGeneralMenu( - context, - details, - ).then((value) async { - if (value == - FileEntryMenuResult.upload) { - if (context.mounted) { - onUploadFiles(context); - } - return; - } - if (value == - FileEntryMenuResult.refresh) { - if (context.mounted) { - context - .read() - .add(OnRefreshFiles()); - } - return; - } - if (value == - FileEntryMenuResult - .newDirectory) { - if (context.mounted) { - await onShowCreateDirectoryDialog( - context, - ); - } - return; - } - }); - }, - child: ListView.builder( - controller: _scrollController, - padding: EdgeInsets.all(16), - itemCount: state.files.length, - itemBuilder: (context, index) { - final file = state.files.elementAt( - index, - ); - final query = - _showSearch && - _searchController - .text - .isNotEmpty - ? _searchController.text - : null; - - return FileExplorerFileEntryItem( - file: file, - isSelected: - state.selectedFile == file, - searchQuery: query, - onDownloadFile: () => context - .read() - .add( - OnDownloadFile( - fileName: file.name, - ), - ), - onDeleteFile: () => context - .read() - .add( - OnDeleteFile( - fileName: file.name, - ), - ), - onTap: () { - context - .read() - .add( - OnFileEntryTapped( - fileEntry: file, - ), - ); - - // Trigger preview for files - if (file.type == FileType.file) { - previewBloc.add( - OnFilePreviewAppearingEvent( - fileEntry: file, - currentPath: state.path, - ), - ); - } - }, - onUploadFile: () async { - await onUploadFiles(context); - }, - onRefresh: () => context - .read() - .add(OnRefreshFiles()), - onNewDirectory: () async { - await onShowCreateDirectoryDialog( - context, - ); - }, - ); - }, - ), - ), - ), - ], - ); - }, - ), - ); - - // No preview: full width file explorer - if (state.selectedFile == null) { - return fileExplorerContent; - } - - // With preview: resizable panels - return LayoutBuilder( - builder: (context, constraints) { - const dividerWidth = 8.0; - final leftWidth = - constraints.maxWidth * _leftPanelWidth; - final rightWidth = - constraints.maxWidth - leftWidth - dividerWidth; - - return Row( - children: [ - SizedBox( - width: leftWidth, - child: fileExplorerContent, - ), - ResizableDivider( - onResize: (delta) { - setState(() { - final newWidth = - _leftPanelWidth + - (delta / constraints.maxWidth); - _leftPanelWidth = newWidth.clamp( - 0.2, - 0.8, - ); - }); - }, - ), - SizedBox( - width: rightWidth, - child: FilePreviewScreen( - key: ValueKey( - '${state.path}/${state.selectedFile!.name}', - ), - fileEntry: state.selectedFile!, - currentPath: state.path, - onDownloadFile: () { - context.read().add( - OnDownloadFile( - fileName: state.selectedFile!.name, - ), - ); - }, - onDeleteFile: () { - context.read().add( - OnDeleteFile( - fileName: state.selectedFile!.name, - ), - ); - }, - ), - ), - ], - ); - }, - ); - }, - ), - ), - BlocBuilder( - builder: (context, state) { - return FileExplorerBreadcrumb( - currentPath: state.path, - onNavigateToPath: (path) { - context.read().add( - OnGoToDirectory(path: path), - ); - }, - ); - }, - ), - ], - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/file_explorer_state.dart b/lib/features/file_explorer/presentation/file_explorer_state.dart deleted file mode 100644 index 2873225f..00000000 --- a/lib/features/file_explorer/presentation/file_explorer_state.dart +++ /dev/null @@ -1,18 +0,0 @@ -part of 'file_explorer_bloc.dart'; - -@MappableClass() -class FileExplorerState with FileExplorerStateMappable { - final Iterable files; - final FileEntry? selectedFile; - final String path; - final DeviceEntity? device; - final bool isLoading; - - FileExplorerState({ - this.files = const [], - this.path = "/", - this.device, - this.selectedFile, - this.isLoading = false, - }); -} diff --git a/lib/features/file_explorer/presentation/file_preview/file_preview_bloc.dart b/lib/features/file_explorer/presentation/file_preview/file_preview_bloc.dart deleted file mode 100644 index 48c8dd3f..00000000 --- a/lib/features/file_explorer/presentation/file_preview/file_preview_bloc.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'dart:io'; -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/file_explorer/core/file_extension_helper.dart'; -import 'package:android_tools/features/file_explorer/domain/usecases/download_file_to_cache_usecase.dart'; -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/usecases/listen_selected_device_usecase.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logger/logger.dart'; -import 'package:path/path.dart' as p; - -part 'file_preview_event.dart'; -part 'file_preview_state.dart'; -part 'file_preview_bloc.mapper.dart'; - -class FilePreviewBloc extends Bloc { - final Logger _logger = getIt.get(); - final DownloadFileToCacheUsecase _downloadFileToCacheUsecase = getIt.get(); - final ListenSelectedDeviceUsecase _listenSelectedDeviceUsecase = getIt.get(); - - DeviceEntity? _currentDevice; - - FilePreviewBloc() : super(FilePreviewState()) { - // Listen to device changes - _listenSelectedDeviceUsecase().listen((device) { - _currentDevice = device; - }); - - on((event, emit) async { - if (_currentDevice == null) { - _logger.w("Device is null, can't preview file"); - emit( - state.copyWith( - status: FilePreviewStatus.error, - errorMessage: "No device selected", - ), - ); - return; - } - - try { - emit( - state.copyWith( - status: FilePreviewStatus.loading, - fileEntry: event.fileEntry, - ), - ); - - final filePath = p.join(event.currentPath, event.fileEntry.name); - - // Check if file is previewable before downloading - final previewType = _canPreviewFile(event.fileEntry.name); - if (previewType == PreviewType.unsupported) { - _logger.i( - "File $filePath cannot be previewed (unsupported extension)", - ); - emit( - state.copyWith( - status: FilePreviewStatus.loaded, - previewType: PreviewType.unsupported, - ), - ); - return; - } - - _logger.i("Downloading file $filePath to cache"); - - // Download file to cache - final localFilePath = await _downloadFileToCacheUsecase( - filePath, - event.fileEntry.name, - _currentDevice!.deviceId, - ); - - _logger.i("File downloaded to $localFilePath"); - - // Read content for text files - String? content; - if (previewType == PreviewType.text) { - final file = File(localFilePath); - content = await file.readAsString(); - _logger.i("Read ${content.length} characters from file"); - } - - emit( - state.copyWith( - status: FilePreviewStatus.loaded, - previewType: previewType, - content: content, - localFilePath: localFilePath, - ), - ); - } catch (e, stackTrace) { - _logger.e( - "Error loading file preview", - error: e, - stackTrace: stackTrace, - ); - emit( - state.copyWith( - status: FilePreviewStatus.error, - errorMessage: e.toString(), - ), - ); - } - }); - } - - /// Check if a file can be previewed based on its filename/extension - /// This method doesn't require the file to be downloaded - PreviewType _canPreviewFile(String filename) { - final extension = p.extension(filename).toLowerCase(); - - // Text extensions - if (FileExtensionHelper.isTextExtension(extension)) { - return PreviewType.text; - } - - // Image extensions - if (FileExtensionHelper.isImageExtension(extension)) { - return PreviewType.image; - } - - // Video extensions - if (FileExtensionHelper.isVideoExtension(extension)) { - return PreviewType.video; - } - - // Audio extensions - if (FileExtensionHelper.isAudioExtension(extension)) { - return PreviewType.audio; - } - - // PDF extension - if (FileExtensionHelper.isPdfExtension(extension)) { - return PreviewType.pdf; - } - - return PreviewType.unsupported; - } -} diff --git a/lib/features/file_explorer/presentation/file_preview/file_preview_event.dart b/lib/features/file_explorer/presentation/file_preview/file_preview_event.dart deleted file mode 100644 index fb6d79a4..00000000 --- a/lib/features/file_explorer/presentation/file_preview/file_preview_event.dart +++ /dev/null @@ -1,13 +0,0 @@ -part of 'file_preview_bloc.dart'; - -sealed class FilePreviewEvent {} - -class OnFilePreviewAppearingEvent extends FilePreviewEvent { - final FileEntry fileEntry; - final String currentPath; - - OnFilePreviewAppearingEvent({ - required this.fileEntry, - required this.currentPath, - }); -} diff --git a/lib/features/file_explorer/presentation/file_preview/file_preview_screen.dart b/lib/features/file_explorer/presentation/file_preview/file_preview_screen.dart deleted file mode 100644 index a35bddc4..00000000 --- a/lib/features/file_explorer/presentation/file_preview/file_preview_screen.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/file_explorer/core/int_extensions.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_preview/file_preview_bloc.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_preview/widgets/file_preview_action_button.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_preview/widgets/image_preview_widget.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_preview/widgets/pdf_preview_widget.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_preview/widgets/text_preview_widget.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_entry_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class FilePreviewScreen extends StatelessWidget { - final FileEntry fileEntry; - final String currentPath; - final void Function() onDownloadFile; - final void Function() onDeleteFile; - - const FilePreviewScreen({ - super.key, - required this.fileEntry, - required this.currentPath, - required this.onDownloadFile, - required this.onDeleteFile, - }); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => FilePreviewBloc() - ..add( - OnFilePreviewAppearingEvent( - fileEntry: fileEntry, - currentPath: currentPath, - ), - ), - child: Scaffold( - body: BlocBuilder( - builder: (context, state) { - if (state.status == FilePreviewStatus.loading) { - return const Center(child: CircularProgressIndicator()); - } - - if (state.status == FilePreviewStatus.error) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: 64, - color: Colors.red, - ), - const SizedBox(height: 16), - Text( - 'Error loading file', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - state.errorMessage ?? 'Unknown error', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], - ), - ); - } - - if (state.status == FilePreviewStatus.loaded) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - - children: [ - Expanded(child: _buildPreviewContent(context, state)), - Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - fileEntry.name, - style: Theme.of(context).textTheme.titleSmall, - ), - - Divider( - color: Theme.of( - context, - ).colorScheme.onSurface.withAlpha(60), - ), - - Text( - fileEntry.size?.toReadableBytes() ?? "", - style: Theme.of(context).textTheme.bodyMedium, - ), - - Text( - fileEntry.date?.toIso8601String() ?? "", - style: Theme.of(context).textTheme.bodyMedium, - ), - - Text( - fileEntry.permissions, - style: Theme.of(context).textTheme.bodyMedium, - ), - - Divider( - color: Theme.of( - context, - ).colorScheme.onSurface.withAlpha(60), - ), - - Row( - children: [ - Expanded( - child: Wrap( - alignment: WrapAlignment.center, - spacing: 8, - children: [ - FilePreviewActionButton( - onPressed: onDownloadFile, - icon: Icons.download, - text: 'Download', - ), - FilePreviewActionButton( - onPressed: onDeleteFile, - icon: Icons.delete, - text: 'Delete', - ), - ], - ), - ), - ], - ), - ], - ), - ), - ], - ); - } - - return const Center(child: Text('No preview available')); - }, - ), - ), - ); - } - - Widget _buildPreviewContent(BuildContext context, FilePreviewState state) { - switch (state.previewType) { - case PreviewType.text: - return _buildTextPreview(state); - case PreviewType.image: - return _buildImagePreview(state); - case PreviewType.pdf: - return _buildPdfPreview(state); - case PreviewType.video: - return _buildVideoPreview(context, state); - case PreviewType.audio: - return _buildAudioPreview(context, state); - case PreviewType.unsupported: - return _buildUnsupportedPreview(context); - } - } - - Widget _buildTextPreview(FilePreviewState state) { - return TextPreviewWidget( - content: state.content ?? "", - fileName: fileEntry.name, - ); - } - - Widget _buildImagePreview(FilePreviewState state) { - if (state.localFilePath == null) { - return const Center(child: Text('No image file path available')); - } - - return ImagePreviewWidget( - imagePath: state.localFilePath!, - fileName: fileEntry.name, - ); - } - - Widget _buildPdfPreview(FilePreviewState state) { - if (state.localFilePath == null) { - return const Center(child: Text('No PDF file path available')); - } - - return PdfPreviewWidget( - pdfPath: state.localFilePath!, - fileName: fileEntry.name, - ); - } - - Widget _buildVideoPreview(BuildContext context, FilePreviewState state) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.video_library, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text('Video preview', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text( - 'Coming soon...', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey), - ), - ], - ), - ); - } - - Widget _buildAudioPreview(BuildContext context, FilePreviewState state) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.audio_file, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text('Audio preview', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text( - 'Coming soon...', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey), - ), - ], - ), - ); - } - - Widget _buildUnsupportedPreview(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [fileEntry.icon(width: 200)], - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/file_preview/file_preview_state.dart b/lib/features/file_explorer/presentation/file_preview/file_preview_state.dart deleted file mode 100644 index f1aa4e56..00000000 --- a/lib/features/file_explorer/presentation/file_preview/file_preview_state.dart +++ /dev/null @@ -1,24 +0,0 @@ -part of 'file_preview_bloc.dart'; - -enum FilePreviewStatus { initial, loading, loaded, error } - -enum PreviewType { text, image, video, audio, pdf, unsupported } - -@MappableClass() -class FilePreviewState with FilePreviewStateMappable { - final FilePreviewStatus status; - final PreviewType previewType; - final String? content; - final String? localFilePath; - final String? errorMessage; - final FileEntry? fileEntry; - - FilePreviewState({ - this.status = FilePreviewStatus.initial, - this.previewType = PreviewType.unsupported, - this.content, - this.localFilePath, - this.errorMessage, - this.fileEntry, - }); -} diff --git a/lib/features/file_explorer/presentation/file_preview/widgets/file_preview_action_button.dart b/lib/features/file_explorer/presentation/file_preview/widgets/file_preview_action_button.dart deleted file mode 100644 index 5d05a389..00000000 --- a/lib/features/file_explorer/presentation/file_preview/widgets/file_preview_action_button.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -class FilePreviewActionButton extends StatelessWidget { - final IconData icon; - final String text; - final void Function() onPressed; - - const FilePreviewActionButton({ - super.key, - required this.onPressed, - required this.icon, - required this.text, - }); - - @override - Widget build(BuildContext context) { - return FilledButton( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.transparent), - foregroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.onSurface, - ), - ), - onPressed: onPressed, - child: Column(children: [Icon(icon), Text(text)]), - ); - } -} diff --git a/lib/features/file_explorer/presentation/file_preview/widgets/file_preview_content.dart b/lib/features/file_explorer/presentation/file_preview/widgets/file_preview_content.dart deleted file mode 100644 index 285668c2..00000000 --- a/lib/features/file_explorer/presentation/file_preview/widgets/file_preview_content.dart +++ /dev/null @@ -1,227 +0,0 @@ -import 'package:android_tools/features/file_explorer/presentation/file_preview/file_preview_bloc.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_preview/widgets/pdf_preview_widget.dart'; -import 'package:flutter/material.dart'; - -class FilePreviewContent extends StatelessWidget { - final FilePreviewState state; - - const FilePreviewContent({ - super.key, - required this.state, - }); - - @override - Widget build(BuildContext context) { - if (state.status == FilePreviewStatus.initial) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.article_outlined, - size: 64, - color: Colors.grey.shade600, - ), - const SizedBox(height: 16), - Text( - 'Select a file to preview', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.grey.shade600, - ), - ), - ], - ), - ); - } - - if (state.status == FilePreviewStatus.loading) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (state.status == FilePreviewStatus.error) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: 64, - color: Colors.red, - ), - const SizedBox(height: 16), - Text( - 'Error loading file', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - state.errorMessage ?? 'Unknown error', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], - ), - ); - } - - if (state.status == FilePreviewStatus.loaded) { - return _buildPreviewContent(context); - } - - return const Center( - child: Text('No preview available'), - ); - } - - Widget _buildPreviewContent(BuildContext context) { - switch (state.previewType) { - case PreviewType.text: - return _buildTextPreview(context); - case PreviewType.image: - return _buildImagePreview(context); - case PreviewType.pdf: - return _buildPdfPreview(context); - case PreviewType.video: - return _buildVideoPreview(context); - case PreviewType.audio: - return _buildAudioPreview(context); - case PreviewType.unsupported: - return _buildUnsupportedPreview(context); - } - } - - Widget _buildTextPreview(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: SelectableText( - state.content ?? '', - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 14, - ), - ), - ); - } - - Widget _buildImagePreview(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.image, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text( - 'Image preview', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Coming soon...', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), - ), - ], - ), - ); - } - - Widget _buildPdfPreview(BuildContext context) { - if (state.localFilePath == null) { - return const Center(child: Text('No PDF file path available')); - } - - return PdfPreviewWidget( - pdfPath: state.localFilePath!, - fileName: state.fileEntry?.name ?? 'document.pdf', - ); - } - - Widget _buildVideoPreview(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.video_library, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text( - 'Video preview', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Coming soon...', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), - ), - ], - ), - ); - } - - Widget _buildAudioPreview(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.audio_file, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text( - 'Audio preview', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Coming soon...', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), - ), - ], - ), - ); - } - - Widget _buildUnsupportedPreview(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.insert_drive_file, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text( - 'Preview not available', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'This file type is not supported for preview', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), - textAlign: TextAlign.center, - ), - ], - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/file_preview/widgets/image_preview_widget.dart b/lib/features/file_explorer/presentation/file_preview/widgets/image_preview_widget.dart deleted file mode 100644 index f405b1fd..00000000 --- a/lib/features/file_explorer/presentation/file_preview/widgets/image_preview_widget.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -class ImagePreviewWidget extends StatelessWidget { - final String imagePath; - final String fileName; - - const ImagePreviewWidget({ - super.key, - required this.imagePath, - required this.fileName, - }); - - @override - Widget build(BuildContext context) { - return Container( - color: Colors.black12, - child: InteractiveViewer( - minScale: 0.5, - maxScale: 5.0, - child: Center( - child: fileName.endsWith(".svg") - ? SvgPicture.file(File(imagePath)) - : Image.file( - File(imagePath), - errorBuilder: (context, error, stackTrace) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.broken_image, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text( - 'Failed to load image', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - error.toString(), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Colors.grey), - textAlign: TextAlign.center, - ), - ], - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/file_preview/widgets/pdf_preview_widget.dart b/lib/features/file_explorer/presentation/file_preview/widgets/pdf_preview_widget.dart deleted file mode 100644 index 1300f1a7..00000000 --- a/lib/features/file_explorer/presentation/file_preview/widgets/pdf_preview_widget.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pdfx/pdfx.dart'; - -class PdfPreviewWidget extends StatefulWidget { - final String pdfPath; - final String fileName; - - const PdfPreviewWidget({ - super.key, - required this.pdfPath, - required this.fileName, - }); - - @override - State createState() => _PdfPreviewWidgetState(); -} - -class _PdfPreviewWidgetState extends State { - PdfControllerPinch? _pdfController; - bool _isLoading = true; - String? _errorMessage; - - @override - void initState() { - super.initState(); - _initializePdf(); - } - - Future _initializePdf() async { - try { - setState(() { - _pdfController = PdfControllerPinch( - document: PdfDocument.openFile(widget.pdfPath), - ); - _isLoading = false; - }); - } catch (e) { - setState(() { - _isLoading = false; - _errorMessage = _getErrorMessage(e); - }); - } - } - - String _getErrorMessage(dynamic error) { - final errorStr = error.toString().toLowerCase(); - - if (errorStr.contains('password') || errorStr.contains('encrypted')) { - return 'This PDF is password-protected'; - } else if (errorStr.contains('corrupt') || errorStr.contains('invalid')) { - return 'This PDF file appears to be corrupted'; - } else if (errorStr.contains('format')) { - return 'Unsupported PDF format'; - } - - return 'Failed to load PDF: ${error.toString()}'; - } - - @override - void dispose() { - _pdfController?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (_isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (_errorMessage != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: 64, - color: Colors.red, - ), - const SizedBox(height: 16), - Text( - 'Cannot display PDF', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - _errorMessage!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - ); - } - - if (_pdfController == null) { - return const Center( - child: Text('PDF controller not initialized'), - ); - } - - return Container( - color: Colors.black12, - child: PdfViewPinch( - controller: _pdfController!, - scrollDirection: Axis.vertical, - backgroundDecoration: const BoxDecoration( - color: Colors.black12, - ), - onDocumentError: (error) { - setState(() { - _errorMessage = _getErrorMessage(error); - }); - }, - builders: PdfViewPinchBuilders( - options: const DefaultBuilderOptions(), - documentLoaderBuilder: (context) => const Center( - child: CircularProgressIndicator(), - ), - pageLoaderBuilder: (context) => const Center( - child: CircularProgressIndicator(), - ), - errorBuilder: (context, error) => Center( - child: Text( - 'Error loading page', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/file_preview/widgets/text_preview_widget.dart b/lib/features/file_explorer/presentation/file_preview/widgets/text_preview_widget.dart deleted file mode 100644 index 1e8af045..00000000 --- a/lib/features/file_explorer/presentation/file_preview/widgets/text_preview_widget.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_monaco/flutter_monaco.dart'; - -class TextPreviewWidget extends StatefulWidget { - final String content; - final String fileName; - - const TextPreviewWidget({ - super.key, - required this.content, - required this.fileName, - }); - - @override - State createState() => _TextPreviewWidgetState(); -} - -class _TextPreviewWidgetState extends State { - final FocusNode _focusNode = FocusNode(); - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Listener( - onPointerDown: (_) { - _focusNode.requestFocus(); - }, - child: Focus( - focusNode: _focusNode, - autofocus: true, - child: MonacoEditor( - initialValue: widget.content, - interactionEnabled: true, - loadingBuilder: (context) => Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - options: EditorOptions( - wordWrap: false, - lineNumbers: false, - readOnly: true, - scrollBeyondLastLine: false, - contextMenu: false, - ), - ), - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/widgets/file_entry_extensions.dart b/lib/features/file_explorer/presentation/widgets/file_entry_extensions.dart deleted file mode 100644 index e396a423..00000000 --- a/lib/features/file_explorer/presentation/widgets/file_entry_extensions.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; - -extension FileEntryExtensions on FileEntry { - Widget icon({double width = 20}) { - switch (type) { - case FileType.directory: - return SvgPicture.asset( - "assets/images/folder/red_folder.svg", - width: width, - ); - case FileType.symlink: - return Icon(Icons.drive_folder_upload_outlined); - case FileType.unknown: - return Icon(Icons.folder_off); - case FileType.file: - return _buildFileIcon(name, width); - } - } - - Widget _buildFileIcon(String name, double width) { - final lowerName = name.toLowerCase(); - - final assetName = switch (lowerName) { - _ when RegExp(r'\.log(\.\d+)?$').hasMatch(lowerName) => 'log.svg', - _ when RegExp(r'\.(png|jpe?g|gif|webp)$').hasMatch(lowerName) => - 'image.svg', - _ when lowerName.endsWith('.svg') => 'svg.svg', - _ when RegExp(r'\.(db|sqlite)$').hasMatch(lowerName) => 'database.svg', - _ when lowerName.endsWith('.json') => 'json.svg', - _ when lowerName.endsWith('.xml') => 'xml.svg', - _ when RegExp(r'\.(mp4|mov|avi|mkv)$').hasMatch(lowerName) => 'video.svg', - _ => 'document.svg', - }; - - return Stack( - alignment: AlignmentGeometry.center, - children: [ - SvgPicture.asset("assets/images/file/file_dark.svg", width: width), - SvgPicture.asset( - "assets/images/file_extensions/$assetName", - width: width / 2, - ), - ], - ); - } -} diff --git a/lib/features/file_explorer/presentation/widgets/file_entry_menu_result.dart b/lib/features/file_explorer/presentation/widgets/file_entry_menu_result.dart deleted file mode 100644 index 538f0275..00000000 --- a/lib/features/file_explorer/presentation/widgets/file_entry_menu_result.dart +++ /dev/null @@ -1 +0,0 @@ -enum FileEntryMenuResult { download, delete, upload, refresh, newDirectory } diff --git a/lib/features/file_explorer/presentation/widgets/file_explorer_app_bar.dart b/lib/features/file_explorer/presentation/widgets/file_explorer_app_bar.dart deleted file mode 100644 index 45b0ee54..00000000 --- a/lib/features/file_explorer/presentation/widgets/file_explorer_app_bar.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:flutter/material.dart'; - -class FileExplorerAppBar extends StatelessWidget { - final void Function()? onGoBack; - final String path; - final bool showSearch; - final TextEditingController? searchController; - final FocusNode? searchFocusNode; - final int matchCount; - final int currentMatchIndex; - final VoidCallback? onSearchNext; - final VoidCallback? onSearchPrevious; - final VoidCallback? onSearchClose; - final VoidCallback? onSearchChanged; - - const FileExplorerAppBar({ - super.key, - required this.path, - required this.onGoBack, - this.showSearch = false, - this.searchController, - this.searchFocusNode, - this.matchCount = 0, - this.currentMatchIndex = 0, - this.onSearchNext, - this.onSearchPrevious, - this.onSearchClose, - this.onSearchChanged, - }); - - @override - Widget build(BuildContext context) { - return AppBar( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - foregroundColor: Theme.of(context).colorScheme.onSurface, - surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, - title: Text(path, style: Theme.of(context).textTheme.titleLarge), - leading: IconButton( - onPressed: onGoBack, - icon: Icon(Icons.chevron_left_rounded), - ), - actions: [ - _buildSearchField(context), - if (showSearch) ..._buildSearchActions(context), - SizedBox(width: 8), - ], - ); - } - - Widget _buildSearchField(BuildContext context) { - return SizedBox( - width: 250, - child: TextField( - controller: searchController, - focusNode: searchFocusNode, - autofocus: showSearch, - style: Theme.of(context).textTheme.bodySmall, - decoration: InputDecoration( - hintText: 'Search...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.3), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.3), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - ), - ), - hintStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 14, - ), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - isDense: true, - suffixText: showSearch - ? (matchCount == 0 - ? '0/0' - : '${currentMatchIndex + 1}/$matchCount') - : null, - suffixStyle: TextStyle( - color: Theme.of(context).colorScheme.outline, - fontSize: 11, - ), - ), - onChanged: (_) => onSearchChanged?.call(), - ), - ); - } - - List _buildSearchActions(BuildContext context) { - return [ - IconButton( - icon: Icon(Icons.keyboard_arrow_up, size: 20), - onPressed: matchCount == 0 ? null : onSearchPrevious, - tooltip: 'Previous (Shift+Enter)', - ), - IconButton( - icon: Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: matchCount == 0 ? null : onSearchNext, - tooltip: 'Next (Enter)', - ), - IconButton( - icon: Icon(Icons.close, size: 20), - onPressed: onSearchClose, - tooltip: 'Close (Escape)', - ), - ]; - } -} diff --git a/lib/features/file_explorer/presentation/widgets/file_explorer_breadcrumb.dart b/lib/features/file_explorer/presentation/widgets/file_explorer_breadcrumb.dart deleted file mode 100644 index 57593057..00000000 --- a/lib/features/file_explorer/presentation/widgets/file_explorer_breadcrumb.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; - -class FileExplorerBreadcrumb extends StatelessWidget { - final String currentPath; - final void Function(String path) onNavigateToPath; - - const FileExplorerBreadcrumb({ - super.key, - required this.currentPath, - required this.onNavigateToPath, - }); - - @override - Widget build(BuildContext context) { - final parts = currentPath - .split("/") - .where((part) => part.isNotEmpty) - .toList(); - final breadcrumbItems = ["/", ...parts]; - - return Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: SizedBox( - height: 30, - child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - scrollDirection: Axis.horizontal, - separatorBuilder: (context, index) { - return Icon( - Icons.chevron_right_rounded, - size: 12, - color: Theme.of(context).colorScheme.outline, - ); - }, - itemBuilder: (context, index) { - final part = breadcrumbItems[index]; - return TextButton( - style: ButtonStyle( - foregroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.onSurface, - ), - ), - onPressed: () { - if (index == 0) { - onNavigateToPath(""); - } else { - final newPath = parts.take(index).join("/"); - onNavigateToPath(newPath); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Row( - spacing: 4, - children: [ - SvgPicture.asset( - "assets/images/folder/red_folder.svg", - width: 12, - ), - Text(part), - ], - ), - ), - ); - }, - itemCount: breadcrumbItems.length, - ), - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/widgets/file_explorer_drop_target.dart b/lib/features/file_explorer/presentation/widgets/file_explorer_drop_target.dart deleted file mode 100644 index ce746c29..00000000 --- a/lib/features/file_explorer/presentation/widgets/file_explorer_drop_target.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:desktop_drop/desktop_drop.dart'; -import 'package:flutter/material.dart'; - -class FileExplorerDropTarget extends StatefulWidget { - final Widget child; - final OnDragDoneCallback? onFileDropped; - - const FileExplorerDropTarget({ - super.key, - required this.child, - this.onFileDropped, - }); - - @override - State createState() => _FileExplorerDropTargetState(); -} - -class _FileExplorerDropTargetState extends State { - bool isDropping = false; - - @override - Widget build(BuildContext context) { - return DropTarget( - onDragEntered: (_) { - setState(() => isDropping = true); - }, - onDragExited: (_) { - setState(() => isDropping = false); - }, - onDragDone: widget.onFileDropped, - child: Stack( - children: [ - widget.child, - - if (isDropping) - Positioned.fill( - child: IgnorePointer( - child: Container( - color: Colors.black.withAlpha(60), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon( - Icons.file_download_outlined, - size: 64, - color: Colors.white, - ), - SizedBox(height: 12), - Text( - 'Drop your files here', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/widgets/file_explorer_file_entry_item.dart b/lib/features/file_explorer/presentation/widgets/file_explorer_file_entry_item.dart deleted file mode 100644 index cec73b0b..00000000 --- a/lib/features/file_explorer/presentation/widgets/file_explorer_file_entry_item.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/file_explorer/core/int_extensions.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_entry_menu_result.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_explorer_menus.dart'; -import 'package:android_tools/features/file_explorer/presentation/widgets/file_entry_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -class FileExplorerFileEntryItem extends StatelessWidget { - final FileEntry file; - final bool isSelected; - final String? searchQuery; - final void Function() onDownloadFile; - final void Function() onDeleteFile; - final void Function() onUploadFile; - final void Function() onRefresh; - final void Function() onNewDirectory; - final void Function() onTap; - - const FileExplorerFileEntryItem({ - super.key, - required this.file, - required this.isSelected, - this.searchQuery, - required this.onDownloadFile, - required this.onDeleteFile, - required this.onTap, - required this.onUploadFile, - required this.onRefresh, - required this.onNewDirectory, - }); - - Widget _buildHighlightedText( - BuildContext context, - String text, - String? query, - TextStyle? style, { - required Color textColor, - }) { - if (query == null || query.isEmpty) { - return Text( - text, - style: (style ?? const TextStyle()).copyWith(color: textColor), - ); - } - - final lowerText = text.toLowerCase(); - final lowerQuery = query.toLowerCase(); - final matches = []; - int currentIndex = 0; - - // Create base style with proper text color - final baseStyle = (style ?? const TextStyle()).copyWith(color: textColor); - - while (currentIndex < text.length) { - final matchIndex = lowerText.indexOf(lowerQuery, currentIndex); - - if (matchIndex == -1) { - // No more matches, add remaining text - matches.add( - TextSpan(text: text.substring(currentIndex), style: baseStyle), - ); - break; - } - - // Add text before match - if (matchIndex > currentIndex) { - matches.add( - TextSpan( - text: text.substring(currentIndex, matchIndex), - style: baseStyle, - ), - ); - } - - // Add highlighted match - matches.add( - TextSpan( - text: text.substring(matchIndex, matchIndex + query.length), - style: TextStyle( - backgroundColor: Colors.orange, - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - ); - - currentIndex = matchIndex + query.length; - } - - return Text.rich( - TextSpan(children: matches, style: baseStyle), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } - - Widget? _buildSubtitle(BuildContext context) { - final parts = []; - if (file.size != null) parts.add(file.size!.toReadableBytes()); - if (file.date != null) parts.add(DateFormat('dd MMM yyyy HH:mm').format(file.date!)); - if (parts.isEmpty) return null; - return Text( - parts.join(' Β· '), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: isSelected - ? Theme.of(context).colorScheme.surface.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - ); - } - - @override - Widget build(BuildContext context) { - return AbsorbPointer( - absorbing: false, - child: GestureDetector( - onSecondaryTapDown: (details) { - FileExplorerMenus.showFileEntryMenu(context, details).then((value) { - switch (value) { - case FileEntryMenuResult.download: - onDownloadFile(); - case FileEntryMenuResult.delete: - onDeleteFile(); - case FileEntryMenuResult.upload: - onUploadFile(); - case FileEntryMenuResult.refresh: - onRefresh(); - case FileEntryMenuResult.newDirectory: - onNewDirectory(); - case null: - } - }); - }, - child: Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - color: Colors.transparent, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusGeometry.all(Radius.circular(12)), - ), - child: ListTile( - selectedColor: Theme.of(context).colorScheme.surface, - selectedTileColor: Theme.of(context).colorScheme.onSurface, - tileColor: Colors.transparent, - enabled: - file.type == FileType.directory || file.type == FileType.file, - selected: isSelected, - leading: file.icon(), - title: _buildHighlightedText( - context, - file.name, - searchQuery, - null, - textColor: isSelected - ? Theme.of(context).colorScheme.surface - : Theme.of(context).colorScheme.onSurface, - ), - subtitle: _buildSubtitle(context), - onTap: onTap, - ), - ), - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/widgets/file_explorer_menus.dart b/lib/features/file_explorer/presentation/widgets/file_explorer_menus.dart deleted file mode 100644 index d4d99384..00000000 --- a/lib/features/file_explorer/presentation/widgets/file_explorer_menus.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:android_tools/features/file_explorer/presentation/widgets/file_entry_menu_result.dart'; -import 'package:flutter/material.dart'; - -class FileExplorerMenus { - static Future showFileEntryMenu( - BuildContext context, - TapDownDetails details, - ) { - return showMenu( - context: context, - position: RelativeRect.fromLTRB( - details.globalPosition.dx, - details.globalPosition.dy, - details.globalPosition.dx, - details.globalPosition.dy, - ), - items: [ - const PopupMenuItem( - value: FileEntryMenuResult.refresh, - child: Row( - spacing: 8, - children: [Icon(Icons.refresh_rounded), Text('Refresh')], - ), - ), - const PopupMenuItem( - value: FileEntryMenuResult.newDirectory, - child: Row( - spacing: 8, - children: [ - Icon(Icons.create_new_folder_outlined), - Text('New folder'), - ], - ), - ), - const PopupMenuItem( - value: FileEntryMenuResult.upload, - child: Row( - spacing: 8, - children: [Icon(Icons.upload_rounded), Text('Upload')], - ), - ), - const PopupMenuItem( - value: FileEntryMenuResult.download, - child: Row( - spacing: 8, - children: [Icon(Icons.download_rounded), Text('Download')], - ), - ), - PopupMenuItem( - value: FileEntryMenuResult.delete, - child: Row( - spacing: 8, - children: [ - Icon( - Icons.delete_outline_rounded, - color: Theme.of(context).colorScheme.error, - ), - Text( - 'Delete', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - ), - ), - ], - ); - } - - static Future showGeneralMenu( - BuildContext context, - TapDownDetails details, - ) { - return showMenu( - context: context, - position: RelativeRect.fromLTRB( - details.globalPosition.dx, - details.globalPosition.dy, - details.globalPosition.dx, - details.globalPosition.dy, - ), - items: [ - const PopupMenuItem( - value: FileEntryMenuResult.refresh, - child: Row( - spacing: 8, - children: [Icon(Icons.refresh_rounded), Text('Refresh')], - ), - ), - const PopupMenuItem( - value: FileEntryMenuResult.newDirectory, - child: Row( - spacing: 8, - children: [ - Icon(Icons.create_new_folder_outlined), - Text('New folder'), - ], - ), - ), - const PopupMenuItem( - value: FileEntryMenuResult.upload, - child: Row( - spacing: 8, - children: [Icon(Icons.upload_rounded), Text('Upload')], - ), - ), - ], - ); - } -} diff --git a/lib/features/file_explorer/presentation/widgets/file_explorer_search_bar.dart b/lib/features/file_explorer/presentation/widgets/file_explorer_search_bar.dart deleted file mode 100644 index 7e49b15f..00000000 --- a/lib/features/file_explorer/presentation/widgets/file_explorer_search_bar.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; - -class FileExplorerSearchBar extends StatelessWidget { - final TextEditingController controller; - final FocusNode focusNode; - final int matchCount; - final int currentMatchIndex; - final VoidCallback onNext; - final VoidCallback onPrevious; - final VoidCallback onClose; - final VoidCallback onChanged; - - const FileExplorerSearchBar({ - super.key, - required this.controller, - required this.focusNode, - required this.matchCount, - required this.currentMatchIndex, - required this.onNext, - required this.onPrevious, - required this.onClose, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Container( - color: Theme.of(context).colorScheme.surfaceContainerLow, - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - focusNode: focusNode, - autofocus: true, - decoration: InputDecoration( - hintText: 'Rechercher...', - border: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - ), - ), - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - isDense: true, - suffixText: matchCount == 0 - ? '0/0' - : '${currentMatchIndex + 1}/$matchCount', - suffixStyle: TextStyle( - color: Theme.of(context).colorScheme.outline, - ), - ), - onChanged: (_) => onChanged(), - ), - ), - SizedBox(width: 8), - IconButton( - icon: Icon(Icons.keyboard_arrow_up), - onPressed: matchCount == 0 ? null : onPrevious, - tooltip: 'PrΓ©cΓ©dent (Shift+Enter)', - padding: EdgeInsets.all(4), - constraints: BoxConstraints(), - ), - IconButton( - icon: Icon(Icons.keyboard_arrow_down), - onPressed: matchCount == 0 ? null : onNext, - tooltip: 'Suivant (Enter)', - padding: EdgeInsets.all(4), - constraints: BoxConstraints(), - ), - IconButton( - icon: Icon(Icons.close), - onPressed: onClose, - tooltip: 'Fermer (Escape)', - padding: EdgeInsets.all(4), - constraints: BoxConstraints(), - ), - ], - ), - ); - } -} diff --git a/lib/features/file_explorer/presentation/widgets/resizable_divider.dart b/lib/features/file_explorer/presentation/widgets/resizable_divider.dart deleted file mode 100644 index c6418d89..00000000 --- a/lib/features/file_explorer/presentation/widgets/resizable_divider.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; - -class ResizableDivider extends StatefulWidget { - final ValueChanged onResize; - final double width; - - const ResizableDivider({ - super.key, - required this.onResize, - this.width = 8.0, - }); - - @override - State createState() => _ResizableDividerState(); -} - -class _ResizableDividerState extends State { - bool _isHovering = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - onEnter: (_) => setState(() => _isHovering = true), - onExit: (_) => setState(() => _isHovering = false), - child: GestureDetector( - onHorizontalDragUpdate: (details) { - widget.onResize(details.delta.dx); - }, - child: Container( - width: widget.width, - color: _isHovering - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2) - : Colors.transparent, - child: Center( - child: Container( - width: 1, - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/home/core/home_module.dart b/lib/features/home/core/home_module.dart deleted file mode 100644 index f7a32a19..00000000 --- a/lib/features/home/core/home_module.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:android_tools/features/home/presentation/home_bloc.dart'; -import 'package:android_tools/main.dart'; - -class HomeModule { - static void configureDependencies() { - getIt.registerFactory(() => HomeBloc()); - } -} diff --git a/lib/features/home/presentation/home_bloc.dart b/lib/features/home/presentation/home_bloc.dart deleted file mode 100644 index 7c7120d7..00000000 --- a/lib/features/home/presentation/home_bloc.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/usecases/listen_connected_devices_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/listen_selected_device_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/refresh_connected_devices_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/set_selected_device_usecase.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logger/logger.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -part 'home_event.dart'; -part 'home_state.dart'; -part 'home_bloc.mapper.dart'; - -class HomeBloc extends Bloc { - final Logger _logger = getIt.get(); - final ListenConnectedDevicesUsecase _listenConnectedDevicesUsecase = getIt - .get(); - final ListenSelectedDeviceUsecase _listenSelectedDeviceUsecase = getIt.get(); - final SetSelectedDeviceUsecase _setSelectedDeviceUsecase = getIt.get(); - final RefreshConnectedDevicesUsecase _refreshConnectedDevicesUsecase = getIt - .get(); - - HomeBloc() : super(HomeState()) { - on((event, emit) async { - final packageInfo = await PackageInfo.fromPlatform(); - emit(state.copyWith(version: packageInfo.version)); - }); - on((event, emit) async { - await _refreshConnectedDevicesUsecase(); - - final packageInfo = await PackageInfo.fromPlatform(); - emit(state.copyWith(version: packageInfo.version)); - - await emit.forEach>( - _listenConnectedDevicesUsecase(), - onData: (devices) { - return state.copyWith(devices: devices); - }, - ); - }); - on((event, emit) async { - await emit.forEach( - _listenSelectedDeviceUsecase(), - onData: (device) { - return state.copyWith(selectedDevice: device); - }, - ); - }); - on((event, emit) async { - _logger.i("Device selected : ${event.device.deviceId}"); - await _setSelectedDeviceUsecase(event.device); - }); - on((event, emit) async { - await _refreshConnectedDevicesUsecase(); - }); - } -} diff --git a/lib/features/home/presentation/home_event.dart b/lib/features/home/presentation/home_event.dart deleted file mode 100644 index 5da45415..00000000 --- a/lib/features/home/presentation/home_event.dart +++ /dev/null @@ -1,17 +0,0 @@ -part of 'home_bloc.dart'; - -sealed class HomeEvent {} - -class OnGetVersion extends HomeEvent {} - -class OnListenConnectedDevices extends HomeEvent {} - -class OnListenSelectedDevice extends HomeEvent {} - -class OnDeviceSelected extends HomeEvent { - final DeviceEntity device; - - OnDeviceSelected({required this.device}); -} - -class OnRefreshDevices extends HomeEvent {} diff --git a/lib/features/home/presentation/home_screen.dart b/lib/features/home/presentation/home_screen.dart deleted file mode 100644 index 3f0334a2..00000000 --- a/lib/features/home/presentation/home_screen.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'package:android_tools/features/apk_inspector/presentation/apk_inspector_screen.dart'; -import 'package:android_tools/features/file_explorer/presentation/file_explorer_screen.dart'; -import 'package:android_tools/features/home/presentation/home_bloc.dart'; -import 'package:android_tools/features/home/presentation/widgets/capture_screen.dart'; -import 'package:android_tools/features/home/presentation/widgets/device_box.dart'; -import 'package:android_tools/features/home/presentation/widgets/navigation_rail_item.dart'; -import 'package:android_tools/features/information/presentation/information_screen.dart'; -import 'package:android_tools/features/logcat/presentation/logcat_screen.dart'; -import 'package:android_tools/features/settings/presentation/settings_screen.dart'; -import 'package:android_tools/main.dart'; -import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); - - @override - State createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State with WidgetsBindingObserver { - final _homeBloc = getIt(); - int _selectedIndex = 0; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - if (state == AppLifecycleState.resumed) { - _homeBloc.add(OnRefreshDevices()); - } - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _homeBloc - ..add(OnListenConnectedDevices()) - ..add(OnListenSelectedDevice()) - ..add(OnRefreshDevices()) - ..add(OnGetVersion()), - child: BlocBuilder( - builder: (context, state) { - return Scaffold( - appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: MoveWindow( - child: Container( - width: double.infinity, - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - SizedBox(width: 100), - // Device box - DeviceBox(state: state), - ], - ), - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - state.version, - style: TextStyle( - fontSize: 11, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - ), - ), - ], - ), - ), - ), - ), - body: Row( - children: [ - SizedBox( - width: 250, - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Navigation items - NavigationRailItem( - selected: _selectedIndex == 0, - text: "Device", - icon: Icons.memory_outlined, - onTap: () => setState(() => _selectedIndex = 0), - ), - const SizedBox(height: 2), - NavigationRailItem( - selected: _selectedIndex == 1, - text: "Logcat", - icon: Icons.terminal, - onTap: () => setState(() => _selectedIndex = 1), - ), - const SizedBox(height: 2), - NavigationRailItem( - selected: _selectedIndex == 2, - text: "Files", - icon: Icons.folder_outlined, - onTap: () => setState(() => _selectedIndex = 2), - ), - const SizedBox(height: 2), - NavigationRailItem( - selected: _selectedIndex == 3, - text: "Capture", - icon: Icons.camera_alt_outlined, - onTap: () => setState(() => _selectedIndex = 3), - ), - const SizedBox(height: 2), - NavigationRailItem( - selected: _selectedIndex == 4, - text: "APK", - icon: Icons.inventory_2_outlined, - onTap: () => setState(() => _selectedIndex = 4), - ), - - const Spacer(), - - Divider( - color: Theme.of( - context, - ).colorScheme.surfaceContainer, - height: 1, - ), - const SizedBox(height: 8), - - NavigationRailItem( - selected: _selectedIndex == 5, - text: "Settings", - icon: Icons.settings_outlined, - onTap: () => setState(() => _selectedIndex = 5), - ), - - const SizedBox(height: 8), - ], - ), - ), - ), - ), - Expanded( - child: switch (_selectedIndex) { - 0 => InformationScreen(), - 1 => LogcatScreen(), - 2 => FileExplorerScreen(), - 3 => CaptureScreen(device: state.selectedDevice), - 4 => ApkInspectorScreen(), - 5 => SettingsScreen(), - _ => Placeholder(), - }, - ), - ], - ), - ); - }, - ), - ); - } -} diff --git a/lib/features/home/presentation/home_state.dart b/lib/features/home/presentation/home_state.dart deleted file mode 100644 index 1a3e2898..00000000 --- a/lib/features/home/presentation/home_state.dart +++ /dev/null @@ -1,10 +0,0 @@ -part of 'home_bloc.dart'; - -@MappableClass() -class HomeState with HomeStateMappable { - final String version; - final Iterable devices; - final DeviceEntity? selectedDevice; - - HomeState({this.version = "", this.devices = const [], this.selectedDevice}); -} diff --git a/lib/features/home/presentation/widgets/capture_screen.dart b/lib/features/home/presentation/widgets/capture_screen.dart deleted file mode 100644 index 61c99105..00000000 --- a/lib/features/home/presentation/widgets/capture_screen.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:android_tools/features/screenshot/presentation/screenshot_preview_bloc.dart'; -import 'package:android_tools/features/screenshot/presentation/screenshot_preview_screen.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class CaptureScreen extends StatefulWidget { - final DeviceEntity? device; - const CaptureScreen({super.key, this.device}); - - @override - State createState() => _CaptureScreenState(); -} - -class _CaptureScreenState extends State { - late final ScreenshotPreviewBloc _bloc; - - @override - void initState() { - super.initState(); - _bloc = ScreenshotPreviewBloc(); - } - - @override - void dispose() { - _bloc.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _bloc, - child: BlocConsumer( - listener: (context, state) { - if (state.status == ScreenshotStatus.success && - state.screenshot != null && - context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ScreenshotPreviewScreen( - screenshot: state.screenshot!, - device: state.device!, - ), - ), - ); - _bloc.add(OnResetState()); - } - if (state.status == ScreenshotStatus.error && - state.errorMessage != null && - context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.errorMessage!), - backgroundColor: Colors.red, - ), - ); - _bloc.add(OnResetState()); - } - }, - builder: (context, captureState) { - final device = widget.device; - final isCapturing = captureState.status == ScreenshotStatus.capturing; - - return Scaffold( - backgroundColor: Colors.transparent, - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Card( - color: Theme.of(context).colorScheme.surfaceContainer, - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 16, - children: [ - Icon( - Icons.camera_alt_outlined, - size: 48, - color: Theme.of(context).colorScheme.primary, - ), - Text( - "Capture", - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - device == null - ? "No device connected" - : "Capture a screenshot of ${device.name}", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - textAlign: TextAlign.center, - ), - FilledButton.icon( - onPressed: device != null && !isCapturing - ? () { - context.read().add( - OnCaptureScreenshot( - deviceId: device.deviceId, - device: device, - ), - ); - } - : null, - icon: isCapturing - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.photo_camera_outlined), - label: Text( - isCapturing ? "Capturing…" : "Take Screenshot", - ), - ), - ], - ), - ), - ), - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/features/home/presentation/widgets/device_box.dart b/lib/features/home/presentation/widgets/device_box.dart deleted file mode 100644 index e875ac8b..00000000 --- a/lib/features/home/presentation/widgets/device_box.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:android_tools/features/home/presentation/home_bloc.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class DeviceBox extends StatelessWidget { - final HomeState state; - const DeviceBox({super.key, required this.state}); - - @override - Widget build(BuildContext context) { - final hasDevice = state.selectedDevice != null; - final devices = state.devices; - - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: SizedBox( - width: 250, - child: Row( - spacing: 8, - children: [ - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: hasDevice - ? const Color(0xFF4CAF50) - : const Color(0xFF6B707A), - boxShadow: hasDevice - ? [ - BoxShadow( - color: const Color(0xFF4CAF50).withValues(alpha: 0.5), - blurRadius: 4, - ), - ] - : null, - ), - ), - Expanded( - child: devices.isEmpty - ? Text( - "No device", - style: TextStyle( - fontSize: 13, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - ) - : DropdownButton( - isDense: true, - icon: const Icon(Icons.keyboard_arrow_down, size: 16), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface, - ), - value: state.selectedDevice, - underline: SizedBox(), - items: devices - .map( - (device) => DropdownMenuItem( - value: device, - child: Text( - device.name, - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(), - onChanged: (device) { - if (device == null) return; - // Find HomeBloc in the widget tree - context.read().add( - OnDeviceSelected(device: device), - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/home/presentation/widgets/navigation_rail_item.dart b/lib/features/home/presentation/widgets/navigation_rail_item.dart deleted file mode 100644 index f1b4d6f1..00000000 --- a/lib/features/home/presentation/widgets/navigation_rail_item.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; - -class NavigationRailItem extends StatelessWidget { - final bool selected; - final String text; - final IconData? icon; - final void Function()? onTap; - - const NavigationRailItem({ - super.key, - required this.selected, - required this.text, - this.icon, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final primary = Theme.of(context).colorScheme.primary; - final dimColor = const Color(0xFF6B707A); - - return InkWell( - borderRadius: BorderRadius.circular(6), - onTap: onTap, - child: Container( - decoration: BoxDecoration( - color: selected ? primary.withValues(alpha: 0.12) : Colors.transparent, - borderRadius: BorderRadius.circular(6), - border: selected - ? Border( - left: BorderSide(color: primary, width: 2), - ) - : null, - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - if (icon != null) ...[ - Icon( - icon, - size: 16, - color: selected ? primary : dimColor, - ), - const SizedBox(width: 8), - ], - Expanded( - child: Text( - text, - style: TextStyle( - fontSize: 13, - color: selected ? primary : const Color(0xFFA7ABB4), - fontWeight: selected ? FontWeight.w600 : FontWeight.normal, - ), - ), - ), - - ], - ), - ), - ); - } -} diff --git a/lib/features/information/core/android_version_helper.dart b/lib/features/information/core/android_version_helper.dart deleted file mode 100644 index eca625e6..00000000 --- a/lib/features/information/core/android_version_helper.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; - -/// Returns the appropriate Android version logo widget based on the version string. -Widget getAndroidVersionLogo(String version, {double? size}) { - const basePath = "assets/android_versions/"; - - Widget logo; - switch (version) { - case "17": - logo = SvgPicture.asset("${basePath}android_17.svg"); - case "16": - logo = SvgPicture.asset("${basePath}android_16.svg"); - break; - case "15": - logo = SvgPicture.asset("${basePath}android_15.svg"); - break; - case "14": - logo = SvgPicture.asset("${basePath}android_14.svg"); - break; - case "13": - logo = SvgPicture.asset("${basePath}android_13.svg"); - break; - case "12": - logo = SvgPicture.asset("${basePath}android_12.svg"); - break; - case "11": - logo = SvgPicture.asset("${basePath}android_11.svg"); - break; - case "10": - logo = SvgPicture.asset("${basePath}android_10.svg"); - break; - case "9": - logo = SvgPicture.asset("${basePath}android_9.svg"); - break; - case "8": - logo = SvgPicture.asset("${basePath}android_8.svg"); - break; - case "7": - logo = const Image(image: AssetImage('${basePath}android_7.png')); - break; - case "6": - logo = SvgPicture.asset("${basePath}android_6.svg"); - break; - case "5": - logo = SvgPicture.asset("${basePath}android_5.svg"); - break; - case "4": - logo = const Image(image: AssetImage('${basePath}android_4.png')); - break; - case "3": - logo = const Image(image: AssetImage('${basePath}android_3.png')); - break; - case "2": - logo = const Image(image: AssetImage('${basePath}android_1.png')); - break; - case "1": - logo = const Image(image: AssetImage('${basePath}android_1.png')); - break; - default: - logo = const Image(image: AssetImage('${basePath}android.png')); - } - - if (size != null) { - return SizedBox(width: size, height: size, child: logo); - } - - return logo; -} diff --git a/lib/features/information/core/information_module.dart b/lib/features/information/core/information_module.dart deleted file mode 100644 index 9d9cf6e5..00000000 --- a/lib/features/information/core/information_module.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:android_tools/features/information/data/repositories/device_information_repository_impl.dart'; -import 'package:android_tools/features/information/domain/repositories/device_information_repository.dart'; -import 'package:android_tools/features/information/domain/usecases/get_device_battery_information_usecase.dart'; -import 'package:android_tools/features/information/domain/usecases/get_device_information_usecase.dart'; -import 'package:android_tools/main.dart'; - -class InformationModule { - static void configureDependencies() { - getIt.registerLazySingleton(() => GetDeviceInformationUsecase(getIt.get())); - getIt.registerLazySingleton( - () => GetDeviceBatteryInformationUsecase(getIt.get()), - ); - getIt.registerLazySingleton( - () => DeviceInformationRepositoryImpl(getIt.get()), - ); - } -} diff --git a/lib/features/information/data/repositories/device_information_repository_impl.dart b/lib/features/information/data/repositories/device_information_repository_impl.dart deleted file mode 100644 index e1158f6e..00000000 --- a/lib/features/information/data/repositories/device_information_repository_impl.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/information/domain/entities/device_information_entity.dart'; -import 'package:android_tools/features/information/domain/repositories/device_information_repository.dart'; - -class DeviceInformationRepositoryImpl implements DeviceInformationRepository { - final AdbClient _adbClient; - - DeviceInformationRepositoryImpl(this._adbClient); - - @override - Future getDeviceInformation(String deviceId) async { - try { - final properties = await _adbClient.getProperties(deviceId); - - DisplayInfo? displayInfo; - try { - displayInfo = await _adbClient.getDisplayInfo(deviceId); - } catch (_) {} - - return DeviceInformationEntity( - manufacturer: properties['ro.product.manufacturer'] ?? "", - model: properties['ro.product.model'] ?? "", - version: properties['ro.build.version.release'] ?? "", - serialNumber: properties['ro.serialno'] ?? "", - rawInformation: properties, - screenWidth: displayInfo?.widthPixels, - screenHeight: displayInfo?.heightPixels, - ); - } on FormatException catch (e) { - // Handle UTF-8 decoding errors from ADB output - throw Exception( - 'Failed to decode device properties (invalid UTF-8 data): ${e.message}', - ); - } - } - - @override - Future getDeviceBatteryInformation(String deviceId) async { - try { - return await _adbClient.getBatteryInfo(deviceId); - } on FormatException catch (e) { - // Handle UTF-8 decoding errors from ADB output - throw Exception( - 'Failed to decode battery info (invalid UTF-8 data): ${e.message}', - ); - } - } -} diff --git a/lib/features/information/domain/entities/device_information_entity.dart b/lib/features/information/domain/entities/device_information_entity.dart deleted file mode 100644 index 18d292ff..00000000 --- a/lib/features/information/domain/entities/device_information_entity.dart +++ /dev/null @@ -1,20 +0,0 @@ -class DeviceInformationEntity { - final String manufacturer; - final String model; - final String version; - final String serialNumber; - final int? screenWidth; - final int? screenHeight; - - final Map rawInformation; - - DeviceInformationEntity({ - required this.manufacturer, - required this.model, - required this.version, - required this.serialNumber, - required this.rawInformation, - this.screenWidth, - this.screenHeight, - }); -} diff --git a/lib/features/information/domain/repositories/device_information_repository.dart b/lib/features/information/domain/repositories/device_information_repository.dart deleted file mode 100644 index 53d9cb24..00000000 --- a/lib/features/information/domain/repositories/device_information_repository.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/information/domain/entities/device_information_entity.dart'; - -abstract class DeviceInformationRepository { - Future getDeviceInformation(String deviceId); - Future getDeviceBatteryInformation(String deviceId); -} diff --git a/lib/features/information/domain/usecases/get_device_battery_information_usecase.dart b/lib/features/information/domain/usecases/get_device_battery_information_usecase.dart deleted file mode 100644 index 95a43ba3..00000000 --- a/lib/features/information/domain/usecases/get_device_battery_information_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/information/domain/repositories/device_information_repository.dart'; - -class GetDeviceBatteryInformationUsecase { - final DeviceInformationRepository _deviceInformationRepository; - - GetDeviceBatteryInformationUsecase(this._deviceInformationRepository); - - Future call(String deviceId) { - return _deviceInformationRepository.getDeviceBatteryInformation(deviceId); - } -} diff --git a/lib/features/information/domain/usecases/get_device_information_usecase.dart b/lib/features/information/domain/usecases/get_device_information_usecase.dart deleted file mode 100644 index 6cceb738..00000000 --- a/lib/features/information/domain/usecases/get_device_information_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:android_tools/features/information/domain/entities/device_information_entity.dart'; -import 'package:android_tools/features/information/domain/repositories/device_information_repository.dart'; - -class GetDeviceInformationUsecase { - final DeviceInformationRepository _deviceInformationRepository; - - GetDeviceInformationUsecase(this._deviceInformationRepository); - - Future call(String deviceId) { - return _deviceInformationRepository.getDeviceInformation(deviceId); - } -} diff --git a/lib/features/information/presentation/information_bloc.dart b/lib/features/information/presentation/information_bloc.dart deleted file mode 100644 index fcc2a15d..00000000 --- a/lib/features/information/presentation/information_bloc.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/information/domain/entities/device_information_entity.dart'; -import 'package:android_tools/features/information/domain/usecases/get_device_battery_information_usecase.dart'; -import 'package:android_tools/features/information/domain/usecases/get_device_information_usecase.dart'; -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/usecases/listen_selected_device_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/refresh_connected_devices_usecase.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logger/logger.dart'; - -part 'information_event.dart'; -part 'information_state.dart'; -part 'information_bloc.mapper.dart'; - -class InformationBloc extends Bloc { - final Logger _logger = getIt.get(); - final ListenSelectedDeviceUsecase _listenSelectedDeviceUsecase = getIt.get(); - final GetDeviceInformationUsecase _getDeviceInformationUsecase = getIt.get(); - final RefreshConnectedDevicesUsecase _refreshConnectedDevicesUsecase = getIt - .get(); - final GetDeviceBatteryInformationUsecase _getDeviceBatteryInformationUsecase = - getIt.get(); - - InformationBloc() : super(InformationState()) { - on((event, emit) async { - await emit.onEach( - _listenSelectedDeviceUsecase(), - onData: (device) async { - if (device == null) { - _logger.w("Selected device is null, can't get information"); - emit( - state.copyWith( - deviceInformation: null, - device: null, - isLoading: false, - ), - ); - return; - } - - final information = await _getDeviceInformationUsecase( - device.deviceId, - ); - final batteryInformation = await _getDeviceBatteryInformationUsecase( - device.deviceId, - ); - emit( - state.copyWith( - isLoading: false, - deviceInformation: information, - device: device, - deviceBatteryInformation: batteryInformation, - ), - ); - }, - ); - }); - on((event, emit) async { - _logger.i("Refresing connected devices"); - await _refreshConnectedDevicesUsecase(); - }); - } -} diff --git a/lib/features/information/presentation/information_event.dart b/lib/features/information/presentation/information_event.dart deleted file mode 100644 index 3a24349e..00000000 --- a/lib/features/information/presentation/information_event.dart +++ /dev/null @@ -1,7 +0,0 @@ -part of 'information_bloc.dart'; - -sealed class InformationEvent {} - -class OnAppearing extends InformationEvent {} - -class OnRefreshDevices extends InformationEvent {} diff --git a/lib/features/information/presentation/information_screen.dart b/lib/features/information/presentation/information_screen.dart deleted file mode 100644 index df4dc4a4..00000000 --- a/lib/features/information/presentation/information_screen.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:android_tools/features/information/presentation/information_bloc.dart'; -import 'package:android_tools/features/information/presentation/widgets/info_card.dart'; -import 'package:android_tools/features/information/presentation/widgets/info_cards_grid.dart'; -import 'package:android_tools/features/information/presentation/widgets/stat_header.dart'; -import 'package:android_tools/shared/presentation/refresh_device_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class InformationScreen extends StatelessWidget { - final bloc = InformationBloc(); - - InformationScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc..add(OnAppearing()), - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } - - if (state.device == null) { - return Scaffold( - body: Center( - child: RefreshDeviceButton( - onPressed: () => - context.read().add(OnRefreshDevices()), - ), - ), - ); - } - - final info = state.deviceInformation; - final raw = info?.rawInformation ?? {}; - - return Scaffold( - body: Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), - children: [ - StatHeader(state: state), - const SizedBox(height: 24), - InfoCardsGrid( - cards: [ - InfoCard( - title: 'System', - rows: [ - ('Manufacturer', info?.manufacturer ?? 'β€”'), - ('Model', info?.model ?? 'β€”'), - ('Serial', info?.serialNumber ?? 'β€”'), - ('Android', info?.version ?? 'β€”'), - ('Build ID', raw['ro.build.id'] ?? 'β€”'), - ('ABI', raw['ro.product.cpu.abi'] ?? 'β€”'), - if (raw['ro.soc.model'] != null) - ('SoC', raw['ro.soc.model']!), - ( - 'Security patch', - raw['ro.build.version.security_patch'] ?? 'β€”', - ), - if (info?.screenWidth != null && - info?.screenHeight != null) - ( - 'Screen', - '${info!.screenWidth} Γ— ${info.screenHeight}', - ), - ], - ), - if (state.deviceBatteryInformation != null) - InfoCard( - title: 'Battery', - rows: [ - ( - 'Level', - '${state.deviceBatteryInformation!.level}%', - ), - ( - 'Status', - state.deviceBatteryInformation!.isPlugged - ? 'Charging' - : 'Discharging', - ), - ( - 'Temperature', - '${state.deviceBatteryInformation!.temperature.toStringAsFixed(1)} Β°C', - ), - ( - 'Voltage', - '${state.deviceBatteryInformation!.voltage} mV', - ), - ( - 'Health', - state.deviceBatteryInformation!.health.name, - ), - if (state - .deviceBatteryInformation! - .technology != - null) - ( - 'Technology', - state.deviceBatteryInformation!.technology!, - ), - ], - ), - InfoCard( - title: 'Build', - rows: [ - ('Brand', raw['ro.product.brand'] ?? 'β€”'), - ('Device', raw['ro.product.device'] ?? 'β€”'), - ( - 'Fingerprint', - _truncate( - raw['ro.build.fingerprint'] ?? 'β€”', - 32, - ), - ), - ( - 'Incremental', - raw['ro.build.version.incremental'] ?? 'β€”', - ), - ('SDK', raw['ro.build.version.sdk'] ?? 'β€”'), - ('Build type', raw['ro.build.type'] ?? 'β€”'), - ('Tags', raw['ro.build.tags'] ?? 'β€”'), - ], - ), - ], - ), - ], - ), - ), // Expanded - ], - ), // Column - ); - }, - ), - ); - } - - String _truncate(String s, int maxLen) => - s.length > maxLen ? '${s.substring(0, maxLen)}…' : s; -} diff --git a/lib/features/information/presentation/information_state.dart b/lib/features/information/presentation/information_state.dart deleted file mode 100644 index f9c42a0e..00000000 --- a/lib/features/information/presentation/information_state.dart +++ /dev/null @@ -1,16 +0,0 @@ -part of 'information_bloc.dart'; - -@MappableClass() -class InformationState with InformationStateMappable { - final bool isLoading; - final DeviceInformationEntity? deviceInformation; - final BatteryInfo? deviceBatteryInformation; - final DeviceEntity? device; - - InformationState({ - this.isLoading = true, - this.deviceInformation, - this.device, - this.deviceBatteryInformation, - }); -} diff --git a/lib/features/information/presentation/widgets/android_version_card.dart b/lib/features/information/presentation/widgets/android_version_card.dart deleted file mode 100644 index 8ae6ac94..00000000 --- a/lib/features/information/presentation/widgets/android_version_card.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:ui'; - -import 'package:android_tools/features/information/core/android_version_helper.dart'; -import 'package:flutter/material.dart'; - -class AndroidVersionCard extends StatelessWidget { - final String androidVersion; - - const AndroidVersionCard({super.key, required this.androidVersion}); - - @override - Widget build(BuildContext context) { - final logo = getAndroidVersionLogo(androidVersion); - - return Card.filled( - clipBehavior: Clip.antiAlias, - child: Stack( - alignment: Alignment.center, - children: [ - Positioned.fill( - child: ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), - child: logo, - ), - ), - - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 16, - children: [ - SizedBox(width: 128, height: 128, child: logo), - Text( - "Android $androidVersion", - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Colors.white), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/battery_information_widget.dart b/lib/features/information/presentation/widgets/battery_information_widget.dart deleted file mode 100644 index ad70dc53..00000000 --- a/lib/features/information/presentation/widgets/battery_information_widget.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/information/presentation/widgets/bubble_progress.dart'; -import 'package:flutter/material.dart'; - -class BatteryInformationWidget extends StatelessWidget { - final int level; - final bool isPlugged; - final BatteryHealth? health; - final double? temperature; - - const BatteryInformationWidget({ - super.key, - required this.level, - required this.isPlugged, - this.health, - this.temperature, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 200, - height: 200, - child: Card( - color: Color(0xFF1A1D1C), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: BubbleProgress( - percentage: level.toDouble(), - totalBubbles: 48, - size: 9, - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 8, - children: [ - Text( - "$level%", - style: TextStyle(fontSize: 22), - ), - if (isPlugged) - Icon( - Icons.bolt, - size: 18, - color: Colors.amber, - ), - ], - ), - Text( - "Battery", - style: TextStyle(color: Color(0xFF6C696E), fontSize: 18), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/battery_stat_card.dart b/lib/features/information/presentation/widgets/battery_stat_card.dart deleted file mode 100644 index 079bf1d5..00000000 --- a/lib/features/information/presentation/widgets/battery_stat_card.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/shared/presentation/widgets/info_card.dart'; -import 'package:flutter/material.dart'; - -class BatteryStatCard extends StatelessWidget { - final BatteryInfo battery; - - const BatteryStatCard({super.key, required this.battery}); - - @override - Widget build(BuildContext context) { - final level = battery.level / 100.0; - final dim = Theme.of(context).colorScheme.surfaceContainerHighest; - final color = level < 0.2 - ? Colors.red - : level < 0.5 - ? Colors.orange - : const Color(0xFF4CAF50); - - final statusText = battery.isPlugged ? 'Charging' : 'Discharging'; - final tempText = '${battery.temperature.toStringAsFixed(1)} Β°C'; - - return InfoCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 10, - children: [ - Row( - spacing: 8, - children: [ - Icon( - battery.isPlugged - ? Icons.battery_charging_full_rounded - : Icons.battery_std_rounded, - size: 16, - color: dim, - ), - Text( - 'Battery', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: dim, - ), - ), - ], - ), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 6, - children: [ - Text( - '${battery.level}', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w700, - height: 1, - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Text( - '%', - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), - ), - ), - ], - ), - Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - value: battery.isPlugged ? null : level, - minHeight: 4, - color: color, - backgroundColor: color.withValues(alpha: 0.15), - ), - ), - ), - ], - ), - Text( - '$statusText Β· $tempText', - style: TextStyle(fontSize: 11, color: dim), - ), - ], - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/bubble_progress.dart b/lib/features/information/presentation/widgets/bubble_progress.dart deleted file mode 100644 index 466f346f..00000000 --- a/lib/features/information/presentation/widgets/bubble_progress.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; - -class BubbleProgress extends StatelessWidget { - final double percentage; // 0 Γ  100 - final int totalBubbles; - final double size; - final double spacing; - - const BubbleProgress({ - super.key, - required this.percentage, - this.totalBubbles = 30, - this.size = 16, - this.spacing = 1, - }); - - @override - Widget build(BuildContext context) { - final filledBubbles = (percentage.clamp(0, 100) / 100 * totalBubbles) - .round(); - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: List.generate(totalBubbles, (index) { - final isFilled = index < filledBubbles; - - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: isFilled ? Theme.of(context).primaryColor : Colors.white, - shape: BoxShape.circle, - ), - ); - }), - ); - } -} diff --git a/lib/features/information/presentation/widgets/connected_badge.dart b/lib/features/information/presentation/widgets/connected_badge.dart deleted file mode 100644 index c4a0d324..00000000 --- a/lib/features/information/presentation/widgets/connected_badge.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; - -class ConnectedBadge extends StatelessWidget { - const ConnectedBadge({super.key}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFF4CAF50).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: const Color(0xFF4CAF50).withValues(alpha: 0.4), - ), - ), - child: Row( - spacing: 5, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xFF4CAF50), - ), - ), - const Text( - 'Connected', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Color(0xFF4CAF50), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/device_stat_card.dart b/lib/features/information/presentation/widgets/device_stat_card.dart deleted file mode 100644 index d7a25049..00000000 --- a/lib/features/information/presentation/widgets/device_stat_card.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:android_tools/features/information/core/android_version_helper.dart'; -import 'package:android_tools/features/information/presentation/widgets/connected_badge.dart'; -import 'package:android_tools/shared/presentation/widgets/info_badge.dart'; -import 'package:android_tools/shared/presentation/widgets/info_card.dart'; -import 'package:flutter/material.dart'; - -/// Full-width card displaying the device name, connection status, -/// manufacturer, metadata, and optional actions (e.g., screenshot). -class DeviceStatCard extends StatelessWidget { - final String deviceName; - final String manufacturer; - final String serial; - final String androidVersion; - - /// Buttons displayed to the right of the title (after the Connected badge). - final List actions; - - const DeviceStatCard({ - super.key, - required this.deviceName, - required this.manufacturer, - required this.serial, - required this.androidVersion, - this.actions = const [], - }); - - @override - Widget build(BuildContext context) { - final dim = Theme.of(context).colorScheme.surfaceContainerHighest; - final primary = Theme.of(context).colorScheme.primary; - - return InfoCard( - child: Row( - spacing: 16, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: dim.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.all(2), - child: androidVersion.isNotEmpty - ? getAndroidVersionLogo(androidVersion) - : Icon(Icons.phone_android, color: primary, size: 24), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - Row( - spacing: 8, - children: [ - Expanded( - child: Text( - deviceName, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ConnectedBadge(), - if (actions.isNotEmpty) ...actions, - ], - ), - if (manufacturer.isNotEmpty) - Text( - manufacturer, - style: TextStyle(fontSize: 12, color: dim), - ), - Row( - spacing: 12, - children: [ - if (serial.isNotEmpty) - InfoBadge( - label: serial, - icon: Icons.tag, - iconSize: 11, - color: dim, - ), - if (androidVersion.isNotEmpty) - InfoBadge( - label: 'Android $androidVersion', - icon: Icons.android, - iconSize: 11, - color: dim, - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/display_information_widget.dart b/lib/features/information/presentation/widgets/display_information_widget.dart deleted file mode 100644 index bba55be1..00000000 --- a/lib/features/information/presentation/widgets/display_information_widget.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; - -class DisplayInformationWidget extends StatelessWidget { - final int width; - final int height; - final int density; - - const DisplayInformationWidget({ - super.key, - required this.width, - required this.height, - required this.density, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 200, - height: 200, - child: Card( - color: Color(0xFF1A1D1C), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Center( - child: Icon( - Icons.smartphone, - size: 64, - color: Colors.white.withAlpha(100), - ), - ), - ), - Text( - "${width}x$height", - style: TextStyle(fontSize: 22), - ), - Row( - spacing: 8, - children: [ - Text( - "Display", - style: TextStyle(color: Color(0xFF6C696E), fontSize: 18), - ), - Text( - "${density}dpi", - style: TextStyle(color: Color(0xFF6C696E), fontSize: 14), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/dynamic_device_preview.dart b/lib/features/information/presentation/widgets/dynamic_device_preview.dart deleted file mode 100644 index 4affae77..00000000 --- a/lib/features/information/presentation/widgets/dynamic_device_preview.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:android_tools/features/information/presentation/widgets/side_button.dart'; -import 'package:flutter/material.dart'; - -class DynamicDevicePreview extends StatelessWidget { - final int screenWidth; - final int screenHeight; - final Color? screenColor; - final Widget? child; - - const DynamicDevicePreview({ - super.key, - required this.screenWidth, - required this.screenHeight, - this.screenColor, - this.child, - }); - - @override - Widget build(BuildContext context) { - const double frameHeight = 380; - const double frameThicknessH = 4.0; - const double frameThicknessTop = 4.0; - const double frameThicknessBottom = 4.0; - const double outerRadius = 32.0; - const double innerRadius = 26.0; - const double shadowPadding = 72.0; - - final double aspectRatio = screenWidth / screenHeight; - final double innerHeight = - frameHeight - frameThicknessTop - frameThicknessBottom; - final double innerWidth = innerHeight * aspectRatio; - final double frameWidth = innerWidth + frameThicknessH * 2; - - return Padding( - padding: const EdgeInsets.only(bottom: shadowPadding), - child: SizedBox( - height: frameHeight, - width: frameWidth, - child: Stack( - clipBehavior: Clip.none, - children: [ - // Phone body - Container( - width: frameWidth, - height: frameHeight, - decoration: BoxDecoration( - color: const Color(0xFFCACFDA), - borderRadius: BorderRadius.circular(outerRadius), - border: Border.all(color: const Color(0xFF3A3A3C), width: 1.5), - boxShadow: [ - BoxShadow( - color: Colors.white.withAlpha(25), - blurRadius: 64, - offset: const Offset(0, 10), - ), - BoxShadow( - color: Colors.black.withAlpha(120), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], - ), - ), - - // Screen area - Positioned( - left: frameThicknessH, - right: frameThicknessH, - top: frameThicknessTop, - bottom: frameThicknessBottom, - child: Container( - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(innerRadius), - border: Border.all(color: Colors.black, width: 3.0), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(innerRadius - 3.0), - child: Stack( - children: [ - // Screen content background - Positioned.fill( - child: Container( - color: screenColor ?? const Color(0xFF0A0A0A), - ), - ), - - // Child widget or resolution label - if (child != null) - Center(child: child) - else - Center( - child: Text( - '$screenWidthΓ—$screenHeight', - style: const TextStyle( - color: Color(0xFF3A3A3C), - fontSize: 11, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ), - - // Camera punch hole - Positioned( - top: 10, - left: 0, - right: 0, - child: Center( - child: Container( - width: 11, - height: 11, - decoration: const BoxDecoration( - color: Color(0xFF010102), - shape: BoxShape.circle, - ), - child: Center( - child: Container( - width: 5, - height: 5, - decoration: const BoxDecoration( - color: Color(0xFF0E0E0E), - shape: BoxShape.circle, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - - // Volume buttons (left) - Positioned( - left: -2, - top: 80, - child: Column( - spacing: 10, - children: [SideButton(height: 28), SideButton(height: 28)], - ), - ), - - // Power button (right) - Positioned(right: -2, top: 100, child: SideButton(height: 46)), - ], - ), - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/info_card.dart b/lib/features/information/presentation/widgets/info_card.dart deleted file mode 100644 index 58b075ee..00000000 --- a/lib/features/information/presentation/widgets/info_card.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Detail card with a header title and key/value rows. -class InfoCard extends StatelessWidget { - final String title; - final List<(String, String)> rows; - - const InfoCard({super.key, required this.title, required this.rows}); - - @override - Widget build(BuildContext context) { - final dim = Theme.of(context).colorScheme.surfaceContainerHighest; - - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: dim.withValues(alpha: 0.15)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), - child: Text( - title.toUpperCase(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.8, - color: dim, - ), - ), - ), - Divider(height: 1, color: dim.withValues(alpha: 0.15)), - ...rows.map( - (row) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - child: Row( - children: [ - Expanded( - flex: 2, - child: Text( - row.$1, - style: TextStyle(fontSize: 12, color: dim), - ), - ), - Expanded( - flex: 3, - child: Text( - row.$2, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 4), - ], - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/info_cards_grid.dart b/lib/features/information/presentation/widgets/info_cards_grid.dart deleted file mode 100644 index 2a70ca48..00000000 --- a/lib/features/information/presentation/widgets/info_cards_grid.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Responsive grid of detail cards. -/// All cards fit on the same line if space permits, -/// otherwise they wrap to the next line. Minimum width per card: 240 px. -class InfoCardsGrid extends StatelessWidget { - final List cards; - - static const double _minCardWidth = 240; - static const double _spacing = 16; - - const InfoCardsGrid({super.key, required this.cards}); - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final available = constraints.maxWidth; - final n = cards.length; - - int perRow = 1; - for (int k = n; k >= 1; k--) { - if (k * _minCardWidth + (k - 1) * _spacing <= available) { - perRow = k; - break; - } - } - - final cardWidth = (available - (perRow - 1) * _spacing) / perRow; - - return Wrap( - spacing: _spacing, - runSpacing: _spacing, - children: cards - .map((c) => SizedBox(width: cardWidth, child: c)) - .toList(), - ); - }, - ); - } -} diff --git a/lib/features/information/presentation/widgets/information_recap_item.dart b/lib/features/information/presentation/widgets/information_recap_item.dart deleted file mode 100644 index 6b9046db..00000000 --- a/lib/features/information/presentation/widgets/information_recap_item.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -class InformationRecapItem extends StatelessWidget { - final String label; - final String value; - - const InformationRecapItem({ - super.key, - required this.label, - required this.value, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(180), - ), - ), - Text(value, style: Theme.of(context).textTheme.titleSmall), - ], - ); - } -} diff --git a/lib/features/information/presentation/widgets/network_information_widget.dart b/lib/features/information/presentation/widgets/network_information_widget.dart deleted file mode 100644 index 934b2149..00000000 --- a/lib/features/information/presentation/widgets/network_information_widget.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; - -class NetworkInformationWidget extends StatelessWidget { - final String? wifiSsid; - final String? ipAddress; - - const NetworkInformationWidget({ - super.key, - this.wifiSsid, - this.ipAddress, - }); - - @override - Widget build(BuildContext context) { - final hasWifi = wifiSsid != null && wifiSsid!.isNotEmpty; - - return SizedBox( - width: 200, - height: 200, - child: Card( - color: Color(0xFF1A1D1C), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Center( - child: Icon( - hasWifi ? Icons.wifi : Icons.wifi_off, - size: 64, - color: hasWifi - ? Colors.white.withAlpha(100) - : Colors.white.withAlpha(50), - ), - ), - ), - Text( - wifiSsid ?? "Not connected", - style: TextStyle(fontSize: 18), - overflow: TextOverflow.ellipsis, - ), - if (ipAddress != null && ipAddress!.isNotEmpty) - Text( - ipAddress!, - style: TextStyle(color: Color(0xFF6C696E), fontSize: 14), - ), - Text( - "Network", - style: TextStyle(color: Color(0xFF6C696E), fontSize: 18), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/side_button.dart b/lib/features/information/presentation/widgets/side_button.dart deleted file mode 100644 index c3a91395..00000000 --- a/lib/features/information/presentation/widgets/side_button.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; - -class SideButton extends StatelessWidget { - final double height; - - const SideButton({super.key, required this.height}); - - @override - Widget build(BuildContext context) { - return Container( - width: 4, - height: height, - decoration: BoxDecoration( - color: const Color(0xFF2C2C2E), - borderRadius: BorderRadius.circular(2), - ), - ); - } -} diff --git a/lib/features/information/presentation/widgets/stat_header.dart b/lib/features/information/presentation/widgets/stat_header.dart deleted file mode 100644 index 04256aaf..00000000 --- a/lib/features/information/presentation/widgets/stat_header.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:android_tools/features/information/presentation/information_bloc.dart'; -import 'package:android_tools/features/information/presentation/widgets/battery_stat_card.dart'; -import 'package:android_tools/features/information/presentation/widgets/device_stat_card.dart'; -import 'package:android_tools/features/screenshot/presentation/widgets/screenshot_button.dart'; -import 'package:flutter/material.dart'; - -/// Header of the device info screen: Full-width DeviceStatCard with integrated -/// screenshot button, followed by secondary cards (battery, etc.) below. -/// -/// Note: Do not wrap this widget in MoveWindow β€” it lives inside a ListView -/// that provides unbounded height, incompatible with MoveWindow's internal -/// Column (mainAxisSize: max). -class StatHeader extends StatelessWidget { - final InformationState state; - - const StatHeader({super.key, required this.state}); - - @override - Widget build(BuildContext context) { - final info = state.deviceInformation; - final battery = state.deviceBatteryInformation; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 12, - children: [ - SizedBox( - width: double.infinity, - child: DeviceStatCard( - deviceName: info?.model ?? state.device?.name ?? 'Unknown', - manufacturer: info?.manufacturer ?? '', - serial: info?.serialNumber ?? '', - androidVersion: info?.version ?? '', - actions: [ScreenshotButton(device: state.device)], - ), - ), - if (battery != null) - Row( - spacing: 12, - children: [ - SizedBox(width: 200, child: BatteryStatCard(battery: battery)), - ], - ), - ], - ); - } -} diff --git a/lib/features/information/presentation/widgets/storage_information_widget.dart b/lib/features/information/presentation/widgets/storage_information_widget.dart deleted file mode 100644 index 23f11870..00000000 --- a/lib/features/information/presentation/widgets/storage_information_widget.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:android_tools/features/file_explorer/core/int_extensions.dart'; -import 'package:android_tools/features/information/presentation/widgets/bubble_progress.dart'; -import 'package:flutter/material.dart'; - -class StorageInformationWidget extends StatelessWidget { - final int totalBytes; - final int freeBytes; - - const StorageInformationWidget({ - super.key, - required this.totalBytes, - required this.freeBytes, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 200, - height: 200, - child: Card( - color: Color(0xFF1A1D1C), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: BubbleProgress( - percentage: (freeBytes) * 100 / totalBytes, - totalBubbles: 48, - size: 9, - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 8, - children: [ - Text( - freeBytes.toReadableBytes(), - style: TextStyle(fontSize: 22), - ), - Text("left"), - ], - ), - Text( - "Storage", - style: TextStyle(color: Color(0xFF6C696E), fontSize: 18), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/logcat/core/logcat_module.dart b/lib/features/logcat/core/logcat_module.dart deleted file mode 100644 index 8bbe37a5..00000000 --- a/lib/features/logcat/core/logcat_module.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:android_tools/features/logcat/data/repositories/logcat_repository_impl.dart'; -import 'package:android_tools/features/logcat/domain/repositories/logcat_repository.dart'; -import 'package:android_tools/features/logcat/domain/usecases/clear_logcat_usecase.dart'; -import 'package:android_tools/features/logcat/domain/usecases/get_processes_usecase.dart'; -import 'package:android_tools/features/logcat/domain/usecases/listen_logcat_usecase.dart'; -import 'package:android_tools/main.dart'; - -class LogcatModule { - static void configureDependencies() { - _registerRepositories(); - _registerUseCases(); - } - - static void _registerRepositories() { - getIt.registerLazySingleton( - () => LogcatRepositoryImpl(getIt.get(), getIt.get()), - ); - } - - static void _registerUseCases() { - getIt.registerLazySingleton( - () => ListenLogcatUsecase(logcatRepository: getIt.get()), - ); - getIt.registerLazySingleton(() => GetProcessesUsecase(getIt.get())); - getIt.registerLazySingleton( - () => ClearLogcatUsecase(logcatRepository: getIt.get()), - ); - } -} diff --git a/lib/features/logcat/data/repositories/logcat_repository_impl.dart b/lib/features/logcat/data/repositories/logcat_repository_impl.dart deleted file mode 100644 index b330fa95..00000000 --- a/lib/features/logcat/data/repositories/logcat_repository_impl.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:async'; - -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/logcat/domain/entities/process_entity.dart'; -import 'package:android_tools/features/logcat/domain/repositories/logcat_repository.dart'; -import 'package:logger/logger.dart'; - -class LogcatRepositoryImpl implements LogcatRepository { - final Logger _logger; - final AdbClient _adbClient; - - LogcatRepositoryImpl(this._logger, this._adbClient); - - @override - Stream> listenLogcat( - String deviceId, - LogcatLevel? level, - int? processId, - ) { - return _adbClient.listenLogcat(deviceId, level: level, processId: processId); - } - - @override - Future clearLogcat(String deviceId) async { - _adbClient.clearLogcat(deviceId); - } - - @override - Future> getProcesses(String deviceId) async { - try { - final processes = await _adbClient.getProcesses(deviceId); - - return processes - .where((p) => !_shouldIgnoreProcess(p.packageName)) - .map((p) => ProcessEntity( - processId: p.processId, - packageName: p.packageName, - )) - .toList(); - } catch (e) { - _logger.w('Exception getting processes: $e'); - return []; - } - } - - bool _shouldIgnoreProcess(String name) { - // Kernel threads - if (name.startsWith('[') && name.endsWith(']')) return true; - - // HAL / vendor processes - if (name.startsWith('vendor.')) return true; - if (name.startsWith('android.hardware.')) return true; - - // Known native binaries that are not apps - const ignoreList = [ - 'init', - 'logd', - 'adbd', - 'ps', - 'wpa_supplicant', - 'sh', - 'ip6tables-restore', - 'iptables-restore', - 'thermal', - 'rild', - 'artd', - 'pageboostd', - ]; - if (ignoreList.contains(name)) return true; - - // Non-app processes have no dot in their name (e.g., kworker, netd) - if (!name.contains('.')) return true; - - return false; - } -} diff --git a/lib/features/logcat/domain/entities/logcat_line_entity.dart b/lib/features/logcat/domain/entities/logcat_line_entity.dart deleted file mode 100644 index 0b640444..00000000 --- a/lib/features/logcat/domain/entities/logcat_line_entity.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; - -class LogcatLineEntity { - final DateTime dateTime; - final LogcatLevel level; - - final String tag; - final String message; - - final int processId; - final int threadId; - - const LogcatLineEntity({ - required this.dateTime, - required this.level, - required this.processId, - required this.threadId, - required this.tag, - required this.message, - }); -} diff --git a/lib/features/logcat/domain/entities/process_entity.dart b/lib/features/logcat/domain/entities/process_entity.dart deleted file mode 100644 index 70ce76a5..00000000 --- a/lib/features/logcat/domain/entities/process_entity.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:equatable/equatable.dart'; - -class ProcessEntity extends Equatable { - final int processId; - final String packageName; - - const ProcessEntity({required this.processId, required this.packageName}); - - @override - List get props => [packageName, processId]; -} diff --git a/lib/features/logcat/domain/repositories/logcat_repository.dart b/lib/features/logcat/domain/repositories/logcat_repository.dart deleted file mode 100644 index d806b62d..00000000 --- a/lib/features/logcat/domain/repositories/logcat_repository.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/logcat/domain/entities/process_entity.dart'; - -abstract class LogcatRepository { - Stream> listenLogcat( - String deviceId, - LogcatLevel? level, - int? processId, - ); - Future clearLogcat(String deviceId); - Future> getProcesses(String deviceId); -} diff --git a/lib/features/logcat/domain/usecases/clear_logcat_usecase.dart b/lib/features/logcat/domain/usecases/clear_logcat_usecase.dart deleted file mode 100644 index 97c1465d..00000000 --- a/lib/features/logcat/domain/usecases/clear_logcat_usecase.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:android_tools/features/logcat/domain/repositories/logcat_repository.dart'; - -class ClearLogcatUsecase { - final LogcatRepository logcatRepository; - const ClearLogcatUsecase({required this.logcatRepository}); - - Future call(String deviceId) { - return logcatRepository.clearLogcat(deviceId); - } -} diff --git a/lib/features/logcat/domain/usecases/get_processes_usecase.dart b/lib/features/logcat/domain/usecases/get_processes_usecase.dart deleted file mode 100644 index a259611d..00000000 --- a/lib/features/logcat/domain/usecases/get_processes_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:android_tools/features/logcat/domain/entities/process_entity.dart'; -import 'package:android_tools/features/logcat/domain/repositories/logcat_repository.dart'; - -class GetProcessesUsecase { - final LogcatRepository logcatRepository; - - GetProcessesUsecase(this.logcatRepository); - - Future> call(String deviceId) { - return logcatRepository.getProcesses(deviceId); - } -} diff --git a/lib/features/logcat/domain/usecases/listen_logcat_usecase.dart b/lib/features/logcat/domain/usecases/listen_logcat_usecase.dart deleted file mode 100644 index 49156205..00000000 --- a/lib/features/logcat/domain/usecases/listen_logcat_usecase.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/logcat/domain/repositories/logcat_repository.dart'; - -class ListenLogcatUsecase { - final LogcatRepository logcatRepository; - const ListenLogcatUsecase({required this.logcatRepository}); - - Stream> call( - String deviceId, - LogcatLevel? level, - int? processId, - ) { - return logcatRepository.listenLogcat(deviceId, level, processId); - } -} diff --git a/lib/features/logcat/presentation/core/logcat_colors.dart b/lib/features/logcat/presentation/core/logcat_colors.dart deleted file mode 100644 index c769352b..00000000 --- a/lib/features/logcat/presentation/core/logcat_colors.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -class LogcatColors { - static final Color verboseBackgroundColor = Color.fromARGB( - 255, - 187, - 187, - 187, - ); - static final Color verboseTextColor = Color.fromARGB(255, 102, 102, 102); - static final Color verboseOnBackgroundColor = Color.fromARGB(255, 34, 34, 34); - - static final Color debugBackgroundColor = Color(0xFF305D78); - static final Color debugOnBackgroundTextColor = Color(0xFFBBBBBB); - static final Color debugTextColor = Color(0xFF299999); - - static final Color infoBackgroundColor = Color(0xFF6A8759); - static final Color infoTextColor = Color(0xFFABC023); - static final Color infoOnBackgroundColor = Color(0xFFE9F5E6); - - static final Color errorTextColor = Color(0xFFFD6A67); - static final Color errorBackgroundColor = Color(0xFFCF5B56); - static final Color errorOnBackgroundColor = Color(0xFF000000); - - static final Color warningTextColor = Color(0xFFBBB529); - static final Color warningBackgroundColor = Color(0xFFBBB529); - static final Color warningOnBackgroundColor = Colors.black; -} diff --git a/lib/features/logcat/presentation/core/logcat_level_extensions.dart b/lib/features/logcat/presentation/core/logcat_level_extensions.dart deleted file mode 100644 index fc01126b..00000000 --- a/lib/features/logcat/presentation/core/logcat_level_extensions.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/logcat/presentation/core/logcat_colors.dart'; -import 'package:flutter/material.dart'; - -extension LogcatLevelExtensions on LogcatLevel { - Color backgroundColor() { - return switch (this) { - LogcatLevel.verbose => LogcatColors.verboseBackgroundColor, - LogcatLevel.debug => LogcatColors.debugBackgroundColor, - LogcatLevel.info => LogcatColors.infoBackgroundColor, - LogcatLevel.warning => LogcatColors.warningBackgroundColor, - LogcatLevel.error => LogcatColors.errorBackgroundColor, - LogcatLevel.fatal => LogcatColors.errorBackgroundColor, - }; - } - - IconData icon() { - return switch (this) { - LogcatLevel.verbose => Icons.filter_none, - LogcatLevel.debug => Icons.bug_report, - LogcatLevel.info => Icons.info, - LogcatLevel.warning => Icons.warning, - LogcatLevel.error => Icons.cancel, - LogcatLevel.fatal => Icons.cancel, - }; - } - - Color onBackgroundColor() { - return switch (this) { - LogcatLevel.verbose => LogcatColors.verboseOnBackgroundColor, - LogcatLevel.debug => LogcatColors.debugOnBackgroundTextColor, - LogcatLevel.info => LogcatColors.infoOnBackgroundColor, - LogcatLevel.warning => LogcatColors.warningOnBackgroundColor, - LogcatLevel.error => LogcatColors.errorOnBackgroundColor, - LogcatLevel.fatal => LogcatColors.errorOnBackgroundColor, - }; - } - - Color textColor() { - return switch (this) { - LogcatLevel.verbose => LogcatColors.verboseTextColor, - LogcatLevel.debug => LogcatColors.debugTextColor, - LogcatLevel.info => LogcatColors.infoTextColor, - LogcatLevel.warning => LogcatColors.warningTextColor, - LogcatLevel.error => LogcatColors.errorTextColor, - LogcatLevel.fatal => LogcatColors.errorTextColor, - }; - } -} diff --git a/lib/features/logcat/presentation/logcat_bloc.dart b/lib/features/logcat/presentation/logcat_bloc.dart deleted file mode 100644 index cb302e83..00000000 --- a/lib/features/logcat/presentation/logcat_bloc.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:async'; - -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/logcat/domain/entities/process_entity.dart'; -import 'package:android_tools/features/logcat/domain/usecases/get_processes_usecase.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/features/logcat/domain/usecases/clear_logcat_usecase.dart'; -import 'package:android_tools/features/logcat/domain/usecases/listen_logcat_usecase.dart'; -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/domain/usecases/listen_selected_device_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/refresh_connected_devices_usecase.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logger/logger.dart'; -import 'package:collection/collection.dart'; - -part 'logcat_event.dart'; -part 'logcat_state.dart'; -part 'logcat_bloc.mapper.dart'; - -class LogcatBloc extends Bloc { - final ListenLogcatUsecase _listenLogcatUsecase = getIt.get(); - final ClearLogcatUsecase _clearLogcatUsecase = getIt.get(); - final GetProcessesUsecase _getProcessesUsecase = getIt.get(); - final RefreshConnectedDevicesUsecase _refreshConnectedDevicesUsecase = getIt - .get(); - final Logger _logger = getIt.get(); - final ListenSelectedDeviceUsecase _listenSelectedDeviceUsecase = getIt.get(); - - StreamSubscription>? _logcatSubscription; - - LogcatBloc() : super(LogcatState()) { - on((event, emit) async { - await emit.onEach( - _listenSelectedDeviceUsecase(), - onData: (device) async { - emit(state.copyWith(selectedProcess: null, logs: [])); - - if (device == null) { - _logger.i("Selected device is null, can't get processes"); - _logcatSubscription?.cancel(); - emit(state.copyWith(selectedDevice: null, processes: [])); - return; - } - - _logger.i("Received new device : $device, get processes"); - final processes = await _getProcessesUsecase(device.deviceId); - _logger.i("Found ${processes.length} processes for device $device"); - emit(state.copyWith(selectedDevice: device, processes: processes)); - await _listenLogcat(); - }, - ); - }); - on((event, emit) { - _logger.d("Adding ${event.lines.length} new lines to logcat lines"); - final updated = List.from(state.logs)..addAll(event.lines); - _logger.d("Size of logcat lines : ${updated.length}"); - emit(state.copyWith(logs: updated, isLoading: false)); - }); - - on((event, emit) async { - final selectedDevice = state.selectedDevice; - if (selectedDevice == null) { - return _logger.w("Can't clear logcat, no device selected"); - } - - _logger.i("Start cleaning logcat"); - emit(state.copyWith(logs: [])); - await _clearLogcatUsecase(selectedDevice.deviceId); - _logger.i("Logcat cleaned"); - }); - - on((event, emit) { - _logger.i("isSticky changed to ${event.isSticky}"); - emit(state.copyWith(isSticky: event.isSticky)); - }); - - on((event, emit) async { - _logger.i("Minimum logcat level changed for ${event.minimumLogLevel}"); - emit(state.copyWith(minimumLogLevel: event.minimumLogLevel, logs: [])); - await _listenLogcat(); - }); - - on((event, emit) { - _logger.i("Pausing logcat"); - emit(state.copyWith(isPaused: true)); - _logcatSubscription?.pause(); - }); - on((event, emit) { - _logger.i("Resuming logcat"); - emit(state.copyWith(isPaused: false)); - _logcatSubscription?.resume(); - }); - on((event, emit) async { - _logger.i("Refreshing logcat"); - await _refreshConnectedDevicesUsecase(); - final selectedDevice = state.selectedDevice; - if (selectedDevice == null) { - _logger.w("Can't refresh logcat, no device selected"); - return; - } - - final processes = await _getProcessesUsecase(selectedDevice.deviceId); - final oldProcess = state.selectedProcess; - // ProcessId can change so we map to update processId - final selectedProcess = oldProcess == null - ? null - : processes.firstWhereOrNull( - (p) => p.packageName == oldProcess.packageName, - ); - emit( - state.copyWith( - processes: processes, - selectedProcess: selectedProcess, - logs: [], - ), - ); - - await _listenLogcat(); - }); - on((event, emit) async { - _logger.i("Refreshing devices"); - await _refreshConnectedDevicesUsecase(); - }); - on((event, emit) async { - _logger.i("Process selected : ${event.process}"); - emit(state.copyWith(logs: [], selectedProcess: event.process)); - await _listenLogcat(); - }); - on((event, emit) { - _logger.i( - "OnIsShowProcessThreadIds called with value: ${event.isShowProcessThreadIds}", - ); - emit( - state.copyWith(isShowProcessThreadIds: event.isShowProcessThreadIds), - ); - }); - } - Future _listenLogcat() async { - if (state.selectedDevice == null) { - _logger.w("Can't listen for logcat (No device selcted)"); - return; - } - - await _logcatSubscription?.cancel(); - final stream = _listenLogcatUsecase( - state.selectedDevice!.deviceId, - state.minimumLogLevel, - state.selectedProcess?.processId, - ); - _logcatSubscription = stream.listen((lines) { - add(OnLogReceived(lines: lines)); - }); - } - - @override - Future close() async { - _logger.i("Closing logcat bloc"); - await _logcatSubscription?.cancel(); - return super.close(); - } -} diff --git a/lib/features/logcat/presentation/logcat_event.dart b/lib/features/logcat/presentation/logcat_event.dart deleted file mode 100644 index 61a527cc..00000000 --- a/lib/features/logcat/presentation/logcat_event.dart +++ /dev/null @@ -1,50 +0,0 @@ -part of 'logcat_bloc.dart'; - -sealed class LogcatEvent {} - -class OnStartListeningLogcat extends LogcatEvent {} - -class OnClearLogcat extends LogcatEvent {} - -class OnToggleIsSticky extends LogcatEvent { - final bool isSticky; - - OnToggleIsSticky({required this.isSticky}); -} - -class OnMinimumLogLevelChanged extends LogcatEvent { - final LogcatLevel? minimumLogLevel; - - OnMinimumLogLevelChanged({required this.minimumLogLevel}); -} - -class OnPauseLogcat extends LogcatEvent {} - -class OnResumeLogcat extends LogcatEvent {} - -class OnRefreshLogcat extends LogcatEvent {} - -class OnLogReceived extends LogcatEvent { - final Iterable lines; - OnLogReceived({required this.lines}); -} - -class OnLogcatMaxLinesChanged extends LogcatEvent { - final int maxLines; - OnLogcatMaxLinesChanged({required this.maxLines}); -} - -class OnRefreshDevices extends LogcatEvent { - OnRefreshDevices(); -} - -class OnProcessSelected extends LogcatEvent { - final ProcessEntity? process; - OnProcessSelected({required this.process}); -} - -class OnIsShowProcessThreadIdsChanged extends LogcatEvent { - final bool isShowProcessThreadIds; - - OnIsShowProcessThreadIdsChanged({required this.isShowProcessThreadIds}); -} diff --git a/lib/features/logcat/presentation/logcat_screen.dart b/lib/features/logcat/presentation/logcat_screen.dart deleted file mode 100644 index 6cc1b895..00000000 --- a/lib/features/logcat/presentation/logcat_screen.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:android_tools/features/logcat/presentation/widgets/logcat_appbar.dart'; -import 'package:android_tools/features/logcat/presentation/logcat_bloc.dart'; -import 'package:android_tools/features/logcat/presentation/widgets/logcat_filter_drawer.dart'; -import 'package:android_tools/features/logcat/presentation/widgets/logcat_line.dart'; -import 'package:android_tools/shared/presentation/refresh_device_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class LogcatScreen extends StatefulWidget { - const LogcatScreen({super.key}); - - @override - State createState() => _LogcatScreenState(); -} - -class _LogcatScreenState extends State { - final bloc = LogcatBloc(); - final ScrollController _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - bloc.add(OnStartListeningLogcat()); - } - - @override - void dispose() { - _scrollController.dispose(); - bloc.close(); - super.dispose(); - } - - void _scrollToBottom() { - if (_scrollController.hasClients) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - }); - } - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, - child: Scaffold( - floatingActionButton: BlocBuilder( - builder: (context, state) { - return FloatingActionButton( - backgroundColor: state.isSticky - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.surface, - foregroundColor: state.isSticky - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.primary, - onPressed: () { - context.read().add( - OnToggleIsSticky(isSticky: !state.isSticky), - ); - }, - child: Icon(Icons.arrow_downward_rounded), - ); - }, - ), - endDrawer: LogcatFilterDrawer(), - appBar: LogcatAppbar(), - body: BlocListener( - listener: (context, state) { - if (state.isSticky) { - _scrollToBottom(); - } - }, - child: BlocBuilder( - builder: (context, state) { - return state.isLoading - ? Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.onSurface, - ), - ) - : state.selectedDevice == null - ? Center( - child: RefreshDeviceButton( - onPressed: () => - context.read().add(OnRefreshDevices()), - ), - ) - : NotificationListener( - onNotification: (notification) { - if (notification.direction == ScrollDirection.reverse && - state.isSticky) { - bloc.add(OnToggleIsSticky(isSticky: false)); - } - return false; - }, - child: ListView.builder( - prototypeItem: state.logs.isEmpty - ? null - : LogcatLine( - line: state.logs.last, - isShowProcessThreadIds: - state.isShowProcessThreadIds, - ), - reverse: true, - padding: EdgeInsets.all(8), - controller: _scrollController, - itemCount: state.logs.length, - itemBuilder: (context, index) { - final line = state.logs.reversed.elementAt(index); - return LogcatLine( - line: line, - isShowProcessThreadIds: - state.isShowProcessThreadIds, - ); - }, - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/features/logcat/presentation/logcat_state.dart b/lib/features/logcat/presentation/logcat_state.dart deleted file mode 100644 index 1772dd84..00000000 --- a/lib/features/logcat/presentation/logcat_state.dart +++ /dev/null @@ -1,27 +0,0 @@ -part of 'logcat_bloc.dart'; - -@MappableClass() -class LogcatState with LogcatStateMappable { - final List logs; - final bool isLoading; - - final bool isSticky; - final bool isPaused; - final bool isShowProcessThreadIds; - final LogcatLevel minimumLogLevel; - final DeviceEntity? selectedDevice; - final List processes; - final ProcessEntity? selectedProcess; - - LogcatState({ - this.logs = const [], - this.isLoading = true, - this.isSticky = true, - this.isPaused = false, - this.minimumLogLevel = LogcatLevel.debug, - this.selectedDevice, - this.processes = const [], - this.selectedProcess, - this.isShowProcessThreadIds = false, - }); -} diff --git a/lib/features/logcat/presentation/widgets/logcat_appbar.dart b/lib/features/logcat/presentation/widgets/logcat_appbar.dart deleted file mode 100644 index cb564a15..00000000 --- a/lib/features/logcat/presentation/widgets/logcat_appbar.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/logcat/presentation/logcat_bloc.dart'; -import 'package:android_tools/features/logcat/presentation/widgets/logcat_button.dart'; -import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class LogcatAppbar extends StatefulWidget implements PreferredSizeWidget { - const LogcatAppbar({super.key}); - - @override - State createState() => _LogcatAppbarState(); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} - -class _LogcatAppbarState extends State { - static const _levels = [ - LogcatLevel.verbose, - LogcatLevel.debug, - LogcatLevel.info, - LogcatLevel.warning, - LogcatLevel.error, - ]; - - static const _levelLabels = { - LogcatLevel.verbose: 'V', - LogcatLevel.debug: 'D', - LogcatLevel.info: 'I', - LogcatLevel.warning: 'W', - LogcatLevel.error: 'E', - }; - - static const _levelColors = { - LogcatLevel.verbose: Color(0xFF6B707A), - LogcatLevel.debug: Color(0xFF4A9EDD), - LogcatLevel.info: Color(0xFF4CAF50), - LogcatLevel.warning: Color(0xFFFFC107), - LogcatLevel.error: Color(0xFFF44336), - }; - - // Map a single log line letter to the enum - static const _letterToLevel = { - 'V': LogcatLevel.verbose, - 'D': LogcatLevel.debug, - 'I': LogcatLevel.info, - 'W': LogcatLevel.warning, - 'E': LogcatLevel.error, - 'F': LogcatLevel.fatal, - }; - - Map _countLevels(List logs) { - final counts = { - for (final l in _levels) l: 0, - }; - // Logcat threadtime format: "MM-DD HH:MM:SS.mmm PID TID LEVEL tag: msg" - final re = RegExp(r'^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\s+\d+\s+\d+\s+([VDIWEF])\s'); - for (final line in logs) { - final m = re.firstMatch(line); - if (m != null) { - final level = _letterToLevel[m.group(1)]; - if (level != null && counts.containsKey(level)) { - counts[level] = counts[level]! + 1; - } - } - } - return counts; - } - - @override - Widget build(BuildContext context) { - return MoveWindow( - child: AppBar( - title: BlocBuilder( - builder: (context, state) { - return Text( - "${state.logs.length} lines", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 14, - ), - ); - }, - ), - backgroundColor: Theme.of(context).colorScheme.surface, - surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, - actions: [ - BlocBuilder( - builder: (context, state) { - final counts = _countLevels(state.logs); - final currentLevel = state.minimumLogLevel; - - return Row( - spacing: 8, - children: [ - // Level filter pills - Row( - spacing: 4, - children: _levels.map((level) { - final isActive = level.index >= currentLevel.index; - final color = _levelColors[level]!; - final count = counts[level] ?? 0; - - return InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () { - context.read().add( - OnMinimumLogLevelChanged(minimumLogLevel: level), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 7, - vertical: 3, - ), - decoration: BoxDecoration( - color: isActive - ? color.withValues(alpha: 0.15) - : Colors.transparent, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: isActive - ? color.withValues(alpha: 0.5) - : const Color(0xFF3A3B3F), - ), - ), - child: Row( - spacing: 4, - children: [ - Text( - _levelLabels[level]!, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: isActive - ? color - : const Color(0xFF6B707A), - ), - ), - if (count > 0) - Text( - count > 999 ? '999+' : '$count', - style: TextStyle( - fontSize: 10, - color: isActive - ? color.withValues(alpha: 0.8) - : const Color(0xFF6B707A), - ), - ), - ], - ), - ), - ); - }).toList(), - ), - - // Pause / Resume - Card( - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - FilledButton( - style: ButtonStyle( - shape: WidgetStateProperty.all( - BeveledRectangleBorder(), - ), - ), - child: Icon( - state.isPaused - ? Icons.play_arrow - : Icons.pause, - ), - onPressed: () { - final event = state.isPaused - ? OnResumeLogcat() - : OnPauseLogcat(); - context.read().add(event); - }, - ), - FilledButton( - style: ButtonStyle( - shape: WidgetStateProperty.all( - BeveledRectangleBorder(), - ), - ), - onPressed: () { - context.read().add(OnRefreshLogcat()); - }, - child: const Icon(Icons.refresh), - ), - ], - ), - ), - - LogcatButton( - color: const Color.fromARGB(255, 213, 36, 54), - icon: Icons.delete_outline_rounded, - onPressed: () => - context.read().add(OnClearLogcat()), - ), - IconButton( - color: Theme.of(context).colorScheme.primary, - onPressed: () { - Scaffold.of(context).openEndDrawer(); - }, - icon: const Icon(Icons.filter_alt_off_rounded), - ), - ], - ); - }, - ), - ], - ), - ); - } -} diff --git a/lib/features/logcat/presentation/widgets/logcat_button.dart b/lib/features/logcat/presentation/widgets/logcat_button.dart deleted file mode 100644 index 2449c70c..00000000 --- a/lib/features/logcat/presentation/widgets/logcat_button.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -class LogcatButton extends StatelessWidget { - final Color color; - final IconData icon; - final void Function()? onPressed; - - const LogcatButton({ - super.key, - required this.color, - required this.icon, - this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return FilledButton( - onPressed: onPressed, - style: ButtonStyle( - shape: WidgetStateProperty.all( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return Colors.grey; - } - return color; - }), - ), - child: Icon(icon), - ); - } -} diff --git a/lib/features/logcat/presentation/widgets/logcat_filter_drawer.dart b/lib/features/logcat/presentation/widgets/logcat_filter_drawer.dart deleted file mode 100644 index b344d8ff..00000000 --- a/lib/features/logcat/presentation/widgets/logcat_filter_drawer.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/logcat/domain/entities/process_entity.dart'; -import 'package:android_tools/features/logcat/presentation/core/logcat_level_extensions.dart'; -import 'package:android_tools/features/logcat/presentation/logcat_bloc.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class LogcatFilterDrawer extends StatelessWidget { - final availableLogcatLevels = [ - LogcatLevel.debug, - LogcatLevel.info, - LogcatLevel.warning, - LogcatLevel.error, - ]; - - LogcatFilterDrawer({super.key}); - - @override - Widget build(BuildContext context) { - return Drawer( - shape: ContinuousRectangleBorder(), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Minimum level"), - Row( - children: [ - Expanded( - child: BlocBuilder( - builder: (context, state) { - return DropdownButtonFormField( - initialValue: state.minimumLogLevel, - padding: EdgeInsets.symmetric(horizontal: 15), - elevation: 16, - onChanged: (LogcatLevel? value) { - if (state.minimumLogLevel == value) return; - context.read().add( - OnMinimumLogLevelChanged(minimumLogLevel: value), - ); - }, - items: availableLogcatLevels - .map>(( - LogcatLevel value, - ) { - return DropdownMenuItem( - value: value, - child: Row( - spacing: 8, - children: [ - Icon( - value.icon(), - color: value.textColor(), - ), - Text(value.name.toString()), - ], - ), - ); - }) - .toList(), - ); - }, - ), - ), - ], - ), - Text("Package"), - BlocBuilder( - builder: (context, state) { - return Autocomplete( - initialValue: TextEditingValue( - text: state.selectedProcess?.packageName ?? "", - ), - displayStringForOption: (item) => - "${item.packageName} (${item.processId})", - - onSelected: (option) { - context.read().add( - OnProcessSelected(process: option), - ); - }, - - fieldViewBuilder: - (context, controller, focusNode, onFieldSubmitted) { - return BlocListener( - listenWhen: (previous, current) => - previous.selectedProcess != - current.selectedProcess, - listener: (context, state) { - if (state.selectedProcess == null) { - controller.clear(); - } - }, - child: TextFormField( - controller: controller, - onChanged: (value) { - if (state.selectedProcess != null) { - context.read().add( - OnProcessSelected(process: null), - ); - controller.clear(); - } - }, - focusNode: focusNode, - decoration: const InputDecoration( - hintText: "Rechercher un process...", - ), - ), - ); - }, - - optionsBuilder: (textEditingValue) { - return state.processes.where( - (p) => p.packageName.contains(textEditingValue.text), - ); - }, - ); - }, - ), - BlocBuilder( - builder: (context, state) { - return Row( - children: [ - Checkbox( - value: state.isShowProcessThreadIds, - onChanged: (value) { - context.read().add( - OnIsShowProcessThreadIdsChanged( - isShowProcessThreadIds: value!, - ), - ); - }, - ), - Text("Show Process/Thread Id"), - ], - ); - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/logcat/presentation/widgets/logcat_line.dart b/lib/features/logcat/presentation/widgets/logcat_line.dart deleted file mode 100644 index b5917eaf..00000000 --- a/lib/features/logcat/presentation/widgets/logcat_line.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/logcat/domain/entities/logcat_line_entity.dart'; -import 'package:android_tools/features/logcat/presentation/core/logcat_level_extensions.dart'; -import 'package:flutter/material.dart'; - -class LogcatLine extends StatelessWidget { - static final _regex = RegExp( - r'^(\d{2}-\d{2}) ' - r'(\d{2}:\d{2}:\d{2}\.\d{3})\s+' - r'(\d+)\s+(\d+)\s+' - r'([VDIWEF])\s+' - r'(\S+)\s*:?\s*(.*)$', - ); - - final String line; - final bool isShowProcessThreadIds; - - const LogcatLine({ - super.key, - required this.line, - required this.isShowProcessThreadIds, - }); - - @override - Widget build(BuildContext context) { - final parsed = _parseLogcatLine(line); - - if (parsed != null) { - final textSpan = TextSpan( - style: const TextStyle(fontFamily: "monospace", fontSize: 12), - children: [ - WidgetSpan( - child: SizedBox( - width: 130, - child: Text( - '${parsed.dateTime.month.toString().padLeft(2, '0')}-' - '${parsed.dateTime.day.toString().padLeft(2, '0')} ' - '${parsed.dateTime.hour.toString().padLeft(2, '0')}:' - '${parsed.dateTime.minute.toString().padLeft(2, '0')}:' - '${parsed.dateTime.second.toString().padLeft(2, '0')}.' - '${parsed.dateTime.millisecond.toString().padLeft(3, '0')} ', - style: const TextStyle(fontSize: 12), - ), - ), - ), - if (isShowProcessThreadIds) - WidgetSpan( - child: SizedBox( - width: 100, - child: Text( - textAlign: TextAlign.center, - '${parsed.processId}-${parsed.threadId} ', - ), - ), - ), - - WidgetSpan( - child: SizedBox( - width: 140, - child: Text( - parsed.tag, - maxLines: 1, - style: const TextStyle(fontSize: 12), - ), - ), - ), - - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Container( - alignment: Alignment.center, - width: 30, - height: 30, - decoration: BoxDecoration(color: parsed.level.backgroundColor()), - child: Text( - textAlign: TextAlign.center, - parsed.level.name[0].toUpperCase(), - style: TextStyle( - color: parsed.level.onBackgroundColor(), - fontSize: 14, - fontFamily: "Nothing", - ), - ), - ), - ), - const TextSpan(text: ' '), - TextSpan( - text: parsed.message, - style: TextStyle(color: parsed.level.textColor()), - ), - ], - ); - - return SelectableText.rich(textSpan, maxLines: 1); - } else { - return SelectableText( - line, - style: const TextStyle(fontFamily: "monospace", fontSize: 12), - ); - } - } - - LogcatLineEntity? _parseLogcatLine(String line) { - final match = _regex.firstMatch(line); - if (match == null) return null; - - try { - final dateStr = match.group(1)!; - final timeStr = match.group(2)!; - final processId = int.parse(match.group(3)!); - final threadId = int.parse(match.group(4)!); - final levelLetter = match.group(5)!; - final tag = match.group(6)!; - final message = match.group(7)!; - - final now = DateTime.now(); - final dateTime = DateTime.parse('${now.year}-$dateStr $timeStr'); - - final level = switch (levelLetter) { - 'V' => LogcatLevel.verbose, - 'D' => LogcatLevel.debug, - 'I' => LogcatLevel.info, - 'W' => LogcatLevel.warning, - 'E' => LogcatLevel.error, - 'F' => LogcatLevel.fatal, - _ => LogcatLevel.verbose, - }; - - return LogcatLineEntity( - dateTime: dateTime, - level: level, - processId: processId, - threadId: threadId, - tag: tag, - message: message, - ); - } catch (_) { - return null; - } - } -} diff --git a/lib/features/screenshot/core/screenshot_module.dart b/lib/features/screenshot/core/screenshot_module.dart deleted file mode 100644 index 5a2fe997..00000000 --- a/lib/features/screenshot/core/screenshot_module.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:android_tools/features/screenshot/data/repositories/screenshot_repository_impl.dart'; -import 'package:android_tools/features/screenshot/domain/repositories/screenshot_repository.dart'; -import 'package:android_tools/features/screenshot/domain/usecases/copy_screenshot_usecase.dart'; -import 'package:android_tools/features/screenshot/domain/usecases/save_screenshot_usecase.dart'; -import 'package:android_tools/features/screenshot/domain/usecases/take_screenshot_usecase.dart'; -import 'package:android_tools/main.dart'; - -class ScreenshotModule { - static void configureDependencies() { - _registerRepositories(); - _registerUseCases(); - } - - static void _registerRepositories() { - getIt.registerLazySingleton( - () => ScreenshotRepositoryImpl(getIt.get(), getIt.get()), - ); - } - - static void _registerUseCases() { - getIt.registerLazySingleton(() => TakeScreenshotUsecase(getIt.get())); - getIt.registerLazySingleton(() => SaveScreenshotUsecase(getIt.get())); - getIt.registerLazySingleton(() => CopyScreenshotUsecase(getIt.get())); - } -} diff --git a/lib/features/screenshot/data/repositories/screenshot_repository_impl.dart b/lib/features/screenshot/data/repositories/screenshot_repository_impl.dart deleted file mode 100644 index 97b2f3c9..00000000 --- a/lib/features/screenshot/data/repositories/screenshot_repository_impl.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'dart:io'; - -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/features/screenshot/domain/entities/screenshot_entity.dart'; -import 'package:android_tools/features/screenshot/domain/repositories/screenshot_repository.dart'; -import 'package:image/image.dart' as img; -import 'package:logger/logger.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:super_clipboard/super_clipboard.dart'; - -class ScreenshotRepositoryImpl implements ScreenshotRepository { - final AdbClient _adbClient; - final Logger _logger; - - ScreenshotRepositoryImpl(this._adbClient, this._logger); - - @override - Future takeScreenshot(String deviceId) async { - try { - _logger.i('Taking screenshot for device: $deviceId'); - - // Get temp directory - final tempDir = await getTemporaryDirectory(); - final screenshotDir = Directory(p.join(tempDir.path, 'screenshots')); - - // Create directory if it doesn't exist - if (!await screenshotDir.exists()) { - await screenshotDir.create(recursive: true); - _logger.d('Created screenshots directory: ${screenshotDir.path}'); - } - - // Generate filename with timestamp - final timestamp = DateTime.now(); - final filename = 'screenshot_${timestamp.millisecondsSinceEpoch}.png'; - final filePath = p.join(screenshotDir.path, filename); - - // Capture screenshot using adb_dart 1.2.5 - // takeScreenshot(deviceId, outputPath) writes directly to file - await _adbClient.takeScreenshot(deviceId, filePath); - - // Read the file to get dimensions and size - final file = File(filePath); - final screenshotBytes = await file.readAsBytes(); - - // Get image dimensions - final image = img.decodeImage(screenshotBytes); - final width = image?.width ?? 0; - final height = image?.height ?? 0; - - _logger.i( - 'Screenshot captured: $filePath (${width}x$height, ${screenshotBytes.length} bytes)', - ); - - return ScreenshotEntity( - filePath: filePath, - timestamp: timestamp, - width: width, - height: height, - sizeInBytes: screenshotBytes.length, - ); - } catch (e, stackTrace) { - _logger.e('Error taking screenshot', error: e, stackTrace: stackTrace); - rethrow; - } - } - - @override - Future saveScreenshot( - String sourcePath, - String destinationPath, - ) async { - try { - _logger.i('Saving screenshot from $sourcePath to $destinationPath'); - final sourceFile = File(sourcePath); - await sourceFile.copy(destinationPath); - _logger.i('Screenshot saved successfully'); - } catch (e, stackTrace) { - _logger.e('Error saving screenshot', error: e, stackTrace: stackTrace); - rethrow; - } - } - - @override - Future copyToClipboard(String filePath) async { - try { - _logger.i('Copying screenshot to clipboard: $filePath'); - final file = File(filePath); - final bytes = await file.readAsBytes(); - - // Create clipboard item with PNG image - final item = DataWriterItem(); - item.add(Formats.png(bytes)); - - // Write to system clipboard - await SystemClipboard.instance?.write([item]); - - _logger.i('Screenshot copied to clipboard successfully'); - } catch (e, stackTrace) { - _logger.e( - 'Error copying to clipboard', - error: e, - stackTrace: stackTrace, - ); - rethrow; - } - } - - @override - Future deleteScreenshot(String filePath) async { - try { - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - _logger.i('Screenshot deleted: $filePath'); - } - } catch (e, stackTrace) { - _logger.e('Error deleting screenshot', error: e, stackTrace: stackTrace); - rethrow; - } - } -} diff --git a/lib/features/screenshot/domain/entities/screenshot_entity.dart b/lib/features/screenshot/domain/entities/screenshot_entity.dart deleted file mode 100644 index f8b90bc2..00000000 --- a/lib/features/screenshot/domain/entities/screenshot_entity.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:dart_mappable/dart_mappable.dart'; - -part 'screenshot_entity.mapper.dart'; - -@MappableClass() -class ScreenshotEntity with ScreenshotEntityMappable { - final String filePath; - final DateTime timestamp; - final int width; - final int height; - final int sizeInBytes; - - ScreenshotEntity({ - required this.filePath, - required this.timestamp, - required this.width, - required this.height, - required this.sizeInBytes, - }); -} diff --git a/lib/features/screenshot/domain/repositories/screenshot_repository.dart b/lib/features/screenshot/domain/repositories/screenshot_repository.dart deleted file mode 100644 index 5b97d77a..00000000 --- a/lib/features/screenshot/domain/repositories/screenshot_repository.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:android_tools/features/screenshot/domain/entities/screenshot_entity.dart'; - -abstract class ScreenshotRepository { - /// Captures screenshot from device and saves to temp directory - Future takeScreenshot(String deviceId); - - /// Saves screenshot from temp to user-selected location - Future saveScreenshot(String sourcePath, String destinationPath); - - /// Copies screenshot image to system clipboard - Future copyToClipboard(String filePath); - - /// Deletes screenshot file - Future deleteScreenshot(String filePath); -} diff --git a/lib/features/screenshot/domain/usecases/copy_screenshot_usecase.dart b/lib/features/screenshot/domain/usecases/copy_screenshot_usecase.dart deleted file mode 100644 index 50a8ecea..00000000 --- a/lib/features/screenshot/domain/usecases/copy_screenshot_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:android_tools/features/screenshot/domain/repositories/screenshot_repository.dart'; - -class CopyScreenshotUsecase { - final ScreenshotRepository _repository; - - CopyScreenshotUsecase(this._repository); - - Future call(String filePath) async { - await _repository.copyToClipboard(filePath); - } -} diff --git a/lib/features/screenshot/domain/usecases/save_screenshot_usecase.dart b/lib/features/screenshot/domain/usecases/save_screenshot_usecase.dart deleted file mode 100644 index 2a14c3b3..00000000 --- a/lib/features/screenshot/domain/usecases/save_screenshot_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:android_tools/features/screenshot/domain/repositories/screenshot_repository.dart'; - -class SaveScreenshotUsecase { - final ScreenshotRepository _repository; - - SaveScreenshotUsecase(this._repository); - - Future call(String sourcePath, String destinationPath) async { - await _repository.saveScreenshot(sourcePath, destinationPath); - } -} diff --git a/lib/features/screenshot/domain/usecases/take_screenshot_usecase.dart b/lib/features/screenshot/domain/usecases/take_screenshot_usecase.dart deleted file mode 100644 index 41007c39..00000000 --- a/lib/features/screenshot/domain/usecases/take_screenshot_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:android_tools/features/screenshot/domain/entities/screenshot_entity.dart'; -import 'package:android_tools/features/screenshot/domain/repositories/screenshot_repository.dart'; - -class TakeScreenshotUsecase { - final ScreenshotRepository _repository; - - TakeScreenshotUsecase(this._repository); - - Future call(String deviceId) async { - return await _repository.takeScreenshot(deviceId); - } -} diff --git a/lib/features/screenshot/presentation/screenshot_preview_bloc.dart b/lib/features/screenshot/presentation/screenshot_preview_bloc.dart deleted file mode 100644 index 48bee10c..00000000 --- a/lib/features/screenshot/presentation/screenshot_preview_bloc.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:io'; - -import 'package:android_tools/features/screenshot/domain/entities/screenshot_entity.dart'; -import 'package:android_tools/features/screenshot/domain/usecases/copy_screenshot_usecase.dart'; -import 'package:android_tools/features/screenshot/domain/usecases/save_screenshot_usecase.dart'; -import 'package:android_tools/features/screenshot/domain/usecases/take_screenshot_usecase.dart'; -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logger/logger.dart'; - -part 'screenshot_preview_event.dart'; -part 'screenshot_preview_state.dart'; -part 'screenshot_preview_bloc.mapper.dart'; - -class ScreenshotPreviewBloc - extends Bloc { - final Logger _logger = getIt.get(); - final TakeScreenshotUsecase _takeScreenshotUsecase = getIt.get(); - final SaveScreenshotUsecase _saveScreenshotUsecase = getIt.get(); - final CopyScreenshotUsecase _copyScreenshotUsecase = getIt.get(); - - ScreenshotPreviewBloc({ - ScreenshotEntity? screenshot, - DeviceEntity? device, - }) : super( - ScreenshotPreviewState( - screenshot: screenshot, - device: device, - status: screenshot != null - ? ScreenshotStatus.success - : ScreenshotStatus.idle, - ), - ) { - on(_onCaptureScreenshot); - on(_onResetState); - on((event, emit) async { - if (state.screenshot == null) return; - - try { - // Open save dialog - final result = await FilePicker.platform.saveFile( - dialogTitle: 'Save Screenshot', - fileName: - 'screenshot_${state.screenshot!.timestamp.millisecondsSinceEpoch}.png', - type: FileType.custom, - allowedExtensions: ['png'], - ); - - if (result == null) { - _logger.i('Save cancelled by user'); - return; - } - - emit(state.copyWith(status: ScreenshotStatus.loading)); - - await _saveScreenshotUsecase(state.screenshot!.filePath, result); - - emit(state.copyWith( - status: ScreenshotStatus.success, - successMessage: 'Screenshot saved successfully', - )); - - // Delete temp file after successful save - await _deleteScreenshotFile(); - } catch (e, stackTrace) { - _logger.e('Error saving screenshot', error: e, stackTrace: stackTrace); - emit(state.copyWith( - status: ScreenshotStatus.error, - errorMessage: 'Failed to save screenshot: $e', - )); - } - }); - - on((event, emit) async { - if (state.screenshot == null) return; - - try { - emit(state.copyWith(status: ScreenshotStatus.loading)); - - await _copyScreenshotUsecase(state.screenshot!.filePath); - - emit(state.copyWith( - status: ScreenshotStatus.success, - successMessage: 'Screenshot copied to clipboard', - )); - - // Delete temp file after successful copy - await _deleteScreenshotFile(); - } catch (e, stackTrace) { - _logger.e( - 'Error copying to clipboard', - error: e, - stackTrace: stackTrace, - ); - emit(state.copyWith( - status: ScreenshotStatus.error, - errorMessage: 'Failed to copy screenshot: $e', - )); - } - }); - - on((event, emit) async { - await _deleteScreenshotFile(); - emit(state.copyWith( - status: ScreenshotStatus.idle, - )); - }); - } - - Future _onCaptureScreenshot( - OnCaptureScreenshot event, - Emitter emit, - ) async { - try { - _logger.i('Starting screenshot capture for device: ${event.deviceId}'); - - emit(state.copyWith( - status: ScreenshotStatus.capturing, - device: event.device, - )); - - final screenshot = await _takeScreenshotUsecase(event.deviceId); - - _logger.i('Screenshot captured successfully'); - - emit(state.copyWith( - status: ScreenshotStatus.success, - screenshot: screenshot, - )); - } catch (e, stackTrace) { - _logger.e( - 'Failed to capture screenshot', - error: e, - stackTrace: stackTrace, - ); - - emit(state.copyWith( - status: ScreenshotStatus.error, - errorMessage: 'Failed to capture screenshot: $e', - )); - } - } - - void _onResetState( - OnResetState event, - Emitter emit, - ) { - emit(const ScreenshotPreviewState(status: ScreenshotStatus.idle)); - } - - Future _deleteScreenshotFile() async { - if (state.screenshot == null) return; - - try { - final file = File(state.screenshot!.filePath); - if (await file.exists()) { - await file.delete(); - _logger.i('Deleted temp screenshot file'); - } - } catch (e) { - _logger.w('Failed to delete screenshot file: $e'); - } - } -} diff --git a/lib/features/screenshot/presentation/screenshot_preview_event.dart b/lib/features/screenshot/presentation/screenshot_preview_event.dart deleted file mode 100644 index 754c47c7..00000000 --- a/lib/features/screenshot/presentation/screenshot_preview_event.dart +++ /dev/null @@ -1,18 +0,0 @@ -part of 'screenshot_preview_bloc.dart'; - -sealed class ScreenshotPreviewEvent {} - -class OnCaptureScreenshot extends ScreenshotPreviewEvent { - final String deviceId; - final DeviceEntity device; - - OnCaptureScreenshot({required this.deviceId, required this.device}); -} - -class OnResetState extends ScreenshotPreviewEvent {} - -class OnSaveScreenshot extends ScreenshotPreviewEvent {} - -class OnCopyToClipboard extends ScreenshotPreviewEvent {} - -class OnDeleteScreenshot extends ScreenshotPreviewEvent {} diff --git a/lib/features/screenshot/presentation/screenshot_preview_screen.dart b/lib/features/screenshot/presentation/screenshot_preview_screen.dart deleted file mode 100644 index 0e018e50..00000000 --- a/lib/features/screenshot/presentation/screenshot_preview_screen.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:android_tools/features/screenshot/domain/entities/screenshot_entity.dart'; -import 'package:android_tools/features/screenshot/presentation/screenshot_preview_bloc.dart'; -import 'package:android_tools/features/screenshot/presentation/widgets/screenshot_action_button.dart'; -import 'package:android_tools/features/screenshot/presentation/widgets/screenshot_preview_widget.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/intl.dart'; - -class ScreenshotPreviewScreen extends StatelessWidget { - final ScreenshotEntity screenshot; - final DeviceEntity device; - - const ScreenshotPreviewScreen({ - super.key, - required this.screenshot, - required this.device, - }); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - ScreenshotPreviewBloc(screenshot: screenshot, device: device), - child: Scaffold( - appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: MoveWindow( - child: AppBar( - automaticallyImplyLeading: false, - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - title: Text( - 'Screenshot - ${device.name}', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ), - ), - body: BlocConsumer( - listener: (context, state) { - // Close screen after successful save or copy - if (state.successMessage != null && context.mounted) { - Navigator.of(context).pop(); - } - - // Show error messages only - if (state.errorMessage != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.errorMessage!), - backgroundColor: Colors.red, - ), - ); - } - }, - builder: (context, state) { - if (state.status == ScreenshotStatus.loading || - state.screenshot == null || - state.device == null) { - return Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.onSurface, - ), - ); - } - - return Column( - children: [ - Expanded( - child: ScreenshotPreviewWidget( - imagePath: state.screenshot!.filePath, - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Text( - 'Size: ${state.screenshot!.width} x ${state.screenshot!.height}', - style: Theme.of(context).textTheme.bodyMedium, - ), - Text( - 'Captured: ${DateFormat('yyyy-MM-dd HH:mm:ss').format(state.screenshot!.timestamp)}', - style: Theme.of(context).textTheme.bodyMedium, - ), - Divider( - color: Theme.of( - context, - ).colorScheme.onSurface.withAlpha(60), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 16, - children: [ - ScreenshotActionButton( - icon: Icons.save, - text: 'Save As', - onPressed: () { - context.read().add( - OnSaveScreenshot(), - ); - }, - ), - ScreenshotActionButton( - icon: Icons.copy, - text: 'Copy', - onPressed: () { - context.read().add( - OnCopyToClipboard(), - ); - }, - ), - ScreenshotActionButton( - icon: Icons.delete, - text: 'Delete', - onPressed: () { - context.read().add( - OnDeleteScreenshot(), - ); - Navigator.of(context).pop(); - }, - ), - ], - ), - ], - ), - ), - ], - ); - }, - ), - ), - ); - } -} diff --git a/lib/features/screenshot/presentation/screenshot_preview_state.dart b/lib/features/screenshot/presentation/screenshot_preview_state.dart deleted file mode 100644 index 622dd322..00000000 --- a/lib/features/screenshot/presentation/screenshot_preview_state.dart +++ /dev/null @@ -1,20 +0,0 @@ -part of 'screenshot_preview_bloc.dart'; - -enum ScreenshotStatus { idle, capturing, loading, success, error } - -@MappableClass() -class ScreenshotPreviewState with ScreenshotPreviewStateMappable { - final ScreenshotStatus status; - final ScreenshotEntity? screenshot; - final DeviceEntity? device; - final String? errorMessage; - final String? successMessage; - - const ScreenshotPreviewState({ - required this.status, - this.screenshot, - this.device, - this.errorMessage, - this.successMessage, - }); -} diff --git a/lib/features/screenshot/presentation/widgets/screenshot_action_button.dart b/lib/features/screenshot/presentation/widgets/screenshot_action_button.dart deleted file mode 100644 index bf47a77e..00000000 --- a/lib/features/screenshot/presentation/widgets/screenshot_action_button.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -class ScreenshotActionButton extends StatelessWidget { - final IconData icon; - final String text; - final VoidCallback onPressed; - - const ScreenshotActionButton({ - super.key, - required this.icon, - required this.text, - required this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return FilledButton( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.transparent), - foregroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.onSurface, - ), - ), - onPressed: onPressed, - child: Column( - children: [ - Icon(icon), - Text(text), - ], - ), - ); - } -} diff --git a/lib/features/screenshot/presentation/widgets/screenshot_button.dart b/lib/features/screenshot/presentation/widgets/screenshot_button.dart deleted file mode 100644 index bb6d57fd..00000000 --- a/lib/features/screenshot/presentation/widgets/screenshot_button.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:android_tools/features/screenshot/presentation/screenshot_preview_bloc.dart'; -import 'package:android_tools/features/screenshot/presentation/screenshot_preview_screen.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/presentation/widgets/action_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class ScreenshotButton extends StatefulWidget { - final DeviceEntity? device; - - const ScreenshotButton({ - super.key, - required this.device, - }); - - @override - State createState() => _ScreenshotButtonState(); -} - -class _ScreenshotButtonState extends State { - final _bloc = ScreenshotPreviewBloc(); - - @override - void dispose() { - _bloc.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _bloc, - child: BlocConsumer( - listener: (context, state) { - // Handle success state - navigate to preview - if (state.status == ScreenshotStatus.success && - state.screenshot != null) { - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ScreenshotPreviewScreen( - screenshot: state.screenshot!, - device: state.device!, - ), - ), - ); - - // Reset state after navigation - _bloc.add(OnResetState()); - } - } - - // Handle error state - show error message - if (state.status == ScreenshotStatus.error) { - if (state.errorMessage != null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.errorMessage!), - backgroundColor: Colors.red, - ), - ); - - // Reset state after showing error - _bloc.add(OnResetState()); - } - } - }, - builder: (context, state) { - final isCapturing = state.status == ScreenshotStatus.capturing; - - return ActionButton( - onPressed: widget.device != null - ? () { - context.read().add( - OnCaptureScreenshot( - deviceId: widget.device!.deviceId, - device: widget.device!, - ), - ); - } - : null, - icon: Icons.photo_camera_outlined, - label: 'Screenshot', - isLoading: isCapturing, - loadingLabel: 'Capturing…', - ); - }, - ), - ); - } -} diff --git a/lib/features/screenshot/presentation/widgets/screenshot_preview_widget.dart b/lib/features/screenshot/presentation/widgets/screenshot_preview_widget.dart deleted file mode 100644 index b57bb9dd..00000000 --- a/lib/features/screenshot/presentation/widgets/screenshot_preview_widget.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; - -class ScreenshotPreviewWidget extends StatelessWidget { - final String imagePath; - - const ScreenshotPreviewWidget({ - super.key, - required this.imagePath, - }); - - @override - Widget build(BuildContext context) { - return Container( - color: Colors.black12, - child: InteractiveViewer( - minScale: 0.5, - maxScale: 5.0, - child: Center( - child: Image.file( - File(imagePath), - errorBuilder: (context, error, stackTrace) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.broken_image, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - 'Failed to load screenshot', - style: Theme.of(context).textTheme.titleMedium, - ), - SizedBox(height: 8), - Text( - error.toString(), - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Colors.grey), - textAlign: TextAlign.center, - ), - ], - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/features/settings/core/settings_module.dart b/lib/features/settings/core/settings_module.dart deleted file mode 100644 index a6303049..00000000 --- a/lib/features/settings/core/settings_module.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:android_tools/features/settings/presentation/settings_bloc.dart'; -import 'package:android_tools/main.dart'; - -class SettingsModule { - static void configureDependencies() { - getIt.registerFactory(() => SettingsBloc()); - } -} diff --git a/lib/features/settings/presentation/settings_bloc.dart b/lib/features/settings/presentation/settings_bloc.dart deleted file mode 100644 index bdb00771..00000000 --- a/lib/features/settings/presentation/settings_bloc.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/core/constants.dart'; -import 'package:android_tools/shared/domain/helpers/settings_helper.dart'; -import 'package:auto_updater/auto_updater.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logger/logger.dart'; -import 'package:url_launcher/url_launcher.dart'; - -part 'settings_event.dart'; -part 'settings_state.dart'; -part 'settings_bloc.mapper.dart'; - -class SettingsBloc extends Bloc { - final Logger _logger = getIt.get(); - final SettingsHelper _settingsHelper = getIt.get(); - - SettingsBloc() : super(SettingsState()) { - on((event, emit) async { - _logger.i("Opening log directory"); - final logDirectory = await Constants.getApplicationLogsDirectory(); - final uri = Uri.file(logDirectory.path); - await launchUrl(uri); - }); - on((event, emit) async { - _logger.i("Checking for updates"); - await autoUpdater.checkForUpdates(); - }); - on((event, emit) async { - _logger.i("Opening github repository"); - const repositoryUrl = String.fromEnvironment( - Constants.environmentGitRepositoryUrl, - defaultValue: '', - ); - - if (repositoryUrl.isEmpty) { - _logger.w( - "${Constants.environmentGitRepositoryUrl} not found from environment, launch project with '--dart-define=${Constants.environmentGitRepositoryUrl}=your_git_repository'", - ); - return; - } - - final uri = Uri.parse(repositoryUrl); - await launchUrl(uri); - }); - - on((event, emit) async { - _logger.i("Creating issue"); - const issueUrl = String.fromEnvironment( - Constants.environmentIssueUrl, - defaultValue: '', - ); - - if (issueUrl.isEmpty) { - _logger.w( - "${Constants.environmentIssueUrl} not found from environment, launch project with '--dart-define=${Constants.environmentIssueUrl}=your_issue_url'", - ); - return; - } - - final uri = Uri.parse(issueUrl); - await launchUrl(uri); - }); - - on((event, emit) async { - try { - final themeString = await _settingsHelper.getThemeMode(); - final themeMode = _stringToThemeMode(themeString); - emit(state.copyWith(themeMode: themeMode)); - } catch (e) { - _logger.e("Error loading theme mode: $e"); - emit(state.copyWith(themeMode: ThemeMode.system)); - } - }); - - on((event, emit) async { - try { - await _settingsHelper.setThemeMode(event.themeMode.name); - emit(state.copyWith(themeMode: event.themeMode)); - _logger.i("Theme mode updated to ${event.themeMode.name}"); - } catch (e) { - _logger.e("Error updating theme mode: $e"); - } - }); - - on((event, emit) async { - try { - final disabled = await _settingsHelper.getCrashReportingDisabled(); - emit(state.copyWith(crashReportingDisabled: disabled)); - } catch (e) { - _logger.e("Error loading crash reporting setting: $e"); - emit(state.copyWith(crashReportingDisabled: false)); - } - }); - - on((event, emit) async { - try { - await _settingsHelper.setCrashReportingDisabled(event.disabled); - emit(state.copyWith(crashReportingDisabled: event.disabled)); - _logger.i("Crash reporting ${event.disabled ? 'disabled' : 'enabled'}"); - } catch (e) { - _logger.e("Error updating crash reporting setting: $e"); - } - }); - } - - ThemeMode _stringToThemeMode(String value) { - switch (value) { - case 'light': - return ThemeMode.light; - case 'dark': - return ThemeMode.dark; - case 'system': - default: - return ThemeMode.system; - } - } -} diff --git a/lib/features/settings/presentation/settings_event.dart b/lib/features/settings/presentation/settings_event.dart deleted file mode 100644 index a2393c19..00000000 --- a/lib/features/settings/presentation/settings_event.dart +++ /dev/null @@ -1,27 +0,0 @@ -part of 'settings_bloc.dart'; - -sealed class SettingsEvent {} - -class OnOpenLogDirectory extends SettingsEvent {} - -class OnOpenGithubProject extends SettingsEvent {} - -class OnCreateIssue extends SettingsEvent {} - -class OnCheckForUpdates extends SettingsEvent {} - -class OnLoadThemeMode extends SettingsEvent {} - -class OnThemeModeChanged extends SettingsEvent { - final ThemeMode themeMode; - - OnThemeModeChanged(this.themeMode); -} - -class OnLoadCrashReportingSetting extends SettingsEvent {} - -class OnCrashReportingToggled extends SettingsEvent { - final bool disabled; - - OnCrashReportingToggled(this.disabled); -} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart deleted file mode 100644 index 6a5a1ad4..00000000 --- a/lib/features/settings/presentation/settings_screen.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'package:android_tools/features/settings/presentation/settings_bloc.dart'; -import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SettingsScreen extends StatefulWidget { - const SettingsScreen({super.key}); - - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().add(OnLoadCrashReportingSetting()); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest, - appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: MoveWindow( - child: AppBar( - centerTitle: false, - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - title: Text("Settings"), - ), - ), - ), - body: BlocBuilder( - builder: (context, state) { - return ListView( - padding: EdgeInsets.all(8), - children: [ - Column( - spacing: 16, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Appearance"), - Card( - clipBehavior: Clip.hardEdge, - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - RadioListTile( - title: Text("Auto"), - subtitle: Text("System theme"), - value: ThemeMode.system, - groupValue: state.themeMode, - onChanged: (value) { - if (value != null) { - context.read().add( - OnThemeModeChanged(value), - ); - } - }, - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: Divider( - color: Theme.of(context).colorScheme.outline, - height: 1, - ), - ), - RadioListTile( - title: Text("Light"), - subtitle: Text("Light theme"), - value: ThemeMode.light, - groupValue: state.themeMode, - onChanged: (value) { - if (value != null) { - context.read().add( - OnThemeModeChanged(value), - ); - } - }, - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: Divider( - color: Theme.of(context).colorScheme.outline, - height: 1, - ), - ), - RadioListTile( - title: Text("Dark"), - subtitle: Text("Dark theme"), - value: ThemeMode.dark, - groupValue: state.themeMode, - onChanged: (value) { - if (value != null) { - context.read().add( - OnThemeModeChanged(value), - ); - } - }, - ), - ], - ), - ), - ], - ), - - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Updates"), - Card( - clipBehavior: Clip.hardEdge, - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - ListTile( - leading: Icon(Icons.code), - title: Text("Open on github"), - subtitle: Text( - "Open Browser on Github open source project", - ), - onTap: () => context.read().add( - OnOpenGithubProject(), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 0, - ), - child: Divider( - color: Theme.of(context).colorScheme.outline, - height: 1, - ), - ), - - ListTile( - leading: Icon(Icons.update), - title: Text("Check for updates"), - onTap: () => context.read().add( - OnCheckForUpdates(), - ), - ), - ], - ), - ), - ], - ), - - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Support"), - - Card( - clipBehavior: Clip.hardEdge, - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - ListTile( - leading: Icon(Icons.bug_report), - title: Text("Create an issue"), - subtitle: Text("Create new issue on Github"), - onTap: () => context.read().add( - OnCreateIssue(), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 0, - ), - child: Divider( - color: Theme.of(context).colorScheme.outline, - height: 1, - ), - ), - ListTile( - leading: Icon(Icons.browse_gallery), - title: Text("Open log directory"), - subtitle: Text( - "Open application log directory to handle problems", - ), - onTap: () => context.read().add( - OnOpenLogDirectory(), - ), - ), - ], - ), - ), - ], - ), - - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Privacy"), - Card( - clipBehavior: Clip.hardEdge, - color: Theme.of(context).colorScheme.surface, - child: SwitchListTile( - secondary: Icon(Icons.privacy_tip), - title: Text("Disable crash reporting"), - subtitle: Text( - "Prevent sending anonymous crash reports to help improve the app. Requires restart.", - ), - value: state.crashReportingDisabled, - onChanged: (value) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text("Restart Required"), - content: Text( - "Crash reporting changes will take effect after restarting the application.\n\n" - "Do you want to change this setting?", - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - context.read().add( - OnCrashReportingToggled(value), - ); - }, - child: Text("Change Setting"), - ), - ], - ), - ); - }, - ), - ), - ], - ), - - ], - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/features/settings/presentation/settings_state.dart b/lib/features/settings/presentation/settings_state.dart deleted file mode 100644 index 8c506071..00000000 --- a/lib/features/settings/presentation/settings_state.dart +++ /dev/null @@ -1,12 +0,0 @@ -part of 'settings_bloc.dart'; - -@MappableClass() -class SettingsState with SettingsStateMappable { - final ThemeMode themeMode; - final bool crashReportingDisabled; - - SettingsState({ - this.themeMode = ThemeMode.system, - this.crashReportingDisabled = false, - }); -} diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index de9638c9..00000000 --- a/lib/main.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:android_tools/features/apk_inspector/core/apk_inspector_module.dart'; -import 'package:android_tools/features/file_explorer/core/file_explorer_module.dart'; -import 'package:android_tools/features/home/core/home_module.dart'; -import 'package:android_tools/features/home/presentation/home_screen.dart'; -import 'package:android_tools/features/information/core/information_module.dart'; -import 'package:android_tools/features/logcat/core/logcat_module.dart'; -import 'package:android_tools/features/screenshot/core/screenshot_module.dart'; -import 'package:android_tools/features/settings/core/settings_module.dart'; -import 'package:android_tools/features/settings/presentation/settings_bloc.dart'; -import 'package:android_tools/shared/core/constants.dart'; -import 'package:android_tools/shared/core/shared_module.dart'; -import 'package:android_tools/shared/core/string_extensions.dart'; -import 'package:android_tools/shared/domain/helpers/settings_helper.dart'; -import 'package:android_tools/shared/presentation/themes.dart'; -import 'package:auto_updater/auto_updater.dart'; -import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:logger/logger.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - -final getIt = GetIt.instance; - -Future main(List args) async { - WidgetsFlutterBinding.ensureInitialized(); - LogcatModule.configureDependencies(); - SharedModule.configureDependencies(); - InformationModule.configureDependencies(); - FileExplorerModule.configureDependencies(); - ScreenshotModule.configureDependencies(); - ApkInspectorModule.configureDependencies(); - HomeModule.configureDependencies(); - SettingsModule.configureDependencies(); - await getIt.allReady(); - final logger = await getIt.getAsync(); - final settingsHelper = getIt.get(); - logger.i("==== Starting application ===="); - - // Load crash reporting preference early - final crashReportingDisabled = await settingsHelper - .getCrashReportingDisabled(); - logger.i( - "Crash reporting ${crashReportingDisabled ? 'disabled' : 'enabled'} by user preference", - ); - - const sentryDsn = String.fromEnvironment( - Constants.environmentSentryDsn, - defaultValue: '', - ); - if (sentryDsn.isEmpty) { - logger.w( - "${Constants.environmentSentryDsn} not found from environment, launch project with '--dart-define=${Constants.environmentSentryDsn}=your_sentry_dsn'", - ); - } else { - logger.i( - "${Constants.environmentSentryDsn} found: ${sentryDsn.anonymize()}", - ); - } - - const autoUpdaterFeedUrl = String.fromEnvironment( - Constants.environmentAutoUpdaterFeedUrl, - defaultValue: '', - ); - if (autoUpdaterFeedUrl.isEmpty) { - logger.w( - "${Constants.environmentAutoUpdaterFeedUrl} not found from environment, launch project with '--dart-define=${Constants.environmentAutoUpdaterFeedUrl}=your_feed_url'", - ); - } else { - logger.i( - "${Constants.environmentAutoUpdaterFeedUrl} found: ${autoUpdaterFeedUrl.anonymize()}", - ); - try { - await autoUpdater.setFeedURL(autoUpdaterFeedUrl); - await autoUpdater.checkForUpdates(inBackground: true); - } catch (e) { - logger.w("Error with autoUpdater: $e"); - } - } - - // Conditionally initialize Sentry based on user preference - if (!crashReportingDisabled && sentryDsn.isNotEmpty) { - logger.i("Initializing Sentry for crash reporting"); - await SentryFlutter.init((options) { - options.dsn = sentryDsn; - options.replay.sessionSampleRate = 0.1; - options.replay.onErrorSampleRate = 1.0; - options.attachScreenshot = true; - options.privacy.maskAllImages = false; - options.privacy.maskAllText = false; - }, appRunner: () => runApp(SentryWidget(child: MyApp()))); - } else { - if (crashReportingDisabled) { - logger.i( - "Skipping Sentry initialization - crash reporting disabled by user", - ); - } - runApp(MyApp()); - } - - doWhenWindowReady(() { - appWindow.minSize = Size(800, 50); - appWindow.alignment = Alignment.center; - appWindow.show(); - }); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt()..add(OnLoadThemeMode()), - child: BlocBuilder( - builder: (context, state) { - return MaterialApp( - debugShowCheckedModeBanner: false, - title: 'Android Tools', - theme: Themes.lightTheme, - darkTheme: Themes.darkTheme, - themeMode: state.themeMode, - home: HomeScreen(), - ); - }, - ), - ); - } -} diff --git a/lib/shared/core/constants.dart b/lib/shared/core/constants.dart deleted file mode 100644 index 8f1a40c1..00000000 --- a/lib/shared/core/constants.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:io'; -import 'package:path/path.dart' as p; - -import 'package:path_provider/path_provider.dart'; - -class Constants { - static Future getApplicationLogsDirectory() async { - final directory = await getApplicationSupportDirectory(); - final logDirectory = Directory(p.join(directory.path, 'logs')); - - if (!await logDirectory.exists()) { - await logDirectory.create(recursive: true); - } - - return logDirectory; - } - - static String getAdbPath() { - if (Platform.isMacOS) { - final execDir = Directory(Platform.resolvedExecutable).parent; - final contents = execDir.parent; - final resources = Directory("${contents.path}/Resources"); - return "${resources.path}/adb"; - } else if (Platform.isWindows) { - final execDir = Directory(Platform.resolvedExecutable).parent; - return "${execDir.path}/adb.exe"; - } else { - throw UnsupportedError('Platform not supported'); - } - } - - static String getAaptPath() { - if (Platform.isMacOS) { - final execDir = Directory(Platform.resolvedExecutable).parent; - final contents = execDir.parent; - final resources = Directory("${contents.path}/Resources"); - return "${resources.path}/aapt"; - } else if (Platform.isWindows) { - final execDir = Directory(Platform.resolvedExecutable).parent; - return "${execDir.path}/aapt.exe"; - } else { - throw UnsupportedError('Platform not supported'); - } - } - - static const environmentGitRepositoryUrl = "GIT_REPOSITORY_URL"; - static const environmentIssueUrl = "ISSUE_URL"; - static const environmentSentryDsn = "SENTRY_DSN"; - static const environmentAutoUpdaterFeedUrl = "AUTO_UPDATER_FEED_URL"; -} diff --git a/lib/shared/core/shared_module.dart b/lib/shared/core/shared_module.dart deleted file mode 100644 index 2d495f09..00000000 --- a/lib/shared/core/shared_module.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:aapt_dart/aapt_dart.dart'; -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/main.dart'; -import 'package:android_tools/shared/core/constants.dart'; -import 'package:android_tools/shared/data/datasources/local/app_database.dart'; -import 'package:android_tools/shared/data/datasources/shell/shell_datasource.dart'; -import 'package:android_tools/shared/data/helpers/settings_helper_shared_pref_impl.dart'; -import 'package:android_tools/shared/data/repositories/device_repository_impl.dart'; -import 'package:android_tools/shared/data/repositories/package_repository_impl.dart'; -import 'package:android_tools/shared/domain/helpers/settings_helper.dart'; -import 'package:android_tools/shared/domain/repositories/device_repository.dart'; -import 'package:android_tools/shared/domain/repositories/package_repository.dart'; -import 'package:android_tools/shared/domain/usecases/get_packages_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/listen_connected_devices_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/listen_selected_device_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/refresh_connected_devices_usecase.dart'; -import 'package:android_tools/shared/domain/usecases/set_selected_device_usecase.dart'; -import 'package:logger/logger.dart'; - -class SharedModule { - static void configureDependencies() { - getIt.registerSingletonAsync(() => _createLogger()); - getIt.registerSingleton( - AdbClient(adbExecutablePath: Constants.getAdbPath()), - ); - getIt.registerSingleton( - AaptClient(aaptExecutablePath: Constants.getAaptPath()), - ); - getIt.registerLazySingleton(() => ShellDatasource()); - getIt.registerLazySingleton(() => AppDatabase()); - getIt.registerLazySingleton( - () => SettingsHelperSharedPrefImpl(), - ); - - _registerRepositories(); - _registerUseCases(); - } - - static void _registerRepositories() { - getIt.registerLazySingleton( - () => DeviceRepositoryImpl(getIt.get(), getIt.get()), - ); - getIt.registerLazySingleton( - () => PackageRepositoryImpl(getIt.get()), - ); - } - - static void _registerUseCases() { - getIt.registerLazySingleton(() => SetSelectedDeviceUsecase(getIt.get())); - getIt.registerLazySingleton(() => ListenSelectedDeviceUsecase(getIt.get())); - getIt.registerLazySingleton(() => GetPackagesUsecase(getIt.get())); - getIt.registerLazySingleton( - () => RefreshConnectedDevicesUsecase(getIt.get()), - ); - getIt.registerLazySingleton( - () => ListenConnectedDevicesUsecase(getIt.get()), - ); - } - - static Future _createLogger() async { - final logDirectory = await Constants.getApplicationLogsDirectory(); - return Logger( - filter: ProductionFilter(), - printer: SimplePrinter(printTime: true, colors: false), - output: MultiOutput([ - AdvancedFileOutput( - path: logDirectory.path, - maxRotatedFilesCount: 7, - maxFileSizeKB: 1024, - ), - ConsoleOutput(), - ]), - ); - } -} diff --git a/lib/shared/core/string_extensions.dart b/lib/shared/core/string_extensions.dart deleted file mode 100644 index 16cc9d42..00000000 --- a/lib/shared/core/string_extensions.dart +++ /dev/null @@ -1,12 +0,0 @@ -extension StringExtensions on String { - String anonymize() { - if (length <= 6) { - return '***'; - } - - final start = substring(0, 3); - final end = substring(length - 3); - - return '$start...$end'; - } -} diff --git a/lib/shared/data/datasources/local/app_database.dart b/lib/shared/data/datasources/local/app_database.dart deleted file mode 100644 index 65947839..00000000 --- a/lib/shared/data/datasources/local/app_database.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:android_tools/shared/data/datasources/local/models/app_settings_model.dart'; -import 'package:android_tools/shared/data/datasources/local/models/recent_apk_model.dart'; -import 'package:drift/drift.dart'; -import 'package:drift_flutter/drift_flutter.dart'; -import 'package:path_provider/path_provider.dart'; - -part 'app_database.g.dart'; - -@DriftDatabase(tables: [AppSettingsModel, RecentApkModel]) -class AppDatabase extends _$AppDatabase { - AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); - - @override - int get schemaVersion => 4; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (Migrator m) async { - await m.createAll(); - }, - onUpgrade: (Migrator m, int from, int to) async { - if (from == 1) { - await m.createTable(appSettingsModel); - } - if (from <= 2) { - await m.createTable(recentApkModel); - } - if (from <= 3) { - await m.database.customStatement( - 'DROP TABLE IF EXISTS installed_application_history_model', - ); - } - }, - ); - - static QueryExecutor _openConnection() { - return driftDatabase( - name: 'android_tools.db', - native: const DriftNativeOptions( - databaseDirectory: getApplicationSupportDirectory, - ), - ); - } -} diff --git a/lib/shared/data/datasources/local/models/app_settings_model.dart b/lib/shared/data/datasources/local/models/app_settings_model.dart deleted file mode 100644 index d99ba302..00000000 --- a/lib/shared/data/datasources/local/models/app_settings_model.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:drift/drift.dart'; - -class AppSettingsModel extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get key => text().unique()(); - TextColumn get value => text()(); - - DateTimeColumn get createdAt => dateTime().nullable()(); - DateTimeColumn get updatedAt => dateTime().nullable()(); -} diff --git a/lib/shared/data/datasources/local/models/recent_apk_model.dart b/lib/shared/data/datasources/local/models/recent_apk_model.dart deleted file mode 100644 index c1b59658..00000000 --- a/lib/shared/data/datasources/local/models/recent_apk_model.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:drift/drift.dart'; - -class RecentApkModel extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get filePath => text()(); - TextColumn get fileName => text()(); - RealColumn get sizeInMB => real()(); - TextColumn get packageName => text()(); - TextColumn get appLabel => text()(); - TextColumn get version => text()(); - IntColumn get versionCode => integer()(); - IntColumn get minSdk => integer()(); - IntColumn get targetSdk => integer()(); - - DateTimeColumn get lastInspectedAt => dateTime()(); -} diff --git a/lib/shared/data/datasources/shell/shell_datasource.dart b/lib/shared/data/datasources/shell/shell_datasource.dart deleted file mode 100644 index 6fdf2a9b..00000000 --- a/lib/shared/data/datasources/shell/shell_datasource.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:io'; - -class ShellDatasource { - String getAdbPath() { - final execDir = Directory(Platform.resolvedExecutable).parent; - final contents = execDir.parent; - final resources = Directory("${contents.path}/Resources"); - return "${resources.path}/adb"; - } -} diff --git a/lib/shared/data/helpers/settings_helper_shared_pref_impl.dart b/lib/shared/data/helpers/settings_helper_shared_pref_impl.dart deleted file mode 100644 index bdcf7c19..00000000 --- a/lib/shared/data/helpers/settings_helper_shared_pref_impl.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:android_tools/shared/domain/helpers/settings_helper.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class SettingsHelperSharedPrefImpl implements SettingsHelper { - static const String _keyThemeMode = 'theme_mode'; - static const String _defaultThemeMode = 'system'; - - static const String _keyCrashReportingDisabled = 'crash_reporting_disabled'; - static const bool _defaultCrashReportingDisabled = false; - - @override - Future getThemeMode() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_keyThemeMode) ?? _defaultThemeMode; - } - - @override - Future setThemeMode(String mode) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_keyThemeMode, mode); - } - - @override - Future getCrashReportingDisabled() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_keyCrashReportingDisabled) ?? - _defaultCrashReportingDisabled; - } - - @override - Future setCrashReportingDisabled(bool disabled) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_keyCrashReportingDisabled, disabled); - } -} diff --git a/lib/shared/data/repositories/device_repository_impl.dart b/lib/shared/data/repositories/device_repository_impl.dart deleted file mode 100644 index 7bdd9a38..00000000 --- a/lib/shared/data/repositories/device_repository_impl.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:async'; -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/repositories/device_repository.dart'; -import 'package:logger/logger.dart'; -import 'package:rxdart/rxdart.dart'; - -class DeviceRepositoryImpl implements DeviceRepository { - final Logger logger; - final AdbClient adbClient; - - final BehaviorSubject _selectedDeviceSubject = - BehaviorSubject(); - - final BehaviorSubject> _connectedDevicesSubject = - BehaviorSubject>.seeded(const []); - - Future>? _ongoingRefresh; - - DeviceRepositoryImpl(this.logger, this.adbClient); - - @override - Future setSelectedDevice(DeviceEntity device) async { - _selectedDeviceSubject.add(device); - } - - @override - Stream listenSelectedDevice() => _selectedDeviceSubject.stream; - - @override - Stream> listenConnectedDevice() => - _connectedDevicesSubject.stream; - - @override - Future refreshConnectedDevices() async { - // If a refresh is already in progress, wait for it to complete - if (_ongoingRefresh != null) { - logger.d("Refresh already in progress, waiting for completion"); - await _ongoingRefresh; - return; - } - - logger.i("Refreshing connected devices"); - - _ongoingRefresh = _getConnectedDevices(); - try { - final devices = await _ongoingRefresh!; - _connectedDevicesSubject.add(devices); - - final current = _selectedDeviceSubject.valueOrNull; - - if (devices.isEmpty) { - _selectedDeviceSubject.add(null); - return; - } - - if (current == null || !devices.contains(current)) { - _selectedDeviceSubject.add(devices.first); - } - } finally { - _ongoingRefresh = null; - } - } - - Future> _getConnectedDevices() async { - try { - final connectedDevices = await adbClient.listConnectedDevices(); - return connectedDevices - .map( - (c) => DeviceEntity( - manufacturer: c.manufacturer, - name: c.name, - deviceId: c.deviceId, - ), - ) - .toList(); - } catch (e) { - // Device disconnected while retrieving properties or ADB error occurred - logger.w("Failed to retrieve connected devices: $e"); - return []; - } - } -} diff --git a/lib/shared/data/repositories/package_repository_impl.dart b/lib/shared/data/repositories/package_repository_impl.dart deleted file mode 100644 index c4212f98..00000000 --- a/lib/shared/data/repositories/package_repository_impl.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:adb_dart/adb_dart.dart'; -import 'package:android_tools/shared/domain/repositories/package_repository.dart'; - -class PackageRepositoryImpl implements PackageRepository { - final AdbClient _adbClient; - - PackageRepositoryImpl(this._adbClient); - - @override - Future> getAllPackages(String deviceId) { - return _adbClient.getAllPackages(deviceId); - } -} diff --git a/lib/shared/domain/entities/device_entity.dart b/lib/shared/domain/entities/device_entity.dart deleted file mode 100644 index a36c8493..00000000 --- a/lib/shared/domain/entities/device_entity.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:equatable/equatable.dart'; - -class DeviceEntity extends Equatable { - final String manufacturer; - final String name; - final String deviceId; - - const DeviceEntity({ - required this.manufacturer, - required this.name, - required this.deviceId, - }); - - @override - List get props => [deviceId]; -} diff --git a/lib/shared/domain/helpers/settings_helper.dart b/lib/shared/domain/helpers/settings_helper.dart deleted file mode 100644 index 0a578612..00000000 --- a/lib/shared/domain/helpers/settings_helper.dart +++ /dev/null @@ -1,7 +0,0 @@ -abstract class SettingsHelper { - Future getThemeMode(); - Future setThemeMode(String mode); - - Future getCrashReportingDisabled(); - Future setCrashReportingDisabled(bool disabled); -} diff --git a/lib/shared/domain/repositories/device_repository.dart b/lib/shared/domain/repositories/device_repository.dart deleted file mode 100644 index bcc32d55..00000000 --- a/lib/shared/domain/repositories/device_repository.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:android_tools/shared/domain/entities/device_entity.dart'; - -abstract class DeviceRepository { - Future setSelectedDevice(DeviceEntity device); - Future refreshConnectedDevices(); - - Stream listenSelectedDevice(); - Stream> listenConnectedDevice(); -} diff --git a/lib/shared/domain/repositories/package_repository.dart b/lib/shared/domain/repositories/package_repository.dart deleted file mode 100644 index b8a06fbd..00000000 --- a/lib/shared/domain/repositories/package_repository.dart +++ /dev/null @@ -1,3 +0,0 @@ -abstract class PackageRepository { - Future> getAllPackages(String deviceId); -} diff --git a/lib/shared/domain/usecases/get_packages_usecase.dart b/lib/shared/domain/usecases/get_packages_usecase.dart deleted file mode 100644 index 51610700..00000000 --- a/lib/shared/domain/usecases/get_packages_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:android_tools/shared/domain/repositories/package_repository.dart'; - -class GetPackagesUsecase { - final PackageRepository _packageRepository; - - GetPackagesUsecase(this._packageRepository); - - Future> call(String deviceId) { - return _packageRepository.getAllPackages(deviceId); - } -} diff --git a/lib/shared/domain/usecases/listen_connected_devices_usecase.dart b/lib/shared/domain/usecases/listen_connected_devices_usecase.dart deleted file mode 100644 index 88384b76..00000000 --- a/lib/shared/domain/usecases/listen_connected_devices_usecase.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/repositories/device_repository.dart'; - -class ListenConnectedDevicesUsecase { - final DeviceRepository _deviceRepository; - ListenConnectedDevicesUsecase(this._deviceRepository); - Stream> call() { - return _deviceRepository.listenConnectedDevice(); - } -} diff --git a/lib/shared/domain/usecases/listen_selected_device_usecase.dart b/lib/shared/domain/usecases/listen_selected_device_usecase.dart deleted file mode 100644 index 6b84bf81..00000000 --- a/lib/shared/domain/usecases/listen_selected_device_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/repositories/device_repository.dart'; - -class ListenSelectedDeviceUsecase { - final DeviceRepository _deviceRepository; - - ListenSelectedDeviceUsecase(this._deviceRepository); - - Stream call() { - return _deviceRepository.listenSelectedDevice(); - } -} diff --git a/lib/shared/domain/usecases/refresh_connected_devices_usecase.dart b/lib/shared/domain/usecases/refresh_connected_devices_usecase.dart deleted file mode 100644 index 9474da2b..00000000 --- a/lib/shared/domain/usecases/refresh_connected_devices_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:android_tools/shared/domain/repositories/device_repository.dart'; - -class RefreshConnectedDevicesUsecase { - final DeviceRepository _deviceRepository; - - RefreshConnectedDevicesUsecase(this._deviceRepository); - - Future call() { - return _deviceRepository.refreshConnectedDevices(); - } -} diff --git a/lib/shared/domain/usecases/set_selected_device_usecase.dart b/lib/shared/domain/usecases/set_selected_device_usecase.dart deleted file mode 100644 index 809e7a0f..00000000 --- a/lib/shared/domain/usecases/set_selected_device_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:android_tools/shared/domain/entities/device_entity.dart'; -import 'package:android_tools/shared/domain/repositories/device_repository.dart'; - -class SetSelectedDeviceUsecase { - final DeviceRepository _deviceRepository; - - SetSelectedDeviceUsecase(this._deviceRepository); - - Future call(DeviceEntity selectedDevice) { - return _deviceRepository.setSelectedDevice(selectedDevice); - } -} diff --git a/lib/shared/presentation/refresh_device_button.dart b/lib/shared/presentation/refresh_device_button.dart deleted file mode 100644 index a1ddedb1..00000000 --- a/lib/shared/presentation/refresh_device_button.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; - -class RefreshDeviceButton extends StatelessWidget { - final void Function()? onPressed; - - const RefreshDeviceButton({super.key, this.onPressed}); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton(onPressed: onPressed, child: Text("Refresh devices")), - Text("Can't find any android devices"), - ], - ); - } -} diff --git a/lib/shared/presentation/themes.dart b/lib/shared/presentation/themes.dart deleted file mode 100644 index 6fd7dbba..00000000 --- a/lib/shared/presentation/themes.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; - -class Themes { - static const _orange = Color(0xFFFF7A1A); - - static ThemeData lightTheme = ThemeData( - brightness: Brightness.light, - primaryColor: _orange, - scaffoldBackgroundColor: Color(0xFFF5F5F5), - appBarTheme: AppBarTheme( - backgroundColor: Colors.transparent, - foregroundColor: Color(0xFF111215), - ), - progressIndicatorTheme: ProgressIndicatorThemeData( - linearTrackColor: Colors.grey[300], - ), - colorScheme: ColorScheme.light( - primary: _orange, - surfaceContainerLowest: Color(0xFFFFFFFF), - surfaceContainerLow: Color(0xFFF0F0F0), - surfaceContainer: Color(0xFFE0E0E0), - surfaceContainerHighest: Color(0xFF9E9E9E), - ), - textTheme: TextTheme( - titleLarge: TextStyle(fontFamily: "Nothing", fontSize: 28), - titleMedium: TextStyle(fontFamily: "Nothing", fontSize: 24), - titleSmall: TextStyle(fontFamily: "Nothing", fontSize: 20), - ), - ); - - static ThemeData darkTheme = ThemeData( - brightness: Brightness.dark, - primaryColor: _orange, - scaffoldBackgroundColor: Color(0xFF0B0C0E), - appBarTheme: AppBarTheme( - backgroundColor: Colors.transparent, - foregroundColor: Color(0xFFE6E7EA), - ), - progressIndicatorTheme: ProgressIndicatorThemeData( - linearTrackColor: Color(0xFF2A2B2F), - ), - colorScheme: ColorScheme.dark( - primary: _orange, - surfaceContainerLowest: Color(0xFF0D0E11), - surfaceContainerLow: Color(0xFF111215), - surfaceContainer: Color(0xFF17181C), - surfaceContainerHighest: Color(0xFF6B707A), - ), - textTheme: TextTheme( - titleLarge: TextStyle(fontFamily: "Nothing", fontSize: 28), - titleMedium: TextStyle(fontFamily: "Nothing", fontSize: 24), - titleSmall: TextStyle(fontFamily: "Nothing", fontSize: 20), - ), - ); -} diff --git a/lib/shared/presentation/widgets/action_button.dart b/lib/shared/presentation/widgets/action_button.dart deleted file mode 100644 index 4578e9c7..00000000 --- a/lib/shared/presentation/widgets/action_button.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Generic action button with outlined style, used for actions like screenshot, reconnect, etc. -/// Provides consistent styling across the app for action buttons. -class ActionButton extends StatelessWidget { - final VoidCallback? onPressed; - final IconData icon; - final String label; - final bool isLoading; - final String? loadingLabel; - - const ActionButton({ - super.key, - required this.onPressed, - required this.icon, - required this.label, - this.isLoading = false, - this.loadingLabel, - }); - - @override - Widget build(BuildContext context) { - final surfaceContainer = Theme.of(context).colorScheme.surfaceContainerHighest; - final onSurface = Theme.of(context).colorScheme.onSurface; - - return FilledButton.icon( - onPressed: isLoading ? null : onPressed, - style: FilledButton.styleFrom( - backgroundColor: surfaceContainer, - foregroundColor: onSurface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: isLoading - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: onSurface, - ), - ) - : Icon(icon, size: 18), - label: Text(isLoading ? (loadingLabel ?? label) : label), - ); - } -} diff --git a/lib/shared/presentation/widgets/info_badge.dart b/lib/shared/presentation/widgets/info_badge.dart deleted file mode 100644 index eab0254e..00000000 --- a/lib/shared/presentation/widgets/info_badge.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Generic badge/chip component for displaying metadata. -/// -/// Unifies the `_Badge` component from ApkHeroCard and `MetaChip` from -/// DeviceStatCard. Can display an optional icon and a label with customizable -/// colors and styling. -class InfoBadge extends StatelessWidget { - /// The text to display in the badge - final String label; - - /// Optional icon to display before the label - final IconData? icon; - - /// Optional color for the badge (icon and text) - /// If null, uses the theme's onSurfaceVariant color - final Color? color; - - /// Whether to use monospace font for the label (defaults to true) - final bool monospace; - - /// Font size for the label (defaults to 11) - final double fontSize; - - /// Icon size (defaults to 10) - final double iconSize; - - const InfoBadge({ - super.key, - required this.label, - this.icon, - this.color, - this.monospace = true, - this.fontSize = 11, - this.iconSize = 10, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final badgeColor = color ?? colorScheme.onSurfaceVariant; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: color != null - ? color!.withValues(alpha: 0.1) - : colorScheme.surfaceContainerHigh, - border: Border.all( - color: color != null - ? color!.withValues(alpha: 0.4) - : colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - ), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: icon != null ? 4 : 0, - children: [ - if (icon != null) Icon(icon, size: iconSize, color: badgeColor), - Text( - label, - style: TextStyle( - fontSize: fontSize, - fontFamily: monospace ? 'monospace' : null, - color: badgeColor, - ), - ), - ], - ), - ); - } -} diff --git a/lib/shared/presentation/widgets/info_card.dart b/lib/shared/presentation/widgets/info_card.dart deleted file mode 100644 index 62524ed3..00000000 --- a/lib/shared/presentation/widgets/info_card.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Generic card container with consistent styling across the app. -/// -/// Used for displaying information in cards with a surface container background, -/// border, and rounded corners. This component unifies the styling used in -/// StatCardShell, ApkHeroCard, and various panels. -/// -/// Supports dynamic styling via optional parameters for border color, width, -/// background color, and border radius. -class InfoCard extends StatelessWidget { - /// The content to display inside the card - final Widget child; - - /// Optional padding around the child (defaults to 16) - final EdgeInsetsGeometry? padding; - - /// Optional border color (defaults to surfaceContainerHighest with alpha 0.15) - final Color? borderColor; - - /// Optional border width (defaults to 1) - final double? borderWidth; - - /// Optional background color (defaults to surfaceContainer) - final Color? backgroundColor; - - /// Optional border radius (defaults to 8) - final double? borderRadius; - - /// Optional border stroke alignment (defaults to BorderSide.strokeAlignCenter) - final double? strokeAlign; - - const InfoCard({ - super.key, - required this.child, - this.padding, - this.borderColor, - this.borderWidth, - this.backgroundColor, - this.borderRadius, - this.strokeAlign, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - decoration: BoxDecoration( - color: backgroundColor ?? colorScheme.surfaceContainer, - border: Border.all( - color: borderColor ?? - colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - width: borderWidth ?? 1, - strokeAlign: strokeAlign ?? BorderSide.strokeAlignCenter, - ), - borderRadius: BorderRadius.circular(borderRadius ?? 8), - ), - padding: padding ?? const EdgeInsets.all(16), - child: child, - ); - } -} diff --git a/lib/shared/presentation/widgets/info_panel.dart b/lib/shared/presentation/widgets/info_panel.dart deleted file mode 100644 index 58b917e4..00000000 --- a/lib/shared/presentation/widgets/info_panel.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Panel component with optional header and content sections. -/// -/// Provides a consistent layout for panels with a title, optional trailing -/// widget in the header, and a content area. Used across APK Inspector panels -/// and can be used in other features. -class InfoPanel extends StatelessWidget { - /// Title displayed in the header - final String? title; - - /// Optional widget displayed at the end of the header (e.g., status badge, count) - final Widget? trailing; - - /// Main content of the panel - final Widget child; - - /// Padding around the content (defaults to EdgeInsets.fromLTRB(14, 6, 14, 12)) - final EdgeInsetsGeometry? contentPadding; - - const InfoPanel({ - super.key, - this.title, - this.trailing, - required this.child, - this.contentPadding, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainer, - border: Border.all( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header (only if title is provided) - if (title != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), - ), - ), - ), - child: Row( - children: [ - Text( - title!, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - if (trailing != null) trailing!, - ], - ), - ), - - // Content - Padding( - padding: contentPadding ?? const EdgeInsets.fromLTRB(14, 6, 14, 12), - child: child, - ), - ], - ), - ); - } -} diff --git a/linux/.gitignore b/linux/.gitignore deleted file mode 100644 index d3896c98..00000000 --- a/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt deleted file mode 100644 index 0592210c..00000000 --- a/linux/CMakeLists.txt +++ /dev/null @@ -1,128 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "android_tools") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.android_tools") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd0164..00000000 --- a/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index c438adf5..00000000 --- a/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,39 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include -#include -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "BitsdojoWindowPlugin"); - bitsdojo_window_plugin_register_with_registrar(bitsdojo_window_linux_registrar); - g_autoptr(FlPluginRegistrar) desktop_drop_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); - desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); - g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); - irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); - g_autoptr(FlPluginRegistrar) sentry_flutter_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin"); - sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar); - g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); - sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); - g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); - super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); -} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47b..00000000 --- a/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 0b4b05b3..00000000 --- a/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,31 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - bitsdojo_window_linux - desktop_drop - irondash_engine_context - sentry_flutter - sqlite3_flutter_libs - super_native_extensions - url_launcher_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc7..00000000 --- a/linux/runner/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the application ID. -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc deleted file mode 100644 index e7c5c543..00000000 --- a/linux/runner/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc deleted file mode 100644 index 4de35f1d..00000000 --- a/linux/runner/my_application.cc +++ /dev/null @@ -1,148 +0,0 @@ -#include "my_application.h" -#include - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView *view) -{ - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); -} - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "android_tools"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "android_tools"); - } - - auto bdw = bitsdojo_window_from(window); // <--- add this line - bdw->setCustomFrame(true); // <-- add this line - //gtk_window_set_default_size(window, 1280, 720); // <-- comment this line - gtk_widget_show(GTK_WIDGET(window)); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); - gtk_widget_realize(GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - //MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - //MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, - nullptr)); -} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h deleted file mode 100644 index 72271d5e..00000000 --- a/linux/runner/my_application.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore deleted file mode 100644 index 746adbb6..00000000 --- a/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index 4b81f9b2..00000000 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index b6c1832e..00000000 --- a/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1,8 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" -CODE_SIGNING_ALLOWED = NO -CODE_SIGNING_REQUIRED = NO -CODE_SIGN_IDENTITY = -CODE_SIGN_STYLE = Manual -DEVELOPMENT_TEAM = -PROVISIONING_PROFILE_SPECIFIER = \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index f5f28313..00000000 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import auto_updater_macos -import bitsdojo_window_macos -import desktop_drop -import device_info_plus -import file_picker -import irondash_engine_context -import package_info_plus -import pdfx -import sentry_flutter -import shared_preferences_foundation -import sqlite3_flutter_libs -import super_native_extensions -import url_launcher_macos -import webview_flutter_wkwebview - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin")) - BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) - DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) - DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) - FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) - IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin")) - SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) - SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) -} diff --git a/macos/Podfile b/macos/Podfile deleted file mode 100644 index ff5ddb3b..00000000 --- a/macos/Podfile +++ /dev/null @@ -1,42 +0,0 @@ -platform :osx, '10.15' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/macos/Podfile.lock b/macos/Podfile.lock deleted file mode 100644 index 06365e8c..00000000 --- a/macos/Podfile.lock +++ /dev/null @@ -1,59 +0,0 @@ -PODS: - - auto_updater_macos (0.0.1): - - FlutterMacOS - - Sparkle - - bitsdojo_window_macos (0.0.1): - - FlutterMacOS - - device_info_plus (0.0.1): - - FlutterMacOS - - FlutterMacOS (1.0.0) - - irondash_engine_context (0.0.1): - - FlutterMacOS - - pdfx (1.0.0): - - FlutterMacOS - - Sparkle (2.8.1) - - super_native_extensions (0.0.1): - - FlutterMacOS - -DEPENDENCIES: - - auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`) - - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`) - - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - - FlutterMacOS (from `Flutter/ephemeral`) - - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) - - pdfx (from `Flutter/ephemeral/.symlinks/plugins/pdfx/macos`) - - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) - -SPEC REPOS: - trunk: - - Sparkle - -EXTERNAL SOURCES: - auto_updater_macos: - :path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos - bitsdojo_window_macos: - :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos - device_info_plus: - :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos - FlutterMacOS: - :path: Flutter/ephemeral - irondash_engine_context: - :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos - pdfx: - :path: Flutter/ephemeral/.symlinks/plugins/pdfx/macos - super_native_extensions: - :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos - -SPEC CHECKSUMS: - auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 - bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 - device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba - pdfx: 1e79f57f7a6ce2f4a4c30f21fa54d3dc82441b51 - Sparkle: a346a4341537c625955751ed3ae4b340b68551fa - super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 - -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 - -COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index a4a6c439..00000000 --- a/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,864 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 4A5160E250D5CA3B511E3DEC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC7B8D60CADF404373F2F0B5 /* Pods_Runner.framework */; }; - 816784622EE4CABF005FAE9B /* adb in Resources */ = {isa = PBXBuildFile; fileRef = 816784612EE4CABF005FAE9B /* adb */; }; - 81AF13902F2A7FD6000B8C6D /* aapt in Resources */ = {isa = PBXBuildFile; fileRef = 81AF138F2F2A7FD6000B8C6D /* aapt */; }; - FB0ADC6AC66BEA5793E99629 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6675F52EC91FAB40364AFCE7 /* Pods_RunnerTests.framework */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* Android Tools.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Android Tools.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 639193A4291EB203D8065492 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 647964B403D92164A24E5956 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 6675F52EC91FAB40364AFCE7 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 77FA2CBB130E5B818102D5C8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 816784612EE4CABF005FAE9B /* adb */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = adb; sourceTree = ""; }; - 81AF138F2F2A7FD6000B8C6D /* aapt */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = aapt; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - C6B20A5305AAF31D983B0A47 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - CC7B8D60CADF404373F2F0B5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - CE04398F6570419E6EA23D05 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - E5C666404CBFE096EF586423 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - FB0ADC6AC66BEA5793E99629 /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - 4A5160E250D5CA3B511E3DEC /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 24ACA47CA18285837468D955 /* Pods */ = { - isa = PBXGroup; - children = ( - 639193A4291EB203D8065492 /* Pods-Runner.debug.xcconfig */, - C6B20A5305AAF31D983B0A47 /* Pods-Runner.release.xcconfig */, - CE04398F6570419E6EA23D05 /* Pods-Runner.profile.xcconfig */, - 647964B403D92164A24E5956 /* Pods-RunnerTests.debug.xcconfig */, - E5C666404CBFE096EF586423 /* Pods-RunnerTests.release.xcconfig */, - 77FA2CBB130E5B818102D5C8 /* Pods-RunnerTests.profile.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - 24ACA47CA18285837468D955 /* Pods */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* Android Tools.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 81AF138F2F2A7FD6000B8C6D /* aapt */, - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - 816784612EE4CABF005FAE9B /* adb */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - CC7B8D60CADF404373F2F0B5 /* Pods_Runner.framework */, - 6675F52EC91FAB40364AFCE7 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 5B0923D77351710495614390 /* [CP] Check Pods Manifest.lock */, - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 326D071E2E46EE63B27ED67E /* [CP] Check Pods Manifest.lock */, - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - 70FA1ED93E058C8CD3A80FA8 /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* Android Tools.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 816784622EE4CABF005FAE9B /* adb in Resources */, - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 81AF13902F2A7FD6000B8C6D /* aapt in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 326D071E2E46EE63B27ED67E /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; - 5B0923D77351710495614390 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 70FA1ED93E058C8CD3A80FA8 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 647964B403D92164A24E5956 /* Pods-RunnerTests.debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.androidTools.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/android_tools.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/android_tools"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = E5C666404CBFE096EF586423 /* Pods-RunnerTests.release.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.androidTools.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/android_tools.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/android_tools"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 77FA2CBB130E5B818102D5C8 /* Pods-RunnerTests.profile.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.androidTools.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/android_tools.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/android_tools"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = H9DTPYA8RY; - ENABLE_APP_SANDBOX = NO; - INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Android Tools"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = fr.thomasbernard03.androidtools; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = H9DTPYA8RY; - ENABLE_APP_SANDBOX = NO; - INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Android Tools"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = fr.thomasbernard03.androidtools; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - AUTOMATION_APPLE_EVENTS = NO; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = NO; - ENABLE_HARDENED_RUNTIME = NO; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; - ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; - ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; - INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Android Tools"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = fr.thomasbernard03.androidtools; - PROVISIONING_PROFILE_SPECIFIER = ""; - RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; - RUNTIME_EXCEPTION_ALLOW_JIT = NO; - RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; - RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; - RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; - RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index b248e548..00000000 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,22 +0,0 @@ -{ - "pins" : [ - { - "identity" : "csqlite", - "kind" : "remoteSourceControl", - "location" : "https://github.com/simolus3/CSQLite.git", - "state" : { - "revision" : "ae972b235e8b3c5af6d8f4e5bf18c800bdddb27e" - } - }, - { - "identity" : "sentry-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa", - "state" : { - "revision" : "9e193ac0b71760603aa666bad7e9e303dd7031a8", - "version" : "8.56.2" - } - } - ], - "version" : 2 -} diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 4435c20c..00000000 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc14..00000000 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index b248e548..00000000 --- a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,22 +0,0 @@ -{ - "pins" : [ - { - "identity" : "csqlite", - "kind" : "remoteSourceControl", - "location" : "https://github.com/simolus3/CSQLite.git", - "state" : { - "revision" : "ae972b235e8b3c5af6d8f4e5bf18c800bdddb27e" - } - }, - { - "identity" : "sentry-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa", - "state" : { - "revision" : "9e193ac0b71760603aa666bad7e9e303dd7031a8", - "version" : "8.56.2" - } - } - ], - "version" : 2 -} diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift deleted file mode 100644 index b3c17614..00000000 --- a/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_128x128.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_128x128.png deleted file mode 100644 index dbb1b563..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_128x128.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_128x128@2x.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_128x128@2x.png deleted file mode 100644 index 8916db88..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_128x128@2x.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_16x16.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_16x16.png deleted file mode 100644 index 72a3e933..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_16x16.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_16x16@2x.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_16x16@2x.png deleted file mode 100644 index a7d328f1..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_16x16@2x.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_256x256.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_256x256.png deleted file mode 100644 index 8916db88..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_256x256.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_256x256@2x.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_256x256@2x.png deleted file mode 100644 index 49b849c1..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_256x256@2x.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_32x32.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_32x32.png deleted file mode 100644 index a7d328f1..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_32x32.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_32x32@2x.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_32x32@2x.png deleted file mode 100644 index e910f9d2..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_32x32@2x.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_512x512.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_512x512.png deleted file mode 100644 index 49b849c1..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_512x512.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_512x512@2x.png b/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_512x512@2x.png deleted file mode 100644 index 18bcc1ff..00000000 Binary files a/macos/Runner/Assets.xcassets/ApkForeground.iconset/icon_512x512@2x.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index af132422..00000000 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "images" : [ - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "AppIcon512x512@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/macos/Runner/Assets.xcassets/Contents.json b/macos/Runner/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/macos/Runner/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a4..00000000 --- a/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index 1a61ff42..00000000 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = Android Tools - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.androidTools - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright Β© 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd94..00000000 --- a/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f495..00000000 --- a/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf47..00000000 --- a/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index 66c803c1..00000000 --- a/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,16 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - com.apple.security.app-sandbox - - - diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist deleted file mode 100644 index fced2e46..00000000 --- a/macos/Runner/Info.plist +++ /dev/null @@ -1,84 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - AndroidToolsPurpleIcon - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - SUPublicEDKey - z9EBcFEFrJ5Bi6oiODtO2k6fo7X61gTEKmiuG3+Qxwg= - SUFeedURL - https://raw.githubusercontent.com/ThomasBernard03/AndroidTools/refs/heads/main/appcast.xml - CFBundleDocumentTypes - - - CFBundleTypeIconFile - ApkForeground - CFBundleTypeIconSystemGenerated - 1 - CFBundleTypeName - Apk files - CFBundleTypeRole - Viewer - LSHandlerRank - Owner - LSItemContentTypes - - com.domain.apk - - - - UTExportedTypeDeclarations - - - UTTypeConformsTo - - public.data - - UTTypeDescription - Apk files - UTTypeIcons - - UTTypeIconBackgroundName - ApkBackground - UTTypeIconBadgeName - ApkForeground - UTTypeIconText - Apk - - UTTypeIdentifier - com.domain.apk - UTTypeTagSpecification - - public.filename-extension - - apk - - - - - - diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 07000525..00000000 --- a/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Cocoa -import FlutterMacOS -import FlutterMacOS -import bitsdojo_window_macos - -class MainFlutterWindow: BitsdojoWindow { - override func bitsdojo_window_configure() -> UInt { - return BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP - } - - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements deleted file mode 100644 index e4164fe2..00000000 --- a/macos/Runner/Release.entitlements +++ /dev/null @@ -1,16 +0,0 @@ - - - - - com.apple.security.files.user-selected.read-write - - com.apple.security.files.documents.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - com.apple.security.app-sandbox - - - diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1f..00000000 --- a/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/macos/aapt b/macos/aapt deleted file mode 100755 index 619005ff..00000000 Binary files a/macos/aapt and /dev/null differ diff --git a/macos/adb b/macos/adb deleted file mode 100755 index eec0200b..00000000 Binary files a/macos/adb and /dev/null differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..fbdc8a1b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4227 @@ +{ + "name": "android-tools", + "version": "2026.10.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "android-tools", + "version": "2026.10.3", + "dependencies": { + "@sentry/vue": "^11.4.0", + "@tauri-apps/api": "^2.8.0", + "vue": "^3.5.21" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@playwright/test": "^1.63.0", + "@tailwindcss/vite": "^4.1.13", + "@tauri-apps/cli": "^2.8.4", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/test-utils": "^2.4.6", + "@xmldom/xmldom": "^0.9.12", + "eslint": "^10.0.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-vue": "^10.4.0", + "globals": "^17.13.0", + "happy-dom": "^20.0.0", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.13", + "typescript": "~6.0.3", + "typescript-eslint": "^8.43.0", + "vite": "^8.3.2", + "vitest": "^5.0.3", + "vue-tsc": "^3.0.7" + }, + "engines": { + "node": ">=24.15.0 <25" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@one-ini/wasm": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", + "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sentry/browser": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-11.4.0.tgz", + "integrity": "sha512-jhq5t6nw8KdBq8i3Uyq5vBCHXehtEM+F5f/OoVxuPS2TtSbGizGjqzhAi24EmTNs4DRlQgYpKPdTOBDTMv49HA==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "11.4.0", + "@sentry/conventions": "^0.25.0", + "@sentry/core": "11.4.0", + "@sentry/feedback": "11.4.0", + "@sentry/replay": "11.4.0", + "@sentry/replay-canvas": "11.4.0" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-11.4.0.tgz", + "integrity": "sha512-RPQefmLDslO8KsRiSuXNXcReAhJy0rEVi2pbez5HpEn2adipuhgebwDMulac6RYoIUa8liFcBueru0ancW1vhA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.25.0", + "@sentry/core": "11.4.0", + "web-vitals": "^6.1.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.25.0.tgz", + "integrity": "sha512-7SouCAX3qx+AzLsxb6gbl3dKVWNhp2wFjdYQ/T90sGwqPPQUoZVA9YwsWwvx07hqcCiSqFW9mUAzyUftASnuhQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.4.0.tgz", + "integrity": "sha512-kAhK8XVJuzu1IKx9uSu04OISxTU9BcIyDVOWbY/ATUCYr5Z6L7GRBtOGOlYCbxEuPtdZCw5TrbphnBsyO9fuTA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.25.0" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + } + }, + "node_modules/@sentry/feedback": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-11.4.0.tgz", + "integrity": "sha512-vGcTGvNMgaL5J+LCwK+HVpubU/4/3TRAexVaJi0VcZmr9LnoGiZlWAQunjvWNsabvvkXs120WbXYc8nHysRNzw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "11.4.0" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + } + }, + "node_modules/@sentry/replay": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-11.4.0.tgz", + "integrity": "sha512-DdgAGaFLOyixQEWGuw8kKcGAVZV8RVVX2kMVV5WqSOMI9if7lhvXdhV+3L0JnZRX7X/6RBqYxYH/C1E0hK43qg==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "11.4.0", + "@sentry/core": "11.4.0" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-11.4.0.tgz", + "integrity": "sha512-zfsf90ckrOl/+Z4D4kg4MNY46V4jzUFSZolm+RcrdobS4qikE4xBMvDy1bKZHk+LKfj+K6dxjgZQbasgzHcZ6w==", + "license": "MIT", + "dependencies": { + "@sentry/core": "11.4.0", + "@sentry/replay": "11.4.0" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + } + }, + "node_modules/@sentry/vue": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-11.4.0.tgz", + "integrity": "sha512-/dsrf/8T0hHdnryf4Iw7srODOavOcsa6moj284xnqOTr88oFkNtE4j5qiQMK6jDUfkNwCjthBebYmPGjIGCYfg==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "11.4.0", + "@sentry/conventions": "^0.25.0", + "@sentry/core": "11.4.0" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "peerDependencies": { + "@tanstack/vue-router": "^1.64.0", + "pinia": "2.x || 3.x || 4.x", + "vue": "2.x || 3.x" + }, + "peerDependenciesMeta": { + "@tanstack/vue-router": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.1.tgz", + "integrity": "sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.1.tgz", + "integrity": "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.12.1", + "@tauri-apps/cli-darwin-x64": "2.12.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.1", + "@tauri-apps/cli-linux-arm64-musl": "2.12.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.1", + "@tauri-apps/cli-linux-x64-gnu": "2.12.1", + "@tauri-apps/cli-linux-x64-musl": "2.12.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.1", + "@tauri-apps/cli-win32-x64-msvc": "2.12.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.1.tgz", + "integrity": "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.1.tgz", + "integrity": "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.1.tgz", + "integrity": "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.1.tgz", + "integrity": "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.1.tgz", + "integrity": "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.1.tgz", + "integrity": "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.1.tgz", + "integrity": "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.1.tgz", + "integrity": "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.1.tgz", + "integrity": "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.1.tgz", + "integrity": "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.1.tgz", + "integrity": "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", + "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.12.tgz", + "integrity": "sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.1.tgz", + "integrity": "sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^2.0.0", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/editorconfig": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", + "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.11.1.tgz", + "integrity": "sha512-pasVXhl7wUmIvhujoqs/m0mJ9r59QW1IR9XXYKGR0FETzSM+Hhlf4qKIYgWiDDP+9YKv4YfbwskD7LqIdVcgQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.4", + "semver": "^7.8.5", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/happy-dom": { + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-beautify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", + "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.12.tgz", + "integrity": "sha512-HwCqmgTfLFskQc4P5TRnbduCMyPat2Ezu0elLWOGn0dD/6BKHP15B5bxce6aDi+E4kblPtru8amIBQzu0Di3qQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-tsc": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.12.tgz", + "integrity": "sha512-kbpaz18O9jPVkXVHD1BEjpUXM6WhXSu+62L1sGGr6Wbzd8GbvkBQWjoj3KZ/C0aq2MZllce7UdtWzwCIYsBMGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/web-vitals": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.2.tgz", + "integrity": "sha512-oto5x6dLEgrRqfcWed+pZEUb2q6ikbFmqF54CRDhI/QGbn+qxC49b4C4OkbH+kb9C3a8shpFD3SgR9kRcs80ZQ==", + "license": "Apache-2.0" + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..79040477 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "android-tools", + "version": "2026.10.3", + "private": true, + "type": "module", + "engines": { + "node": ">=24.15.0 <25" + }, + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "tauri": "tauri", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:release": "node --test scripts/release.test.mjs", + "test:e2e": "playwright test", + "lint": "eslint . --max-warnings 0", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "npm run format:check && npm run lint && npm run build && npm test && npm run test:release", + "check:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --locked -- -D warnings && cargo test --manifest-path src-tauri/Cargo.toml --locked" + }, + "dependencies": { + "@sentry/vue": "^11.4.0", + "@tauri-apps/api": "^2.8.0", + "vue": "^3.5.21" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@playwright/test": "^1.63.0", + "@tailwindcss/vite": "^4.1.13", + "@tauri-apps/cli": "^2.8.4", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/test-utils": "^2.4.6", + "@xmldom/xmldom": "^0.9.12", + "eslint": "^10.0.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-vue": "^10.4.0", + "globals": "^17.13.0", + "happy-dom": "^20.0.0", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.13", + "typescript": "~6.0.3", + "typescript-eslint": "^8.43.0", + "vite": "^8.3.2", + "vitest": "^5.0.3", + "vue-tsc": "^3.0.7" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..68899223 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + workers: 1, + use: { + baseURL: 'http://127.0.0.1:1421', + browserName: 'chromium', + viewport: { width: 1280, height: 900 }, + screenshot: 'only-on-failure', + }, + webServer: { + command: 'npm run dev -- --port 1421', + url: 'http://127.0.0.1:1421', + reuseExistingServer: false, + }, +}); diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index 280a6626..00000000 --- a/pubspec.lock +++ /dev/null @@ -1,1434 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" - url: "https://pub.dev" - source: hosted - version: "92.0.0" - aapt_dart: - dependency: "direct main" - description: - name: aapt_dart - sha256: "80affe91a16a94dc1d8493552b791c93b8a76ed7478afd93b7f82313331a5996" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - adb_dart: - dependency: "direct main" - description: - name: adb_dart - sha256: cc7815a521da191344a11cfe9a931ba975cd94c25888f878871040f0aedb85a3 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" - url: "https://pub.dev" - source: hosted - version: "9.0.0" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - archive: - dependency: transitive - description: - name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" - url: "https://pub.dev" - source: hosted - version: "4.0.7" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - auto_updater: - dependency: "direct main" - description: - name: auto_updater - sha256: "74fd008b021d15e4fc50fb5f1923870e6374ae831bb1168241c23bc35a4e898b" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - auto_updater_macos: - dependency: transitive - description: - name: auto_updater_macos - sha256: ef9de0daf38d782d2243fe131503a9404d62df762f5386e5360020e43c01867f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - auto_updater_platform_interface: - dependency: transitive - description: - name: auto_updater_platform_interface - sha256: ed5dff8cea18c58bc5ac787ff9122fedd1644272e02015d28c9b592f8078c5dc - url: "https://pub.dev" - source: hosted - version: "1.0.0" - auto_updater_windows: - dependency: transitive - description: - name: auto_updater_windows - sha256: "2bba20a71eee072f49b7267fedd5c4f1406c4b1b1e5b83932c634dbab75b80c9" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - bitsdojo_window: - dependency: "direct main" - description: - name: bitsdojo_window - sha256: "88ef7765dafe52d97d7a3684960fb5d003e3151e662c18645c1641c22b873195" - url: "https://pub.dev" - source: hosted - version: "0.1.6" - bitsdojo_window_linux: - dependency: transitive - description: - name: bitsdojo_window_linux - sha256: "9519c0614f98be733e0b1b7cb15b827007886f6fe36a4fb62cf3d35b9dd578ab" - url: "https://pub.dev" - source: hosted - version: "0.1.4" - bitsdojo_window_macos: - dependency: transitive - description: - name: bitsdojo_window_macos - sha256: f7c5be82e74568c68c5b8449e2c5d8fd12ec195ecd70745a7b9c0f802bb0268f - url: "https://pub.dev" - source: hosted - version: "0.1.4" - bitsdojo_window_platform_interface: - dependency: transitive - description: - name: bitsdojo_window_platform_interface - sha256: "65daa015a0c6dba749bdd35a0f092e7a8ba8b0766aa0480eb3ef808086f6e27c" - url: "https://pub.dev" - source: hosted - version: "0.1.2" - bitsdojo_window_windows: - dependency: transitive - description: - name: bitsdojo_window_windows - sha256: fa982cf61ede53f483e50b257344a1c250af231a3cdc93a7064dd6dc0d720b68 - url: "https://pub.dev" - source: hosted - version: "0.1.6" - bloc: - dependency: transitive - description: - name: bloc - sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 - url: "https://pub.dev" - source: hosted - version: "9.2.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: ac78098de97893812b7aff1154f29008fa2464cad9e8e7044d39bc905dad4fbc - url: "https://pub.dev" - source: hosted - version: "2.11.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" - url: "https://pub.dev" - source: hosted - version: "8.12.3" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - console: - dependency: transitive - description: - name: console - sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a - url: "https://pub.dev" - source: hosted - version: "4.1.0" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - convert_object: - dependency: transitive - description: - name: convert_object - sha256: a88cf2501cbe150c4c1e3f58787ba8fdf912c04cc470ba6d2aceeb9836dd36dd - url: "https://pub.dev" - source: hosted - version: "1.0.4" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" - url: "https://pub.dev" - source: hosted - version: "0.3.5+2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - dart_mappable: - dependency: "direct main" - description: - name: dart_mappable - sha256: "0e219930c9f7b9e0f14ae7c1de931c401875110fd5c67975b6b9492a6d3a531b" - url: "https://pub.dev" - source: hosted - version: "4.6.1" - dart_mappable_builder: - dependency: "direct dev" - description: - name: dart_mappable_builder - sha256: "27e8f109eb8fbff03eeeae61f8868a244ad28c8da5a8e429938aa039ae4ee32e" - url: "https://pub.dev" - source: hosted - version: "4.6.4" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b - url: "https://pub.dev" - source: hosted - version: "3.1.3" - dbus: - dependency: transitive - description: - name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.dev" - source: hosted - version: "0.7.12" - desktop_drop: - dependency: "direct main" - description: - name: desktop_drop - sha256: e70b46b2d61f1af7a81a40d1f79b43c28a879e30a4ef31e87e9c27bea4d784e8 - url: "https://pub.dev" - source: hosted - version: "0.7.0" - device_info_plus: - dependency: transitive - description: - name: device_info_plus - sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 - url: "https://pub.dev" - source: hosted - version: "10.1.2" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f - url: "https://pub.dev" - source: hosted - version: "7.0.3" - drift: - dependency: "direct main" - description: - name: drift - sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" - url: "https://pub.dev" - source: hosted - version: "2.31.0" - drift_dev: - dependency: "direct dev" - description: - name: drift_dev - sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" - url: "https://pub.dev" - source: hosted - version: "2.31.0" - drift_flutter: - dependency: "direct main" - description: - name: drift_flutter - sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7 - url: "https://pub.dev" - source: hosted - version: "0.2.8" - equatable: - dependency: "direct main" - description: - name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" - url: "https://pub.dev" - source: hosted - version: "2.0.8" - extension: - dependency: transitive - description: - name: extension - sha256: be3a6b7f8adad2f6e2e8c63c895d19811fcf203e23466c6296267941d0ff4f24 - url: "https://pub.dev" - source: hosted - version: "0.6.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c - url: "https://pub.dev" - source: hosted - version: "2.1.5" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" - url: "https://pub.dev" - source: hosted - version: "10.3.10" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_bloc: - dependency: "direct main" - description: - name: flutter_bloc - sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 - url: "https://pub.dev" - source: hosted - version: "9.1.1" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_monaco: - dependency: "direct main" - description: - name: flutter_monaco - sha256: "6e500d2595b2c0b83f96f449a6923e103b36d716ab872595015c9c6481eca9dd" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 - url: "https://pub.dev" - source: hosted - version: "2.0.33" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - get_it: - dependency: "direct main" - description: - name: get_it - sha256: "1d648d2dd2047d7f7450d5727ca24ee435f240385753d90b49650e3cdff32e56" - url: "https://pub.dev" - source: hosted - version: "9.2.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - globbing: - dependency: transitive - description: - name: globbing - sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hooks: - dependency: transitive - description: - name: hooks - sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: "direct main" - description: - name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" - url: "https://pub.dev" - source: hosted - version: "4.7.2" - injector: - dependency: transitive - description: - name: injector - sha256: ed389bed5b48a699d5b9561c985023d0d5cc88dd5ff2237aadcce5a5ab433e4e - url: "https://pub.dev" - source: hosted - version: "3.0.0" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - irondash_engine_context: - dependency: transitive - description: - name: irondash_engine_context - sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" - url: "https://pub.dev" - source: hosted - version: "0.5.5" - irondash_message_channel: - dependency: transitive - description: - name: irondash_message_channel - sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 - url: "https://pub.dev" - source: hosted - version: "0.7.0" - jni: - dependency: transitive - description: - name: jni - sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 - url: "https://pub.dev" - source: hosted - version: "0.14.2" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" - url: "https://pub.dev" - source: hosted - version: "4.10.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logger: - dependency: "direct main" - description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 - url: "https://pub.dev" - source: hosted - version: "2.6.2" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - msix: - dependency: "direct dev" - description: - name: msix - sha256: b6b08e7a7b5d1845f2b1d31216d5b1fb558e98251efefe54eb79ed00d27bc2ac - url: "https://pub.dev" - source: hosted - version: "3.16.13" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" - url: "https://pub.dev" - source: hosted - version: "0.17.4" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d - url: "https://pub.dev" - source: hosted - version: "9.0.0" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - path: - dependency: "direct main" - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e - url: "https://pub.dev" - source: hosted - version: "2.2.22" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - pdfx: - dependency: "direct main" - description: - name: pdfx - sha256: "29db9b71d46bf2335e001f91693f2c3fbbf0760e4c2eb596bf4bafab211471c1" - url: "https://pub.dev" - source: hosted - version: "2.9.2" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - photo_view: - dependency: transitive - description: - name: photo_view - sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" - url: "https://pub.dev" - source: hosted - version: "0.15.0" - pixel_snap: - dependency: transitive - description: - name: pixel_snap - sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" - url: "https://pub.dev" - source: hosted - version: "0.1.5" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" - url: "https://pub.dev" - source: hosted - version: "6.0.3" - process: - dependency: transitive - description: - name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 - url: "https://pub.dev" - source: hosted - version: "5.0.5" - properties: - dependency: transitive - description: - name: properties - sha256: "333f427dd4ed07bdbe8c75b9ff864a1e70b5d7a8426a2e8bdd457b65ae5ac598" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - provider: - dependency: transitive - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - recase: - dependency: transitive - description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.dev" - source: hosted - version: "4.1.0" - rxdart: - dependency: "direct main" - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - sentry: - dependency: transitive - description: - name: sentry - sha256: "80b2a6667db8e0bb148ad70b2af242eb6aa8da9b56b56e72332a4221d93bc01d" - url: "https://pub.dev" - source: hosted - version: "9.12.0" - sentry_dart_plugin: - dependency: "direct dev" - description: - name: sentry_dart_plugin - sha256: "514cd5cc5c022bed9c232d08dc126a081b8a965dbad78b819ae91bf3a06e622c" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - sentry_flutter: - dependency: "direct main" - description: - name: sentry_flutter - sha256: df09c63e0111a9ad5c4a13a89def67327fc2028bec0f55c246d8c083d486e418 - url: "https://pub.dev" - source: hosted - version: "9.12.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 - url: "https://pub.dev" - source: hosted - version: "2.4.23" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" - url: "https://pub.dev" - source: hosted - version: "4.2.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" - url: "https://pub.dev" - source: hosted - version: "2.9.4" - sqlite3_flutter_libs: - dependency: transitive - description: - name: sqlite3_flutter_libs - sha256: "1e800ebe7f85a80a66adacaa6febe4d5f4d8b75f244e9838a27cb2ffc7aec08d" - url: "https://pub.dev" - source: hosted - version: "0.5.41" - sqlparser: - dependency: transitive - description: - name: sqlparser - sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" - url: "https://pub.dev" - source: hosted - version: "0.43.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - super_clipboard: - dependency: "direct main" - description: - name: super_clipboard - sha256: "4a6ae6dfaa282ec1f2bff750976f535517ed8ca842d5deae13985eb11c00ac1f" - url: "https://pub.dev" - source: hosted - version: "0.8.24" - super_native_extensions: - dependency: transitive - description: - name: super_native_extensions - sha256: a433bba8186cd6b707560c42535bf284804665231c00bca86faf1aa4968b7637 - url: "https://pub.dev" - source: hosted - version: "0.8.24" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 - url: "https://pub.dev" - source: hosted - version: "3.4.0" - system_info2: - dependency: transitive - description: - name: system_info2 - sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43 - url: "https://pub.dev" - source: hosted - version: "4.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - type_plus: - dependency: transitive - description: - name: type_plus - sha256: d5d1019471f0d38b91603adb9b5fd4ce7ab903c879d2fbf1a3f80a630a03fcc9 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - universal_platform: - dependency: transitive - description: - name: universal_platform - sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" - url: "https://pub.dev" - source: hosted - version: "6.3.28" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad - url: "https://pub.dev" - source: hosted - version: "6.3.6" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f - url: "https://pub.dev" - source: hosted - version: "2.4.2" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - uuid: - dependency: transitive - description: - name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 - url: "https://pub.dev" - source: hosted - version: "4.5.2" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" - url: "https://pub.dev" - source: hosted - version: "1.1.20" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" - source: hosted - version: "15.0.2" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webview_flutter: - dependency: transitive - description: - name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 - url: "https://pub.dev" - source: hosted - version: "4.13.1" - webview_flutter_android: - dependency: transitive - description: - name: webview_flutter_android - sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510 - url: "https://pub.dev" - source: hosted - version: "4.10.11" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" - url: "https://pub.dev" - source: hosted - version: "2.14.0" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: "0412b657a2828fb301e73509909e6ec02b77cd2b441ae9f77125d482b3ddf0e7" - url: "https://pub.dev" - source: hosted - version: "3.23.6" - webview_windows: - dependency: transitive - description: - name: webview_windows - sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730" - url: "https://pub.dev" - source: hosted - version: "0.4.0" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" - url: "https://pub.dev" - source: hosted - version: "1.1.5" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.10.8 <4.0.0" - flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml deleted file mode 100644 index ed8fd8f6..00000000 --- a/pubspec.yaml +++ /dev/null @@ -1,102 +0,0 @@ -name: android_tools -description: "A new Flutter project." -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -version: 2026.06.1+35 - -environment: - sdk: ^3.10.8 - -dependencies: - sentry_flutter: ^9.9.0 - flutter: - sdk: flutter - adb_dart: ^1.3.0 - aapt_dart: ^1.1.0 - super_clipboard: ^0.8.0 - image: ^4.0.0 - cupertino_icons: ^1.0.8 - path_provider: ^2.1.5 - dart_mappable: ^4.6.1 - flutter_bloc: ^9.1.1 - get_it: ^9.2.0 - flutter_svg: ^2.2.3 - logger: ^2.6.2 - equatable: ^2.0.7 - rxdart: ^0.28.0 - path: ^1.9.1 - desktop_drop: ^0.7.0 - file_picker: ^10.3.7 - url_launcher: ^6.3.2 - package_info_plus: ^9.0.0 - bitsdojo_window: ^0.1.6 - auto_updater: ^1.0.0 - intl: ^0.20.2 - flutter_monaco: ^1.4.0 - pdfx: ^2.7.0 - drift: ^2.30.1 - drift_flutter: ^0.2.8 - shared_preferences: ^2.3.4 - -dev_dependencies: - sentry_dart_plugin: ^3.2.0 - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - build_runner: ^2.10.4 - dart_mappable_builder: ^4.6.1 - drift_dev: ^2.30.1 - msix: ^3.16.7 - -flutter: - uses-material-design: true - - assets: - - assets/android_versions/ - - assets/images/ - - assets/images/file/ - - assets/images/file_extensions/ - - assets/images/folder/ - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - fonts: - - family: Nothing - fonts: - - asset: assets/fonts/nothing.otf - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package - -sentry: - upload_debug_symbols: true - upload_source_maps: true - project: android-tools - org: thomas-bernard - -msix_config: - display_name: Android Tools - publisher_display_name: Thomas Bernard - publisher: CN=Thomas Bernard - identity_name: ThomasBernard.AndroidTools - msix_version: 1.0.0.0 - logo_path: windows/runner/resources/app_icon.ico - capabilities: internetClient \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..c9901407 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.98.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 00000000..7cc1f802 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,196 @@ +// Pure release metadata helpers. Build, signing and publication stay in the workflow. +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import { createPublicKey, verify } from 'node:crypto'; +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const sparkle = 'http://www.andymatuschak.org/xml-namespaces/sparkle'; +const repository = 'https://github.com/ThomasBernard03/AndroidTools'; +const publicKey = 'z9EBcFEFrJ5Bi6oiODtO2k6fo7X61gTEKmiuG3+Qxwg='; + +export function verifyArchive(bytes, signature, key = publicKey) { + // Sparkle stores the raw Ed25519 key; Node expects an RFC 8410 SPKI wrapper. + const wrappedKey = createPublicKey({ + key: Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), + Buffer.from(key, 'base64'), + ]), + format: 'der', + type: 'spki', + }); + if (!verify(null, bytes, wrappedKey, Buffer.from(signature, 'base64'))) { + throw new Error( + 'The DMG signature does not match the historical Sparkle public key', + ); + } +} + +function parseFeed(xml) { + const document = new DOMParser({ + onError: (level, message) => { + throw new Error(`Invalid appcast (${level}): ${message}`); + }, + }).parseFromString(xml, 'application/xml'); + const channels = document.getElementsByTagName('channel'); + if (channels.length !== 1) throw new Error('Expected one appcast channel'); + return { document, channel: channels[0] }; +} + +function integer(value) { + if ( + !/^[1-9]\d*$/.test(String(value)) || + !Number.isSafeInteger(Number(value)) + ) { + throw new Error('The Sparkle build must be a positive integer'); + } + return Number(value); +} + +export function releaseVersion(config) { + const match = /^(\d{4})\.([1-9]|1[0-2])\.(0|[1-9]\d*)$/.exec(config.version); + if (!match) throw new Error('Use calendar SemVer, for example 2026.6.1'); + return { + version: `${match[1]}.${match[2].padStart(2, '0')}.${match[3]}`, + build: integer(config.bundle.macOS.bundleVersion), + minimumSystemVersion: config.bundle.macOS.minimumSystemVersion, + }; +} + +export function prepare(config, xml) { + const release = releaseVersion(config); + const { channel } = parseFeed(xml); + let latest = 0; + let alreadyPublished = false; + for (const item of Array.from(channel.getElementsByTagName('item'))) { + const build = integer( + item.getElementsByTagNameNS(sparkle, 'version')[0]?.textContent, + ); + const version = item.getElementsByTagNameNS( + sparkle, + 'shortVersionString', + )[0]?.textContent; + latest = Math.max(latest, build); + if (build === release.build || version === release.version) { + if (build !== release.build || version !== release.version) { + throw new Error('A published version or build cannot be reused'); + } + alreadyPublished = true; + } + } + if (release.build < latest) + throw new Error('Build is older than the published appcast'); + return { ...release, needed: !alreadyPublished }; +} + +export function updateFeed(config, xml, metadata) { + const release = prepare(config, xml); + if ( + metadata.version !== release.version || + metadata.build !== release.build + ) { + throw new Error('Artifact metadata does not match the release'); + } + if (!/^[A-Za-z0-9+/]{86}==$/.test(metadata.signature)) { + throw new Error('Invalid Sparkle Ed25519 signature'); + } + integer(metadata.length); + if (!Number.isFinite(Date.parse(metadata.pubDate))) + throw new Error('Invalid publication date'); + if (!release.needed) return xml; + const { document, channel } = parseFeed(xml); + const item = document.createElement('item'); + const values = { + title: `Version ${release.version}`, + 'sparkle:version': String(release.build), + 'sparkle:shortVersionString': release.version, + 'sparkle:minimumSystemVersion': release.minimumSystemVersion, + 'sparkle:releaseNotesLink': `${repository}/blob/main/CHANGELOG.md`, + pubDate: metadata.pubDate, + }; + for (const [name, value] of Object.entries(values)) { + const element = document.createElementNS( + name.startsWith('sparkle:') ? sparkle : null, + name, + ); + element.appendChild(document.createTextNode(value)); + item.appendChild(document.createTextNode('\n ')); + item.appendChild(element); + } + const enclosure = document.createElement('enclosure'); + enclosure.setAttribute( + 'url', + `${repository}/releases/download/${release.version}/AndroidTools-${release.version}-macos.dmg`, + ); + enclosure.setAttributeNS(sparkle, 'sparkle:edSignature', metadata.signature); + enclosure.setAttributeNS(sparkle, 'sparkle:os', 'macos'); + enclosure.setAttribute('length', String(metadata.length)); + enclosure.setAttribute('type', 'application/octet-stream'); + item.appendChild(document.createTextNode('\n ')); + item.appendChild(enclosure); + item.appendChild(document.createTextNode('\n ')); + const first = channel.getElementsByTagName('item')[0] ?? null; + channel.insertBefore(item, first); + channel.insertBefore(document.createTextNode('\n '), first); + return new XMLSerializer().serializeToString(document); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const config = JSON.parse(readFileSync('src-tauri/tauri.conf.json', 'utf8')); + const packageVersion = JSON.parse( + readFileSync('package.json', 'utf8'), + ).version; + const cargoVersion = /^version = "([^"]+)"$/m.exec( + readFileSync('src-tauri/Cargo.toml', 'utf8'), + )?.[1]; + if (config.version !== packageVersion || config.version !== cargoVersion) { + throw new Error('Tauri, npm and Cargo versions must match'); + } + const xml = readFileSync('appcast.xml', 'utf8'); + switch (process.argv[2]) { + case 'prepare': { + const result = prepare(config, xml); + const output = Object.entries(result) + .map(([key, value]) => `${key}=${value}\n`) + .join(''); + if (process.env.GITHUB_OUTPUT) + appendFileSync(process.env.GITHUB_OUTPUT, output); + process.stdout.write(output); + break; + } + case 'metadata': { + const signature = /sparkle:edSignature="([^"]+)"/.exec( + process.env.SIGNATURE_OUTPUT ?? '', + )?.[1]; + const metadata = { + ...releaseVersion(config), + signature, + length: Number(process.env.FILE_SIZE), + pubDate: new Date().toUTCString(), + }; + updateFeed(config, xml, metadata); + const archive = readFileSync( + `AndroidTools-${metadata.version}-macos.dmg`, + ); + if (archive.length !== metadata.length) + throw new Error('Incorrect DMG length'); + verifyArchive(archive, metadata.signature); + writeFileSync( + 'release-metadata.json', + JSON.stringify(metadata, null, 2) + '\n', + ); + break; + } + case 'feed': { + const metadata = JSON.parse( + readFileSync('release-metadata.json', 'utf8'), + ); + writeFileSync('appcast.xml', updateFeed(config, xml, metadata)); + break; + } + default: + throw new Error('Expected prepare, metadata or feed'); + } +} diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 00000000..8a1a8818 --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + prepare, + releaseVersion, + updateFeed, + verifyArchive, +} from './release.mjs'; + +const xml = readFileSync(new URL('../appcast.xml', import.meta.url), 'utf8'); +const config = { + version: '2027.6.1', + bundle: { macOS: { bundleVersion: '100', minimumSystemVersion: '10.13' } }, +}; +const metadata = { + version: '2027.06.1', + build: 100, + signature: Buffer.alloc(64).toString('base64'), + length: 123456, + pubDate: 'Mon, 05 Oct 2026 12:00:00 GMT', +}; + +test('rejects a changed archive or a different Sparkle signing key', () => { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const rawKey = publicKey + .export({ format: 'der', type: 'spki' }) + .subarray(-32) + .toString('base64'); + const archive = Buffer.from('simulated DMG'); + const signature = sign(null, archive, privateKey).toString('base64'); + verifyArchive(archive, signature, rawKey); + assert.throws(() => + verifyArchive(Buffer.from('changed DMG'), signature, rawKey), + ); + assert.throws(() => verifyArchive(archive, signature)); +}); + +test( + 'verifies signatures emitted by the pinned Sparkle tool', + { + skip: !process.env.SPARKLE_SIGN_TOOL, + }, + () => { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const seed = privateKey + .export({ format: 'der', type: 'pkcs8' }) + .subarray(-32); + const rawKey = publicKey + .export({ format: 'der', type: 'spki' }) + .subarray(-32) + .toString('base64'); + const directory = mkdtempSync(join(tmpdir(), 'sparkle-sign-test-')); + try { + const bytes = Buffer.from('deterministic update archive fixture'); + const path = join(directory, 'update.zip'); + writeFileSync(path, bytes); + const output = execFileSync( + process.env.SPARKLE_SIGN_TOOL, + ['--ed-key-file', '-', path], + { + input: seed.toString('base64'), + encoding: 'utf8', + }, + ); + const signature = /sparkle:edSignature="([^"]+)"/.exec(output)?.[1]; + assert.ok(signature); + verifyArchive(bytes, signature, rawKey); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); + +test('calendar SemVer preserves historical zero-padded release tags', () => { + assert.equal(releaseVersion(config).version, '2027.06.1'); + assert.throws(() => releaseVersion({ ...config, version: '2027.06.1' })); + assert.throws(() => + releaseVersion({ ...config, bundle: { macOS: { bundleVersion: '0' } } }), + ); +}); + +test('appcast retains history and advertises the signed artifact with its minimum OS', () => { + const result = updateFeed(config, xml, metadata); + assert.equal( + (result.match(//g) ?? []).length, + (xml.match(//g) ?? []).length + 1, + ); + assert.ok( + result.indexOf('Version 2027.06.1') < result.indexOf('Version 2026.10.2'), + ); + assert.ok( + result.includes( + 'releases/download/2027.06.1/AndroidTools-2027.06.1-macos.dmg', + ), + ); + assert.ok( + result.includes( + '10.13', + ), + ); + assert.ok(result.includes(`sparkle:edSignature="${metadata.signature}"`)); + assert.ok(result.includes('length="123456"')); + assert.equal(prepare(config, result).needed, false); + assert.equal(updateFeed(config, result, metadata), result); +}); + +test('rejects reused builds, reused versions and downgrades', () => { + const result = updateFeed(config, xml, metadata); + assert.throws( + () => prepare({ ...config, version: '2027.6.2' }, result), + /reused/, + ); + assert.throws( + () => + prepare( + { ...config, bundle: { macOS: { bundleVersion: '101' } } }, + result, + ), + /reused/, + ); + assert.throws( + () => + prepare( + { + ...config, + version: '2027.5.1', + bundle: { macOS: { bundleVersion: '99' } }, + }, + result, + ), + /older/, + ); +}); + +test('rejects malformed feeds and mismatched or invalid artifact metadata', () => { + assert.throws(() => prepare(config, ''), /channel/); + assert.throws(() => prepare(config, '')); + for (const patch of [ + { build: 101 }, + { version: '2027.06.2' }, + { signature: '' }, + { length: 0 }, + { pubDate: 'invalid' }, + ]) { + assert.throws(() => updateFeed(config, xml, { ...metadata, ...patch })); + } +}); diff --git a/scripts/setup-sparkle.sh b/scripts/setup-sparkle.sh new file mode 100644 index 00000000..65a2cb03 --- /dev/null +++ b/scripts/setup-sparkle.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Match the framework version used by tauri-plugin-sparkle-updater 0.3.0. +ROOT="$(git rev-parse --show-toplevel)" +VERSION=2.9.6 +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT +curl --fail --location --retry 3 \ + "https://github.com/sparkle-project/Sparkle/releases/download/$VERSION/Sparkle-$VERSION.tar.xz" \ + --output "$TEMP_DIR/sparkle.tar.xz" +tar -xf "$TEMP_DIR/sparkle.tar.xz" -C "$TEMP_DIR" +ditto "$TEMP_DIR/Sparkle.framework" "$ROOT/src-tauri/Sparkle.framework" +mkdir -p "$ROOT/src-tauri/sparkle-tools" +ditto "$TEMP_DIR/bin" "$ROOT/src-tauri/sparkle-tools" diff --git a/scripts/verify-macos-bundle.sh b/scripts/verify-macos-bundle.sh new file mode 100644 index 00000000..2a860313 --- /dev/null +++ b/scripts/verify-macos-bundle.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/Android Tools.app" +PLIST="$APP/Contents/Info.plist" +read_plist() { /usr/libexec/PlistBuddy -c "Print :$1" "$PLIST"; } +test "$(read_plist CFBundleIdentifier)" = 'com.example.androidTools' +test "$(read_plist CFBundleShortVersionString)" = "$(node -p "require('./src-tauri/tauri.conf.json').version")" +test "$(read_plist CFBundleVersion)" = "$(node -p "require('./src-tauri/tauri.conf.json').bundle.macOS.bundleVersion")" +test "$(read_plist LSMinimumSystemVersion)" = "$(node -p "require('./src-tauri/tauri.conf.json').bundle.macOS.minimumSystemVersion")" +test "$(read_plist SUFeedURL)" = 'https://raw.githubusercontent.com/ThomasBernard03/AndroidTools/refs/heads/main/appcast.xml' +test "$(read_plist SUPublicEDKey)" = 'z9EBcFEFrJ5Bi6oiODtO2k6fo7X61gTEKmiuG3+Qxwg=' +test "$(read_plist SUEnableAutomaticChecks)" = 'true' +test -d "$APP/Contents/Frameworks/Sparkle.framework" +EXECUTABLE="$APP/Contents/MacOS/$(read_plist CFBundleExecutable)" +lipo "$EXECUTABLE" -verify_arch arm64 x86_64 +for ARCH in arm64 x86_64; do + if [ "$ARCH" = arm64 ]; then TARGET=aarch64-apple-darwin; else TARGET=x86_64-apple-darwin; fi + SYMBOLS="src-tauri/target/$TARGET/release/android-tools.dSYM" + test -d "$SYMBOLS" + BINARY_UUID=$(xcrun dwarfdump --uuid --arch "$ARCH" "$EXECUTABLE" | cut -d' ' -f2) + SYMBOL_UUID=$(xcrun dwarfdump --uuid "$SYMBOLS" | cut -d' ' -f2) + test -n "$BINARY_UUID" + test "$BINARY_UUID" = "$SYMBOL_UUID" + otool -arch "$ARCH" -l "$EXECUTABLE" | grep -F '@executable_path/../Frameworks' + FLAGS=$(codesign --display --verbose=4 --arch "$ARCH" "$EXECUTABLE" 2>&1) + # Ad-hoc signed frameworks have no Apple Team ID: library validation must stay off. + if [[ "$FLAGS" == *runtime* || "$FLAGS" == *library-validation* ]]; then + echo 'Ad-hoc releases must not enforce hardened runtime or library validation.' >&2 + exit 1 + fi +done +codesign --verify --deep --strict "$APP" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 00000000..e77e0243 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,7215 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tools" +version = "2026.10.3" +dependencies = [ + "apk-info", + "apksig", + "chrono", + "flexi_logger", + "imagesize", + "jks", + "log", + "openssl", + "resvg", + "roxmltree", + "rsa", + "rsadb", + "rusb", + "sentry", + "serde", + "serde_json", + "svgtypes", + "tauri", + "tauri-build", + "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", + "tauri-plugin-opener", + "tauri-plugin-sparkle-updater", + "tempfile", + "tokio", + "zeroize", + "zip", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "apk-info" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c23c472d49d15923246ec76ef1198aca55ca945c009fc5b71556453cb1ca58c3" +dependencies = [ + "apk-info-axml", + "apk-info-xml", + "apk-info-zip", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "apk-info-axml" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5b728c1b100e814602cf9ea0f78bd06cdae15b338efac82b4912a1829ee4ed2" +dependencies = [ + "apk-info-xml", + "bitflags 2.13.2", + "log", + "phf 0.14.0", + "phf_codegen 0.14.0", + "serde", + "serde_json", + "thiserror 2.0.21", + "winnow 1.0.4", +] + +[[package]] +name = "apk-info-xml" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a868f137d0dbaade83da5c4700fd9aef2dfe439fac1763fdb925e5054452a65" + +[[package]] +name = "apk-info-zip" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d67f7c81a64e4617650f87f953cd031e4a32a98e462125a1b5a4c2d478b5bbc8" +dependencies = [ + "ahash", + "cms", + "crc32fast", + "flate2", + "log", + "md-5 0.11.0", + "memchr", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "thiserror 2.0.21", + "winnow 1.0.4", + "x509-cert", +] + +[[package]] +name = "apksig" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329d9c120f16f97522a37cce6d3e7939ba253125257a682e52df25ace736840d" +dependencies = [ + "md-5 0.10.6", + "rsa", + "sha1 0.10.7", + "sha2 0.10.9", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.6+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cms" +version = "0.3.0-pre.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986e7713348fbe5322df18082d73a0ad53a2946dbb7a02452cf3622b83b91e7c" +dependencies = [ + "const-oid 0.10.2", + "der 0.8.2", + "spki 0.8.0", + "x509-cert", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core_affinity" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" +dependencies = [ + "libc", + "num_cpus", + "winapi", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "der_derive", + "flagset", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.6+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "flexi_logger" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4945fa9cdfd7571192cf559c186d45ab36c8034482a2d217a19bdc566f190cc" +dependencies = [ + "chrono", + "core_affinity", + "crossbeam-channel", + "crossbeam-queue", + "log", + "thiserror 2.0.21", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "io-kit-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d3a048d09fbb6597dbf7c69f40d14df4a49487db1487191618c893fc3b1c26" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jks" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e03966fd15eea3cb2886320a78d01e77f8aaeabd3fb01504ee6a2238876c23bc" +dependencies = [ + "asn1-rs", + "rand 0.8.8", + "sha1 0.10.7", + "thiserror 1.0.69", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nusb" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18ef13beb3b3a8fc16fd7aea912ebd3d45dde00a9a5b968d0742297468065845" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "js-sys", + "linux-raw-sys", + "log", + "once_cell", + "rustix", + "slab", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml 0.42.0", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rsadb" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4632861fead2e09377432f38c9c01d7c0db4f5fda1f55d19dd5385bb203654f5" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "nusb", + "rand 0.8.8", + "rsa", + "sha1 0.10.7", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "sentry" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa951f4e644464ebfd2347a7ab03b0828c512d2448a76262cad2607b343dff4" +dependencies = [ + "cfg_aliases", + "httpdate", + "reqwest", + "rustls", + "sentry-contexts", + "sentry-core", + "sentry-panic", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f27cf048aa63ccd5bc94c29989133eabb82e9c6bf7441b58e9d4a95837a105" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ed680585dbdacdf9859c2b4dc53358ff02bd256766882e22e343836db36b22" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ed398494bb8fc412a11a182c1883a10ae147587b8f6314abd222d1d626f31b" +dependencies = [ + "rand 0.10.3", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-panic" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7153e68642b761ed3aa62c022795c11b2facc728b2db7484084492373c944f9" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-types" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c18bf7b159bdfc7c8f97816f476b4947a1937fb6bea9d38d3479e58631614ac" +dependencies = [ + "debugid", + "hex", + "rand 0.10.3", + "serde", + "serde_json", + "thiserror 2.0.21", + "time", + "url", + "uuid", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "sparkle-updater" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d60731c5488e4f3230adcb49041f8f03a49fd546765c896290e07fa3baf949" +dependencies = [ + "block2", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.18.1", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333d3b6beeb1c2c7fc040233d4ea7b6c03a9f8f039592b7400833c43fb5b568a" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.6+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39404b2572927e0d3ed30a7ca226037f7508d22e79c96ee6ef0d42b6a4b3345" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-sparkle-updater" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf83d807572fb7f2865cf38e248481b6d44dd5de6d4e379c2333be06ee5a898" +dependencies = [ + "dispatch", + "log", + "objc2", + "serde", + "serde_json", + "sparkle-updater", + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf 0.13.1", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.6+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.6+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "data-url", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x509-cert" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" +dependencies = [ + "const-oid 0.10.2", + "der 0.8.2", + "spki 0.8.0", + "tls_codec", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure 0.14.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.2", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 00000000..c3d732dc --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "android-tools" +version = "2026.10.3" +description = "Desktop tools for Android devices" +edition = "2024" +rust-version = "1.93" + +[lib] +name = "android_tools_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[features] +custom-protocol = ["tauri/custom-protocol"] +macos-updater = ["dep:tauri-plugin-sparkle-updater"] + +[target.'cfg(target_os = "macos")'.dependencies] +tauri-plugin-sparkle-updater = { version = "=0.3.0", optional = true } + +[profile.release] +debug = 2 +split-debuginfo = "packed" +strip = false + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +rusb = { version = "0.9", features = ["vendored"] } +serde = { version = "1", features = ["derive"] } +rsadb = { version = "0.1.1", default-features = false, features = ["usb"] } +tokio = { version = "1", features = ["sync", "time", "rt"] } +tempfile = "3" +openssl = { version = "0.10.81", features = ["vendored"] } +jks = { version = "0.3.3", default-features = false, features = ["rand"] } +tauri-plugin-dialog = "2.8.1" +tauri-plugin-clipboard-manager = "2.4.1" +tauri-plugin-opener = "2.7.0" +zeroize = "1.9.0" +chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +apk-info = "1.0.13" +apksig = { version = "0.4.0", default-features = false, features = ["signing"] } +rsa = "0.9.10" +zip = { version = "4.6.1", default-features = false, features = ["deflate"] } +serde_json = "1" +resvg = { version = "0.48.1", default-features = false, features = ["raster-images"] } +roxmltree = "0.21.1" +imagesize = { version = "0.15.0", default-features = false, features = ["png", "jpeg", "webp"] } +svgtypes = "0.16.1" +sentry = { version = "0.49", default-features = false, features = ["reqwest", "rustls", "panic", "contexts"] } +log = "0.4" +flexi_logger = { version = "0.31", default-features = false, features = ["async"] } + +[lints.clippy] +unwrap_used = "warn" + +[dev-dependencies] +sentry = { version = "0.49", default-features = false, features = ["test"] } diff --git a/src-tauri/Info.release.plist b/src-tauri/Info.release.plist new file mode 100644 index 00000000..1fd22689 --- /dev/null +++ b/src-tauri/Info.release.plist @@ -0,0 +1,12 @@ + + + + + SUFeedURL + https://raw.githubusercontent.com/ThomasBernard03/AndroidTools/refs/heads/main/appcast.xml + SUPublicEDKey + z9EBcFEFrJ5Bi6oiODtO2k6fo7X61gTEKmiuG3+Qxwg= + SUEnableAutomaticChecks + + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 00000000..a2046e57 --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,9 @@ +fn main() { + println!("cargo:rerun-if-env-changed=SENTRY_DSN"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") + && std::env::var_os("CARGO_FEATURE_MACOS_UPDATER").is_some() + { + println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks"); + } + tauri_build::build(); +} diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json new file mode 100644 index 00000000..8a7d520f --- /dev/null +++ b/src-tauri/capabilities/main.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main", + "description": "Main window; native permissions will be added with the features that need them.", + "windows": ["main"], + "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] +} diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 00000000..dd88e07e Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon512x512@2x.png b/src-tauri/icons/app-icon.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon512x512@2x.png rename to src-tauri/icons/app-icon.png diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 00000000..c8e3ff28 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/src/features/adb/application.rs b/src-tauri/src/features/adb/application.rs new file mode 100644 index 00000000..61df0ddd --- /dev/null +++ b/src-tauri/src/features/adb/application.rs @@ -0,0 +1,278 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use tokio::sync::Mutex; + +use super::domain::{AdbConnector, AdbError, AdbErrorCode, AdbSession, AndroidInfo}; + +struct ActiveSession { + id: String, + session: Box, +} + +/// Owns one authenticated session. Changing selection or any transport failure releases it. +#[derive(Clone)] +pub struct AdbService { + connector: Arc, + active: Arc>>, +} + +impl AdbService { + pub fn new(connector: impl AdbConnector + 'static) -> Self { + Self { + connector: Arc::new(connector), + active: Arc::new(Mutex::new(None)), + } + } + + /// Connects if needed and reads a fresh snapshot. Operations are serialized. + pub async fn read(&self, id: &str) -> Result { + let mut active = self.active.lock().await; + if active.as_ref().is_none_or(|session| session.id != id) { + *active = None; + *active = Some(ActiveSession { + id: id.into(), + session: self.connector.connect(id).await?, + }); + } + let session = &active.as_ref().expect("session established").session; + let result = read_information(session.as_ref()).await; + if result.is_err() { + *active = None; + } + result + } + + /// Releases the USB interface when no device is selected or the view closes. + pub async fn disconnect(&self) { + *self.active.lock().await = None; + } +} + +async fn read_information(session: &dyn AdbSession) -> Result { + let properties = session.shell("getprop").await?; + let mut info = parse_properties(&properties)?; + match session.shell("dumpsys battery").await { + Ok(battery) => { + let (percent, status) = parse_battery(&battery); + info.battery_percent = percent; + info.battery_status = status; + if percent.is_none() { + info.warning = Some("Battery information is unavailable on this device.".into()); + } + } + Err(error) if error.code == AdbErrorCode::ReadFailed => { + info.warning = Some( + "Android properties were read, but battery information is unavailable.".into(), + ); + } + Err(error) => return Err(error), + } + Ok(info) +} + +fn parse_properties(output: &str) -> Result { + let properties: BTreeMap<_, _> = output + .lines() + .filter_map(|line| { + let (key, value) = line + .trim() + .strip_prefix('[')? + .strip_suffix(']')? + .split_once("]: [")?; + Some((key, value.trim())) + }) + .collect(); + if properties.is_empty() { + return Err(AdbError::new( + AdbErrorCode::ReadFailed, + "Android system properties could not be read. Retry the ADB connection.", + )); + } + let value = |key| { + properties + .get(key) + .filter(|value| !value.is_empty()) + .map(|value| (*value).to_owned()) + }; + Ok(AndroidInfo { + manufacturer: value("ro.product.manufacturer"), + model: value("ro.product.model"), + android_version: value("ro.build.version.release"), + api_level: value("ro.build.version.sdk"), + security_patch: value("ro.build.version.security_patch"), + build_id: value("ro.build.display.id"), + architecture: value("ro.product.cpu.abilist").or_else(|| value("ro.product.cpu.abi")), + battery_percent: None, + battery_status: None, + warning: None, + }) +} + +fn parse_battery(output: &str) -> (Option, Option) { + let fields: BTreeMap<_, _> = output + .lines() + .filter_map(|line| { + let (key, value) = line.trim().split_once(':')?; + Some((key, value.trim())) + }) + .collect(); + let number = |key| fields.get(key).and_then(|value| value.parse::().ok()); + let percent = number("level") + .zip(number("scale")) + .and_then(|(level, scale)| { + (scale > 0 && level <= scale) + .then(|| ((u64::from(level) * 100) / u64::from(scale)) as u8) + }); + let status = match number("status") { + Some(2) => Some("Charging"), + Some(3) => Some("Discharging"), + Some(4) => Some("Not charging"), + Some(5) => Some("Full"), + _ => None, + } + .map(str::to_owned); + (percent, status) +} + +#[cfg(test)] +mod tests { + use super::super::domain::AdbFuture; + use super::*; + use std::sync::{ + Mutex as SyncMutex, + atomic::{AtomicUsize, Ordering}, + }; + + const PROPERTIES: &str = "[ro.product.model]: [Pixel 9]\r\n[ro.build.version.release]: [16]\n[ro.product.cpu.abi]: [arm64-v8a]\n[ro.build.version.security_patch]: []\n"; + + struct FakeConnector { + connections: Arc>>, + drops: Arc, + } + struct FakeSession { + drops: Arc, + battery_error: Option, + } + impl Drop for FakeSession { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + impl AdbSession for FakeSession { + fn shell<'a>(&'a self, command: &'a str) -> AdbFuture<'a, String> { + Box::pin(async move { + match command { + "getprop" => Ok(PROPERTIES.into()), + "dumpsys battery" => match &self.battery_error { + Some(code) => Err(AdbError::new(code.clone(), "Simulated battery failure")), + None => Ok("level: 41\nscale: 50\nstatus: 2\n".into()), + }, + _ => panic!("Unexpected command: {command}"), + } + }) + } + } + impl AdbConnector for FakeConnector { + fn connect<'a>(&'a self, id: &'a str) -> AdbFuture<'a, Box> { + Box::pin(async move { + self.connections + .lock() + .expect("connections lock") + .push(id.into()); + if id == "unauthorized" { + return Err(AdbError::new(AdbErrorCode::Unauthorized, "Allow debugging")); + } + Ok(Box::new(FakeSession { + drops: self.drops.clone(), + battery_error: (id == "disconnected").then_some(AdbErrorCode::Disconnected), + }) as Box) + }) + } + } + + #[tokio::test] + async fn reuses_the_selected_session_and_releases_it_on_switch_failure_and_disconnect() { + let connections = Arc::new(SyncMutex::new(Vec::new())); + let drops = Arc::new(AtomicUsize::new(0)); + let service = AdbService::new(FakeConnector { + connections: connections.clone(), + drops: drops.clone(), + }); + let info = service.read("phone-a").await.expect("first read"); + assert_eq!(info.model.as_deref(), Some("Pixel 9")); + assert_eq!(info.battery_percent, Some(82)); + service.read("phone-a").await.expect("repeat read"); + assert_eq!( + *connections.lock().expect("connections lock"), + vec!["phone-a"] + ); + service.read("phone-b").await.expect("second phone"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!( + service + .read("unauthorized") + .await + .expect_err("authorization required") + .code, + AdbErrorCode::Unauthorized + ); + assert_eq!(drops.load(Ordering::SeqCst), 2); + service.read("phone-b").await.expect("retry"); + service.disconnect().await; + assert_eq!(drops.load(Ordering::SeqCst), 3); + assert_eq!( + service + .read("disconnected") + .await + .expect_err("disconnection") + .code, + AdbErrorCode::Disconnected + ); + assert_eq!(drops.load(Ordering::SeqCst), 4); + assert!(service.active.lock().await.is_none()); + } + + #[tokio::test] + async fn keeps_properties_when_only_the_battery_command_is_unsupported() { + let session = FakeSession { + drops: Arc::new(AtomicUsize::new(0)), + battery_error: Some(AdbErrorCode::ReadFailed), + }; + let info = read_information(&session) + .await + .expect("partial information"); + assert_eq!(info.android_version.as_deref(), Some("16")); + assert_eq!(info.battery_percent, None); + assert!(info.warning.is_some()); + } + + #[test] + fn parses_missing_properties_and_rejects_non_property_output() { + let info = parse_properties(PROPERTIES).expect("properties"); + assert_eq!(info.security_patch, None); + assert_eq!(info.manufacturer, None); + assert_eq!(info.architecture.as_deref(), Some("arm64-v8a")); + assert!(parse_properties("/system/bin/sh: getprop: not found").is_err()); + let json = serde_json::to_value(info).expect("serialize"); + assert_eq!(json["androidVersion"], "16"); + assert_eq!(json["securityPatch"], serde_json::Value::Null); + assert!(json.get("batteryPercent").is_some()); + assert_eq!( + serde_json::to_value(AdbError::new(AdbErrorCode::UsbAccess, "Denied")) + .expect("serialize"), + serde_json::json!({"code": "usb_access", "message": "Denied"}) + ); + } + + #[test] + fn handles_battery_scale_empty_and_invalid_values_without_inventing_a_percentage() { + assert_eq!( + parse_battery("level: 0\nscale: 100\nstatus: 3"), + (Some(0), Some("Discharging".into())) + ); + assert_eq!(parse_battery("level: 101\nscale: 100"), (None, None)); + assert_eq!(parse_battery("level: 1\nscale: 0"), (None, None)); + assert_eq!(parse_battery("level: -1\nscale: 100"), (None, None)); + assert_eq!(parse_battery("Permission denied"), (None, None)); + } +} diff --git a/src-tauri/src/features/adb/commands.rs b/src-tauri/src/features/adb/commands.rs new file mode 100644 index 00000000..c9e69495 --- /dev/null +++ b/src-tauri/src/features/adb/commands.rs @@ -0,0 +1,22 @@ +use super::{ + application::AdbService, + domain::{AdbError, AndroidInfo}, +}; +use tauri::State; + +/// Establishes ADB for the selected USB attachment and returns Android information. +#[tauri::command] +pub async fn read_android_info( + service: State<'_, AdbService>, + connection_id: String, +) -> Result { + crate::logging::observe("read_android_info", service.read(&connection_id)).await +} + +/// Releases the selected ADB session and its USB interface. +#[tauri::command] +pub async fn disconnect_adb(service: State<'_, AdbService>) -> Result<(), AdbError> { + service.disconnect().await; + log::info!("ADB session released"); + Ok(()) +} diff --git a/src-tauri/src/features/adb/domain.rs b/src-tauri/src/features/adb/domain.rs new file mode 100644 index 00000000..dd3ca0c9 --- /dev/null +++ b/src-tauri/src/features/adb/domain.rs @@ -0,0 +1,62 @@ +use std::{future::Future, pin::Pin}; + +use serde::Serialize; + +/// Device properties read through an authenticated ADB session. Missing values stay absent. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AndroidInfo { + pub manufacturer: Option, + pub model: Option, + pub android_version: Option, + pub api_level: Option, + pub security_patch: Option, + pub build_id: Option, + pub architecture: Option, + pub battery_percent: Option, + pub battery_status: Option, + pub warning: Option, +} + +/// Stable error codes allow the UI to distinguish authorization from transport failures. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AdbErrorCode { + Unauthorized, + Disconnected, + UsbAccess, + IdentityUnavailable, + Timeout, + Key, + Unsupported, + ReadFailed, + Internal, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AdbError { + pub code: AdbErrorCode, + pub message: String, +} + +impl AdbError { + pub fn new(code: AdbErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +/// Async boundaries keep the use case independent of native USB and ADB libraries. +pub type AdbFuture<'a, T> = Pin> + Send + 'a>>; + +pub trait AdbSession: Send + Sync { + /// Executes only the fixed read-only commands chosen by the application service. + fn shell<'a>(&'a self, command: &'a str) -> AdbFuture<'a, String>; +} + +pub trait AdbConnector: Send + Sync { + /// Connects to this exact discovered USB attachment, never the first matching model. + fn connect<'a>(&'a self, connection_id: &'a str) -> AdbFuture<'a, Box>; +} diff --git a/src-tauri/src/features/adb/infrastructure.rs b/src-tauri/src/features/adb/infrastructure.rs new file mode 100644 index 00000000..a2ee5fe4 --- /dev/null +++ b/src-tauri/src/features/adb/infrastructure.rs @@ -0,0 +1,301 @@ +use std::{io::Write, path::PathBuf, sync::Arc, time::Duration}; + +use rsadb::{ + auth::{HostKey, KeyPaths}, + device::Device, + session::Session, + transport::usb, +}; +use tokio::sync::OnceCell; + +use super::domain::{AdbConnector, AdbError, AdbErrorCode, AdbFuture, AdbSession}; +use crate::features::devices::{ + domain::{DeviceRepository, DeviceSummary}, + infrastructure::UsbDeviceRepository, +}; + +/// Direct USB ADB with an application-specific, persistent host key. +pub struct UsbAdbConnector { + key_directory: PathBuf, + key: OnceCell>, +} + +impl UsbAdbConnector { + pub fn new(key_directory: PathBuf) -> Self { + Self { + key_directory, + key: OnceCell::new(), + } + } +} + +fn map_error(error: rsadb::Error) -> AdbError { + use AdbErrorCode as Code; + let (code, message) = match error { + rsadb::Error::Unauthorized => ( + Code::Unauthorized, + "Unlock your phone and allow USB debugging, then retry.", + ), + rsadb::Error::Disconnected | rsadb::Error::StreamClosed | rsadb::Error::NoDevice(_) => ( + Code::Disconnected, + "The device disconnected. Refresh the device list and reconnect.", + ), + rsadb::Error::ClaimFailed(_) => ( + Code::UsbAccess, + "Cannot claim the USB ADB interface. Check USB permissions and close other ADB clients. If an adb server is running, stop it before retrying.", + ), + rsadb::Error::Key(_) => ( + Code::Key, + "The application's ADB host key could not be loaded or created.", + ), + rsadb::Error::Unsupported(_) => ( + Code::Unsupported, + "This device requires an ADB authentication method that is not supported yet.", + ), + rsadb::Error::Timeout(_) => ( + Code::Timeout, + "The ADB request timed out. Check the phone and retry.", + ), + rsadb::Error::Usb(_) | rsadb::Error::Io(_) => ( + Code::UsbAccess, + "USB communication failed. Check the connection and USB permissions, then retry.", + ), + _ => ( + Code::ReadFailed, + "The device could not complete the ADB request. Retry the connection.", + ), + }; + AdbError::new(code, message) +} + +fn timeout_error() -> AdbError { + AdbError::new( + AdbErrorCode::Timeout, + "The ADB request timed out. Unlock the phone, check USB debugging and retry.", + ) +} + +/// Persist atomically without replacing a host key created by another app instance. +fn load_key(directory: PathBuf) -> Result, AdbError> { + let paths = KeyPaths::in_dir(&directory); + let result = (|| -> rsadb::error::Result { + match std::fs::read_to_string(&paths.private) { + Ok(pem) => return HostKey::from_pem(&pem, "AndroidTools"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let key = HostKey::generate("AndroidTools")?; + std::fs::create_dir_all(&directory)?; + let mut file = tempfile::NamedTempFile::new_in(&directory)?; + file.write_all(key.to_pem()?.as_bytes())?; + file.as_file().sync_all()?; + match file.persist_noclobber(&paths.private) { + Ok(_) => Ok(key), + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + rsadb::auth::read_key(&paths, "AndroidTools") + } + Err(error) => Err(error.error.into()), + } + })(); + result.map(Arc::new).map_err(|_| { + AdbError::new( + AdbErrorCode::Key, + "The application's ADB host key could not be loaded or saved.", + ) + }) +} + +fn selected_device(devices: Vec, id: &str) -> Result { + let device = devices + .iter() + .find(|device| device.id == id) + .ok_or_else(|| { + AdbError::new( + AdbErrorCode::Disconnected, + "The selected USB connection is no longer present. Refresh the device list.", + ) + })?; + if device.serial.is_none() + || devices + .iter() + .filter(|other| { + other.serial == device.serial + && other.vendor_id == device.vendor_id + && other.product_id == device.product_id + }) + .count() + != 1 + { + return Err(AdbError::new( + AdbErrorCode::IdentityUnavailable, + "A unique USB serial number is required to connect to this device safely. Check USB metadata permissions and refresh.", + )); + } + Ok(device.clone()) +} + +impl AdbConnector for UsbAdbConnector { + fn connect<'a>(&'a self, connection_id: &'a str) -> AdbFuture<'a, Box> { + Box::pin(async move { + let key = self + .key + .get_or_try_init(|| async { + let directory = self.key_directory.clone(); + tokio::task::spawn_blocking(move || load_key(directory)) + .await + .map_err(|_| { + AdbError::new( + AdbErrorCode::Internal, + "ADB key initialization could not finish.", + ) + })? + }) + .await?; + let devices = tokio::task::spawn_blocking(|| UsbDeviceRepository.list()) + .await + .map_err(|_| { + AdbError::new(AdbErrorCode::Internal, "USB discovery could not finish.") + })? + .map_err(|error| AdbError::new(AdbErrorCode::UsbAccess, error.message))?; + let selected = selected_device(devices, connection_id)?; + tokio::time::timeout(Duration::from_secs(40), async { + let matches: Vec<_> = usb::list().await.map_err(map_error)?.into_iter().filter(|device| { + device.serial() == selected.serial.as_deref() && device.vendor_id() == selected.vendor_id && device.product_id() == selected.product_id + }).collect(); + if matches.len() != 1 { + return Err(AdbError::new(AdbErrorCode::Disconnected, "The selected device could not be uniquely located. Refresh the device list.")); + } + let transport = usb::open(&matches[0]).await.map_err(map_error)?; + let session = Session::connect(transport, key, Duration::from_secs(30)).await.map_err(map_error)?; + Ok(Box::new(NativeSession(Device::new(session))) as Box) + }).await.map_err(|_| timeout_error())? + }) + } +} + +struct NativeSession(Device); + +impl AdbSession for NativeSession { + fn shell<'a>(&'a self, command: &'a str) -> AdbFuture<'a, String> { + Box::pin(async move { + if !self.0.connection().is_alive() { + return Err(AdbError::new( + AdbErrorCode::Disconnected, + "The ADB connection closed. Reconnect the device and retry.", + )); + } + let output = tokio::time::timeout(Duration::from_secs(10), self.0.shell(command)) + .await + .map_err(|_| timeout_error())? + .map_err(map_error)?; + if !self.0.connection().is_alive() { + return Err(AdbError::new( + AdbErrorCode::Disconnected, + "The device disconnected while reading Android information.", + )); + } + if !output.success() { + return Err(AdbError::new( + AdbErrorCode::ReadFailed, + "The Android information command failed.", + )); + } + Ok(output.stdout_text()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn device(id: &str, serial: Option<&str>) -> DeviceSummary { + DeviceSummary { + id: id.into(), + name: "Pixel".into(), + serial: serial.map(str::to_owned), + manufacturer: None, + product: None, + vendor_id: 0x18d1, + product_id: 0x4ee7, + warning: None, + } + } + + #[test] + fn resolves_the_exact_attachment_and_rejects_missing_or_ambiguous_identity() { + let a = device("usb:1", Some("A")); + let b = device("usb:2", Some("B")); + assert_eq!( + selected_device(vec![a.clone(), b.clone()], "usb:2").expect("selected device"), + b + ); + assert_eq!( + selected_device(vec![a.clone()], "usb:2") + .expect_err("gone") + .code, + AdbErrorCode::Disconnected + ); + assert_eq!( + selected_device(vec![device("usb:1", None)], "usb:1") + .expect_err("missing serial") + .code, + AdbErrorCode::IdentityUnavailable + ); + assert_eq!( + selected_device(vec![a, device("usb:2", Some("A"))], "usb:1") + .expect_err("duplicate serial") + .code, + AdbErrorCode::IdentityUnavailable + ); + } + + #[test] + fn maps_authorization_access_and_disconnection_errors_separately() { + assert_eq!( + map_error(rsadb::Error::Unauthorized).code, + AdbErrorCode::Unauthorized + ); + assert_eq!( + map_error(rsadb::Error::ClaimFailed("busy".into())).code, + AdbErrorCode::UsbAccess + ); + assert_eq!( + map_error(rsadb::Error::Disconnected).code, + AdbErrorCode::Disconnected + ); + assert_eq!(timeout_error().code, AdbErrorCode::Timeout); + } + + #[test] + fn persists_the_host_key_and_does_not_replace_a_corrupt_existing_key() { + let directory = tempfile::tempdir().expect("temporary key directory"); + let first = load_key(directory.path().into()).expect("generate key"); + let second = load_key(directory.path().into()).expect("reuse key"); + assert_eq!( + first.public_key_line().expect("public key"), + second.public_key_line().expect("public key") + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(directory.path().join("adbkey")) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + std::fs::write(directory.path().join("adbkey"), "corrupt").expect("corrupt fixture"); + assert!(matches!( + load_key(directory.path().into()), + Err(AdbError { + code: AdbErrorCode::Key, + .. + }) + )); + assert_eq!( + std::fs::read_to_string(directory.path().join("adbkey")).expect("read fixture"), + "corrupt" + ); + } +} diff --git a/src-tauri/src/features/adb/mod.rs b/src-tauri/src/features/adb/mod.rs new file mode 100644 index 00000000..c31304fe --- /dev/null +++ b/src-tauri/src/features/adb/mod.rs @@ -0,0 +1,4 @@ +pub mod application; +pub mod commands; +pub mod domain; +pub mod infrastructure; diff --git a/src-tauri/src/features/apk/commands.rs b/src-tauri/src/features/apk/commands.rs new file mode 100644 index 00000000..021e9644 --- /dev/null +++ b/src-tauri/src/features/apk/commands.rs @@ -0,0 +1,44 @@ +use super::{ + domain::{self, ApkError, ApkReport}, + infrastructure::NativeApkInspector, +}; +use tauri_plugin_dialog::DialogExt; + +fn failed() -> ApkError { + ApkError::new( + "analysis_failed", + "APK analysis could not finish. Try another APK.", + ) +} + +/// Run archive, resource, certificate and hashing work off the UI thread. +#[tauri::command] +pub async fn analyze_apk(path: String) -> Result { + crate::logging::observe("analyze_apk", async move { + tauri::async_runtime::spawn_blocking(move || { + domain::analyze(std::path::Path::new(&path), &NativeApkInspector) + }) + .await + .map_err(|_| failed())? + }) + .await +} + +#[tauri::command] +pub async fn choose_apk_path(app: tauri::AppHandle) -> Result, ApkError> { + tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .file() + .set_title("Analyze APK") + .add_filter("Android package", &["apk"]) + .blocking_pick_file() + .map(|file| { + file.into_path() + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|_| failed()) + }) + .transpose() + }) + .await + .map_err(|_| failed())? +} diff --git a/src-tauri/src/features/apk/domain.rs b/src-tauri/src/features/apk/domain.rs new file mode 100644 index 00000000..f7c9a521 --- /dev/null +++ b/src-tauri/src/features/apk/domain.rs @@ -0,0 +1,86 @@ +use serde::Serialize; +use std::path::Path; + +/// Stable, presentation-neutral inspection report. Missing values remain explicit. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApkReport { + pub path: String, + pub size: u64, + /// Inline raster preview of the resolved application icon, when supported. + pub icon_data_url: Option, + pub signature: SignatureVerification, + pub sha256: String, + pub sections: Vec
, + pub files: Vec, + pub manifest: String, + pub warnings: Vec, +} + +/// Cryptographic integrity is separate from certificate identity or Android install policy. +#[derive(Debug, Serialize)] +pub struct SignatureVerification { + pub status: VerificationStatus, + pub schemes: Vec, + pub message: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VerificationStatus { + Verified, + Invalid, + Unverified, + Unsigned, +} + +#[derive(Debug, Serialize)] +pub struct Section { + pub title: String, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct InfoItem { + pub label: String, + pub value: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveFile { + pub name: String, + pub size: u64, + pub compressed_size: u64, +} + +/// Replaceable analysis boundary; implementations must not execute APK contents. +pub trait ApkInspector { + fn inspect(&self, path: &Path) -> Result; +} + +#[derive(Debug, Serialize)] +pub struct ApkError { + pub code: &'static str, + pub message: &'static str, +} + +impl ApkError { + pub fn new(code: &'static str, message: &'static str) -> Self { + Self { code, message } + } +} + +/// Validates the supported input before invoking an external inspector. +pub fn analyze(path: &Path, inspector: &impl ApkInspector) -> Result { + if !path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("apk")) + { + return Err(ApkError::new( + "invalid_extension", + "Choose a single .apk file.", + )); + } + inspector.inspect(path) +} diff --git a/src-tauri/src/features/apk/icons.rs b/src-tauri/src/features/apk/icons.rs new file mode 100644 index 00000000..8c88c38c --- /dev/null +++ b/src-tauri/src/features/apk/icons.rs @@ -0,0 +1,530 @@ +//! Converts supported Android drawables to a fixed-size PNG preview, without a device. +use apk_info::{ARSC, AXML, Apk}; +use roxmltree::{Document, Node}; +use std::{fs::File, io::Read}; + +const MAX_IMAGE: u64 = 2 * 1024 * 1024; +const MAX_DEPTH: usize = 16; +const MAX_NODES: usize = 2048; + +/// Resource lookup is injectable so drawable rendering can be tested without APK tooling. +trait Resources { + fn read(&mut self, path: &str) -> Option>; + fn resolve(&mut self, reference: &str) -> Option; +} + +struct ApkResources<'a> { + apk: &'a Apk, + zip: &'a mut zip::ZipArchive, + arsc: Option, + loaded: bool, + remaining: u64, +} + +fn read_entry(zip: &mut zip::ZipArchive, path: &str, limit: u64) -> Option> { + let entry = zip.by_name(path).ok()?; + if entry.size() > limit { + return None; + } + let mut bytes = Vec::new(); + entry.take(limit + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() as u64 <= limit).then_some(bytes) +} + +impl Resources for ApkResources<'_> { + fn read(&mut self, path: &str) -> Option> { + let bytes = read_entry(self.zip, path, MAX_IMAGE.min(self.remaining))?; + self.remaining = self.remaining.checked_sub(bytes.len() as u64)?; + Some(bytes) + } + fn resolve(&mut self, reference: &str) -> Option { + let raw = reference.strip_prefix('@')?; + if let Ok(id) = u32::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16) { + if !self.loaded { + self.loaded = true; + self.arsc = read_entry(self.zip, "resources.arsc", 64 * 1024 * 1024) + .and_then(|bytes| ARSC::new(&mut bytes.as_slice()).ok()); + } + self.arsc.as_ref()?.get_resource_value(id) + } else { + self.apk.get_resource_value(reference) + } + } +} + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} +fn attr<'a>(node: Node<'a, '_>, name: &str) -> Option<&'a str> { + node.attributes() + .find(|a| a.name() == name) + .map(|a| a.value()) +} +fn number(node: Node<'_, '_>, name: &str, default: f32) -> Option { + let value = attr(node, name) + .map(str::parse) + .transpose() + .ok()? + .unwrap_or(default); + (value.is_finite() && value.abs() <= 1_000_000.0).then_some(value) +} +fn rgba(value: &str) -> Option<(String, f32)> { + let value = value.strip_prefix('#')?; + if !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let expanded = if value.len() <= 4 { + value.chars().flat_map(|c| [c, c]).collect::() + } else { + value.into() + }; + match expanded.len() { + 6 => Some((format!("#{expanded}"), 1.0)), + 8 => Some(( + format!("#{}", &expanded[2..]), + u8::from_str_radix(&expanded[..2], 16).ok()? as f32 / 255.0, + )), + _ => None, + } +} +fn raster(bytes: &[u8]) -> Option { + let mime = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + "image/jpeg" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else { + return None; + }; + let size = imagesize::blob_size(bytes).ok()?; + if size.width == 0 || size.height == 0 || size.width > 2048 || size.height > 2048 { + return None; + } + Some(format!( + "data:{mime};base64,{}", + openssl::base64::encode_block(bytes) + )) +} + +fn valid_path(path: &str) -> Option<()> { + let mut count = 0; + for segment in svgtypes::PathParser::from(path) { + segment.ok()?; + count += 1; + if count > 10_000 { + return None; + } + } + (count > 0).then_some(()) +} + +struct Renderer { + resources: R, + nodes: usize, + ids: usize, +} +impl Renderer { + fn id(&mut self) -> String { + self.ids += 1; + format!("drawable{}", self.ids) + } + fn resolve(&mut self, input: &str) -> Option { + let mut value = input.to_owned(); + for _ in 0..MAX_DEPTH { + if !value.starts_with('@') { + return Some(value); + } + value = self.resources.resolve(&value)?; + } + None + } + fn color(&mut self, input: &str) -> Option<(String, f32)> { + let value = self.resolve(input)?; + if let Some(color) = rgba(&value) { + return Some(color); + } + // Default color of a ColorStateList, not a device/theme-specific state. + let bytes = self.resources.read(&value)?; + let xml = xml(&bytes)?; + let document = Document::parse(&xml).ok()?; + let root = document.root_element(); + if root.tag_name().name() != "selector" { + return None; + } + let item = root + .children() + .filter(Node::is_element) + .find(|n| !n.attributes().any(|a| a.name().starts_with("state_")))?; + rgba(&self.resolve(attr(item, "color")?)?) + } + fn gradient(&mut self, node: Node<'_, '_>) -> Option<(String, String)> { + if !node.has_tag_name("gradient") { + return None; + } + let id = self.id(); + let mode = match attr(node, "tileMode") { + Some("repeat" | "1" | "0x00000001") => "repeat", + Some("mirror" | "2" | "0x00000002") => "reflect", + _ => "pad", + }; + let (tag, geometry) = match attr(node, "type") { + None | Some("linear" | "0" | "0x00000000") => ( + "linearGradient", + format!( + "x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", + number(node, "startX", 0.0)?, + number(node, "startY", 0.0)?, + number(node, "endX", 0.0)?, + number(node, "endY", 0.0)? + ), + ), + Some("radial" | "1" | "0x00000001") => { + let radius = number(node, "gradientRadius", 0.0)?; + if radius <= 0.0 { + return None; + } + ( + "radialGradient", + format!( + "cx=\"{}\" cy=\"{}\" r=\"{radius}\"", + number(node, "centerX", 0.0)?, + number(node, "centerY", 0.0)? + ), + ) + } + _ => return None, + }; + let mut stops = Vec::new(); + for item in node.children().filter(Node::is_element) { + if !item.has_tag_name("item") || stops.len() >= 256 { + return None; + } + stops.push(( + number(item, "offset", 0.0)?.clamp(0.0, 1.0), + self.color(attr(item, "color")?)?, + )); + } + if stops.is_empty() { + stops.push((0.0, self.color(attr(node, "startColor")?)?)); + if let Some(color) = attr(node, "centerColor") { + stops.push((0.5, self.color(color)?)); + } + stops.push((1.0, self.color(attr(node, "endColor")?)?)); + } + if stops.len() < 2 || stops.windows(2).any(|s| s[0].0 > s[1].0) { + return None; + } + let stops = stops + .into_iter() + .map(|(offset, (color, alpha))| { + format!( + "" + ) + }) + .collect::(); + Some(( + format!( + "<{tag} id=\"{id}\" gradientUnits=\"userSpaceOnUse\" spreadMethod=\"{mode}\" {geometry}>{stops}" + ), + format!("url(#{id})"), + )) + } + fn paint(&mut self, node: Node<'_, '_>, name: &str) -> Option<(String, String, f32)> { + if let Some(inline) = node.children().filter(Node::is_element).find(|child| { + attr(*child, "name") + .is_some_and(|value| value.strip_prefix("android:").unwrap_or(value) == name) + }) { + let (definitions, value) = self.gradient(inline.children().find(Node::is_element)?)?; + return Some((definitions, value, 1.0)); + } + let Some(value) = attr(node, name) else { + return Some((String::new(), "none".into(), 1.0)); + }; + if let Some((color, alpha)) = self.color(value) { + return Some((String::new(), color, alpha)); + } + let path = self.resolve(value)?; + let bytes = self.resources.read(&path)?; + let xml = xml(&bytes)?; + let doc = Document::parse(&xml).ok()?; + let (definitions, color) = self.gradient(doc.root_element())?; + Some((definitions, color, 1.0)) + } + fn drawable(&mut self, input: &str, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let value = self.resolve(input)?; + if let Some((color, alpha)) = rgba(&value) { + return Some(format!( + "" + )); + } + let bytes = self.resources.read(&value)?; + if let Some(data) = raster(&bytes) { + return Some(format!( + "" + )); + } + let xml = xml(&bytes)?; + let doc = Document::parse(&xml).ok()?; + self.element(doc.root_element(), depth + 1) + } + fn element(&mut self, node: Node<'_, '_>, depth: usize) -> Option { + self.nodes += 1; + if self.nodes > MAX_NODES || depth > MAX_DEPTH { + return None; + } + match node.tag_name().name() { + "vector" => { + let width = number(node, "viewportWidth", 0.0)?; + let height = number(node, "viewportHeight", 0.0)?; + if width <= 0.0 || height <= 0.0 { + return None; + } + let alpha = number(node, "alpha", 1.0)?.clamp(0.0, 1.0); + let mut content = self.children(node, depth + 1)?; + if let Some(tint) = attr(node, "tint") { + if !matches!( + attr(node, "tintMode"), + None | Some("src_in" | "5" | "0x00000005") + ) { + return None; + } + let (color, opacity) = self.color(tint)?; + let id = self.id(); + content = format!( + "{content}" + ); + } + Some(format!( + "{content}" + )) + } + "group" => { + let px = number(node, "pivotX", 0.0)?; + let py = number(node, "pivotY", 0.0)?; + let tx = number(node, "translateX", 0.0)?; + let ty = number(node, "translateY", 0.0)?; + let sx = number(node, "scaleX", 1.0)?; + let sy = number(node, "scaleY", 1.0)?; + let rotation = number(node, "rotation", 0.0)?; + let content = self.children(node, depth + 1)?; + Some(format!( + "{content}", + tx + px, + ty + py, + -px, + -py + )) + } + "path" => { + if number(node, "trimPathStart", 0.0)? != 0.0 + || number(node, "trimPathEnd", 1.0)? != 1.0 + || number(node, "trimPathOffset", 0.0)? != 0.0 + { + return None; + } + let path = attr(node, "pathData")?; + // Validate geometry; malformed paths should use the generic icon. + valid_path(path)?; + let mut style = String::new(); + let mut definitions = String::new(); + for (android, svg, alpha) in [ + ("fillColor", "fill", "fillAlpha"), + ("strokeColor", "stroke", "strokeAlpha"), + ] { + let (defs, color, opacity) = self.paint(node, android)?; + definitions.push_str(&defs); + style.push_str(&format!( + " {svg}=\"{color}\" {svg}-opacity=\"{}\"", + opacity * number(node, alpha, 1.0)?.clamp(0.0, 1.0) + )); + } + if node.children().filter(Node::is_element).any(|n| { + n.tag_name().name() != "attr" + || !matches!( + attr(n, "name"), + Some( + "android:fillColor" + | "android:strokeColor" + | "fillColor" + | "strokeColor" + ) + ) + }) { + return None; + } + let rule = match attr(node, "fillType") { + Some("evenOdd" | "1" | "0x00000001") => "evenodd", + _ => "nonzero", + }; + let cap = match attr(node, "strokeLineCap") { + Some("round" | "1" | "0x00000001") => "round", + Some("square" | "2" | "0x00000002") => "square", + _ => "butt", + }; + let join = match attr(node, "strokeLineJoin") { + Some("round" | "1" | "0x00000001") => "round", + Some("bevel" | "2" | "0x00000002") => "bevel", + _ => "miter", + }; + Some(format!( + "{definitions}", + escape(path), + number(node, "strokeWidth", 0.0)?, + number(node, "strokeMiterLimit", 4.0)? + )) + } + "adaptive-icon" => { + let mut content = String::new(); + for layer in ["background", "foreground"] { + let node = node.children().find(|n| n.has_tag_name(layer))?; + content.push_str(&self.layer(node, depth + 1)?); + } + let id = self.id(); + // Android layers use a 108dp canvas with a 72dp masked viewport. + Some(format!( + "{content}" + )) + } + "inset" => { + let inset = |name| { + attr(node, name) + .unwrap_or("0") + .strip_suffix('%') + .and_then(|n| n.parse::().ok()) + .map(|v| v * 1.08) + }; + let uniform = attr(node, "inset"); + let amount = if uniform.is_some() { + inset("inset")? + } else { + 0.0 + }; + let side = |name| { + if attr(node, name).is_some() { + inset(name) + } else { + Some(amount) + } + }; + let left = side("insetLeft")?; + let right = side("insetRight")?; + let top = side("insetTop")?; + let bottom = side("insetBottom")?; + if [left, right, top, bottom] + .iter() + .any(|v| !v.is_finite() || *v < 0.0) + || left + right >= 108.0 + || top + bottom >= 108.0 + { + return None; + } + Some(format!( + "{}", + (108.0 - left - right) / 108.0, + (108.0 - top - bottom) / 108.0, + self.layer(node, depth + 1)? + )) + } + "color" => { + let (color, opacity) = self.color(node.text()?.trim())?; + Some(format!( + "" + )) + } + _ => None, + } + } + fn layer(&mut self, node: Node<'_, '_>, depth: usize) -> Option { + if let Some(reference) = attr(node, "drawable") { + self.drawable(reference, depth) + } else { + self.element(node.children().find(Node::is_element)?, depth) + } + } + fn children(&mut self, node: Node<'_, '_>, depth: usize) -> Option { + let mut content = String::new(); + let mut clips = 0; + for child in node.children().filter(Node::is_element) { + if child.has_tag_name("clip-path") { + self.nodes += 1; + if self.nodes > MAX_NODES { + return None; + } + let path = attr(child, "pathData")?; + valid_path(path)?; + let id = self.id(); + content.push_str(&format!("", escape(path))); + clips += 1; + } else { + content.push_str(&self.element(child, depth)?); + } + } + content.push_str(&"".repeat(clips)); + Some(content) + } +} + +fn xml(bytes: &[u8]) -> Option { + if bytes.starts_with(&[3, 0, 8, 0]) { + AXML::new(&mut &bytes[..], None) + .ok() + .map(|xml| xml.get_xml_string()) + } else { + std::str::from_utf8(bytes).ok().map(str::to_owned) + } +} + +fn render(resources: impl Resources, path: &str) -> Option { + let mut renderer = Renderer { + resources, + nodes: 0, + ids: 0, + }; + let bytes = renderer.resources.read(path)?; + if let Some(image) = raster(&bytes) { + return Some(image); + } + let xml = xml(&bytes)?; + let document = Document::parse(&xml).ok()?; + let content = renderer.element(document.root_element(), 0)?; + let svg = format!( + "{content}" + ); + let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default()).ok()?; + let mut pixmap = resvg::tiny_skia::Pixmap::new(192, 192)?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + let bytes = pixmap.encode_png().ok()?; + Some(format!( + "data:image/png;base64,{}", + openssl::base64::encode_block(&bytes) + )) +} + +pub(super) fn preview(apk: &Apk, zip: &mut zip::ZipArchive) -> Option { + let path = apk.get_application_icon()?; + render( + ApkResources { + apk, + zip, + arsc: None, + loaded: false, + remaining: 8 * 1024 * 1024, + }, + &path, + ) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/features/apk/icons/tests.rs b/src-tauri/src/features/apk/icons/tests.rs new file mode 100644 index 00000000..22a5ea63 --- /dev/null +++ b/src-tauri/src/features/apk/icons/tests.rs @@ -0,0 +1,183 @@ +use super::*; +use std::collections::HashMap; + +#[derive(Default)] +struct FakeResources { + files: HashMap>, + references: HashMap, +} +impl Resources for FakeResources { + fn read(&mut self, path: &str) -> Option> { + self.files.get(path).cloned() + } + fn resolve(&mut self, reference: &str) -> Option { + self.references.get(reference).cloned() + } +} +fn preview(xml: &str) -> Option { + let mut resources = FakeResources::default(); + resources + .files + .insert("icon.xml".into(), xml.as_bytes().to_vec()); + pixels(render(resources, "icon.xml")?) +} +fn pixels(url: String) -> Option { + let bytes = openssl::base64::decode_block(url.strip_prefix("data:image/png;base64,")?).ok()?; + resvg::tiny_skia::Pixmap::decode_png(&bytes).ok() +} + +#[test] +fn renders_vector_geometry_argb_alpha_transforms_and_clipping() { + let icon = preview( + r##" + + + + + "##, + ) + .expect("render vector"); + assert_eq!((icon.width(), icon.height()), (192, 192)); + assert_eq!(icon.pixel(10, 96).expect("pixel").blue(), 255); + assert_eq!(icon.pixel(110, 96).expect("pixel").red(), 255); + assert_eq!(icon.pixel(170, 96).expect("pixel").blue(), 255); + let alpha = preview(r##""##).expect("alpha vector"); + assert_eq!(alpha.pixel(96, 96).expect("pixel").alpha(), 128); +} + +#[test] +fn composites_adaptive_layers_resolves_aliases_and_applies_a_circular_mask() { + let mut resources = FakeResources::default(); + resources.files.insert("icon.xml".into(), br#""#.to_vec()); + resources.files.insert("foreground.xml".into(), br##""##.to_vec()); + resources + .references + .insert("@color/background".into(), "@7f010001".into()); + resources + .references + .insert("@7f010001".into(), "#0000ff".into()); + resources + .references + .insert("@7f020001".into(), "foreground.xml".into()); + let image = pixels(render(resources, "icon.xml").expect("adaptive icon")).expect("PNG"); + assert_eq!(image.pixel(96, 96).expect("pixel").red(), 255); + assert_eq!(image.pixel(20, 96).expect("pixel").blue(), 255); + assert_eq!(image.pixel(0, 0).expect("pixel").alpha(), 0); +} + +#[test] +fn renders_inline_gradients_and_vector_tint() { + let image = preview(r##" + + + + + "##).expect("gradient"); + assert!(image.pixel(10, 96).expect("left").red() > 230); + assert!(image.pixel(180, 96).expect("right").blue() > 230); + let tinted = preview( + r##" + + "##, + ) + .expect("tint"); + assert_eq!(tinted.pixel(96, 96).expect("pixel").green(), 255); + assert_eq!(tinted.pixel(96, 96).expect("pixel").red(), 0); +} + +#[test] +fn rejects_unresolved_cyclic_and_unsupported_drawables_instead_of_rendering_partial_icons() { + let mut resources = FakeResources::default(); + resources.files.insert("loop.xml".into(), br#""#.to_vec()); + resources + .references + .insert("@loop".into(), "loop.xml".into()); + assert!(render(resources, "loop.xml").is_none()); + for xml in [ + r#""#, + r#""#, + r#""#, + r#""#, + "not XML", + ] { + assert!(preview(xml).is_none(), "{xml}"); + } +} + +/// Build a binary VectorDrawable fixture without aapt or Android SDK binaries. +#[test] +fn decodes_android_binary_xml_before_rendering() { + let strings = [ + "vector", + "viewportWidth", + "24", + "viewportHeight", + "path", + "fillColor", + "#00ff00", + "pathData", + "M0,0 H24 V24 H0 Z", + ]; + let mut data = Vec::new(); + let mut offsets = Vec::new(); + for value in strings { + offsets.push(data.len() as u32); + data.extend([value.len() as u8, value.len() as u8]); + data.extend(value.as_bytes()); + data.push(0); + } + while data.len() % 4 != 0 { + data.push(0); + } + let mut body = Vec::new(); + for value in [ + 0x001c0001u32, + (28 + offsets.len() * 4 + data.len()) as u32, + offsets.len() as u32, + 0, + 0x100, + (28 + offsets.len() * 4) as u32, + 0, + ] { + body.extend(value.to_le_bytes()); + } + for offset in offsets { + body.extend(offset.to_le_bytes()); + } + body.extend(data); + body.extend(0x00080180u32.to_le_bytes()); + body.extend(8u32.to_le_bytes()); + for (tag, attrs) in [(0, vec![(1, 2), (3, 2)]), (4, vec![(5, 6), (7, 8)])] { + for value in [ + 0x00100102u32, + 36 + attrs.len() as u32 * 20, + 1, + u32::MAX, + u32::MAX, + tag, + 0x00140014, + attrs.len() as u32, + 0, + ] { + body.extend(value.to_le_bytes()); + } + for (name, value) in attrs { + for word in [u32::MAX, name, value, 0x03000008, value] { + body.extend(word.to_le_bytes()); + } + } + } + for tag in [4, 0] { + for word in [0x00100103u32, 24, 1, u32::MAX, u32::MAX, tag] { + body.extend(word.to_le_bytes()); + } + } + let mut binary = Vec::new(); + binary.extend(0x00080003u32.to_le_bytes()); + binary.extend((body.len() as u32 + 8).to_le_bytes()); + binary.extend(body); + let mut resources = FakeResources::default(); + resources.files.insert("icon.xml".into(), binary); + let image = pixels(render(resources, "icon.xml").expect("binary vector")).expect("PNG"); + assert_eq!(image.pixel(96, 96).expect("pixel").green(), 255); +} diff --git a/src-tauri/src/features/apk/infrastructure.rs b/src-tauri/src/features/apk/infrastructure.rs new file mode 100644 index 00000000..2a2d986b --- /dev/null +++ b/src-tauri/src/features/apk/infrastructure.rs @@ -0,0 +1,288 @@ +use super::domain::*; +use apk_info::{Apk, Signature}; +use openssl::hash::{Hasher, MessageDigest}; +use std::{ + fs::File, + io::{Read, Write}, + path::Path, +}; + +/// Bounds interactive analysis, including decompressed metadata and IPC output. +const MAX_APK: u64 = 1024 * 1024 * 1024; +const MAX_METADATA: u64 = 64 * 1024 * 1024; +const MAX_FILES: usize = 100_000; + +pub struct NativeApkInspector; + +fn invalid() -> ApkError { + ApkError::new( + "invalid_apk", + "This file is not a readable APK, or its Android manifest is invalid.", + ) +} +fn io_error() -> ApkError { + ApkError::new( + "read_failed", + "The APK could not be read. Check that the file exists and is accessible.", + ) +} +fn limit() -> ApkError { + ApkError::new( + "limit_exceeded", + "This APK exceeds the analysis limits (1 GiB file, 64 MiB metadata, 100,000 entries).", + ) +} +fn section(title: &str, values: Vec<(&str, Option)>) -> Section { + Section { + title: title.into(), + items: values + .into_iter() + .map(|(label, value)| InfoItem { + label: label.into(), + value, + }) + .collect(), + } +} +fn list<'a>(title: &str, values: impl Iterator) -> Section { + section( + title, + values.map(|v| (v, Some("Declared".into()))).collect(), + ) +} +fn components(title: &str, values: impl Iterator) -> Section { + Section { + title: title.into(), + items: values + .enumerate() + .map(|(index, value)| { + let object = serde_json::to_value(value).unwrap_or_default(); + let label = object + .get("name") + .and_then(|v| v.as_str()) + .map(str::to_owned) + .unwrap_or_else(|| format!("Unnamed component {}", index + 1)); + let details = object + .as_object() + .map(|fields| { + fields + .iter() + .filter(|(key, value)| *key != "name" && !value.is_null()) + .map(|(key, value)| { + format!( + "{}: {}", + key.replace('_', " "), + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) + ) + }) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + InfoItem { + label, + value: Some(if details.is_empty() { + "No additional attributes declared".into() + } else { + details + }), + } + }) + .collect(), + } +} + +impl ApkInspector for NativeApkInspector { + fn inspect(&self, path: &Path) -> Result { + let source = File::open(path).map_err(|_| io_error())?; + let metadata = source.metadata().map_err(|_| io_error())?; + if !metadata.is_file() { + return Err(io_error()); + } + if metadata.len() > MAX_APK { + return Err(limit()); + } + // Parse and hash the same bounded snapshot even if the original file changes. + let mut source = source.take(MAX_APK + 1); + let mut snapshot = tempfile::NamedTempFile::new().map_err(|_| io_error())?; + let mut hasher = Hasher::new(MessageDigest::sha256()).map_err(|_| io_error())?; + let mut buffer = [0u8; 65536]; + let mut size = 0; + loop { + let count = source.read(&mut buffer).map_err(|_| io_error())?; + if count == 0 { + break; + } + size += count as u64; + if size > MAX_APK { + return Err(limit()); + } + hasher.update(&buffer[..count]).map_err(|_| io_error())?; + snapshot + .write_all(&buffer[..count]) + .map_err(|_| io_error())?; + } + snapshot.flush().map_err(|_| io_error())?; + let sha256 = hasher + .finish() + .map_err(|_| io_error())? + .iter() + .map(|b| format!("{b:02X}")) + .collect(); + let mut zip = zip::ZipArchive::new(File::open(snapshot.path()).map_err(|_| io_error())?) + .map_err(|_| invalid())?; + if zip.len() > MAX_FILES { + return Err(limit()); + } + let mut files = Vec::with_capacity(zip.len()); + let mut metadata_size = 0u64; + for i in 0..zip.len() { + let entry = zip.by_index(i).map_err(|_| invalid())?; + if entry.name() == "AndroidManifest.xml" + || entry.name() == "resources.arsc" + || entry.name().starts_with("META-INF/") + { + metadata_size = metadata_size.saturating_add(entry.size()); + if metadata_size > MAX_METADATA { + return Err(limit()); + } + } + files.push(ArchiveFile { + name: entry.name().into(), + size: entry.size(), + compressed_size: entry.compressed_size(), + }); + } + if !files.iter().any(|f| f.name == "AndroidManifest.xml") { + return Err(invalid()); + } + files.sort_by(|a, b| a.name.cmp(&b.name)); + let apk = Apk::new(snapshot.path()).map_err(|_| invalid())?; + let icon_data_url = super::icons::preview(&apk, &mut zip); + let has_v1 = files.iter().any(|file| { + let name = file.name.to_ascii_uppercase(); + name.starts_with("META-INF/") + && [".RSA", ".DSA", ".EC", ".SF"] + .iter() + .any(|ext| name.ends_with(ext)) + }); + let signature = super::verification::inspect(snapshot.path(), has_v1); + let package = apk + .get_package_name() + .filter(|p| !p.is_empty()) + .ok_or_else(invalid)?; + let mut sections = vec![ + section( + "Application", + vec![ + ("Package name", Some(package)), + ("Application label", apk.get_application_label()), + ("Version name", apk.get_version_name()), + ("Version code", apk.get_version_code()), + ( + "Version code major", + apk.get_attribute_value("manifest", "versionCodeMajor"), + ), + ("Application class", apk.get_application_name()), + ( + "Launcher activity", + apk.get_main_activity().map(str::to_owned), + ), + ("Icon resource", apk.get_application_icon()), + ("Install location", apk.get_install_location()), + ("Split name", apk.get_attribute_value("manifest", "split")), + ], + ), + section( + "Android compatibility", + vec![ + ("Minimum SDK", apk.get_min_sdk_version()), + ( + "Target SDK (declared)", + apk.get_attribute_value("uses-sdk", "targetSdkVersion"), + ), + ("Maximum SDK", apk.get_max_sdk_version()), + ("Compile SDK", apk.get_compile_sdk_version()), + ("Native ABIs", Some(apk.get_supported_abis().join(", "))), + ("Multidex", Some(apk.is_multidex().to_string())), + ], + ), + section( + "Application flags (declared)", + vec![ + ("Debuggable", apk.get_application_debuggable()), + ("Allow backup", apk.get_application_allow_backup()), + ( + "Uses cleartext traffic", + apk.get_attribute_value("application", "usesCleartextTraffic"), + ), + ( + "Network security config", + apk.get_attribute_value("application", "networkSecurityConfig"), + ), + ( + "Test only", + apk.get_attribute_value("application", "testOnly"), + ), + ( + "Extract native libraries", + apk.get_attribute_value("application", "extractNativeLibs"), + ), + ], + ), + list("Permissions", apk.get_permissions()), + list("Permissions (SDK 23+)", apk.get_permissions_sdk23()), + list("Declared features", apk.get_features()), + list("Shared libraries", apk.get_libraries()), + list("Native libraries", apk.get_native_libraries()), + components("Declared permissions", apk.get_declared_permissions()), + components("Activities", apk.get_activities()), + components("Activity aliases", apk.get_activity_aliases()), + components("Services", apk.get_services()), + components("Receivers", apk.get_receivers()), + components("Providers", apk.get_providers()), + ]; + let mut warnings = Vec::new(); + match apk.get_signatures() { + Ok(signatures) => { + let mut count = 0; + for signature in signatures { + let name = signature.name(); + let certificates = match signature { + Signature::V1(c) | Signature::V2(c) | Signature::V3(c) | Signature::V31(c) => c, + Signature::StampBlockV1(c) | Signature::StampBlockV2(c) => vec![c], + _ => continue, + }; + for (index, c) in certificates.into_iter().enumerate() { + count += 1; + sections.push(section(&format!("Certificate Β· {name} Β· {}", index + 1), vec![ + ("Subject", Some(c.subject)), ("Issuer", Some(c.issuer)), ("Serial number", Some(c.serial_number)), + ("Valid from", Some(c.valid_from)), ("Valid until", Some(c.valid_until)), + ("Signature algorithm", Some(c.signature_type)), ("SHA-1", Some(c.sha1_fingerprint)), ("SHA-256", Some(c.sha256_fingerprint)), + ])); + } + } + if count == 0 { warnings.push("No signing certificates could be extracted.".into()); } + } + Err(_) => warnings.push("Signing certificates could not be parsed; other APK information is still available.".into()), + } + let manifest = apk.get_xml_string(); + if manifest.len() as u64 > MAX_METADATA { + return Err(limit()); + } + Ok(ApkReport { + path: path.to_string_lossy().into_owned(), + size, + icon_data_url, + signature, + sha256, + sections, + files, + manifest, + warnings, + }) + } +} diff --git a/src-tauri/src/features/apk/mod.rs b/src-tauri/src/features/apk/mod.rs new file mode 100644 index 00000000..faef721a --- /dev/null +++ b/src-tauri/src/features/apk/mod.rs @@ -0,0 +1,9 @@ +//! Local, read-only APK inspection with no Android SDK dependency. +pub mod commands; +pub mod domain; +mod icons; +pub mod infrastructure; +mod verification; + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/features/apk/tests.rs b/src-tauri/src/features/apk/tests.rs new file mode 100644 index 00000000..930942a7 --- /dev/null +++ b/src-tauri/src/features/apk/tests.rs @@ -0,0 +1,316 @@ +use super::{domain::*, infrastructure::NativeApkInspector}; +use std::{io::Write, path::Path}; +use zip::{ZipWriter, write::SimpleFileOptions}; + +// Minimal Android binary XML, generated here rather than depending on SDK tools +// or redistributing a third-party application. All attributes use string values. +fn manifest() -> Vec { + let strings = [ + "manifest", + "package", + "com.example.fixture", + "versionCode", + "42", + "versionName", + "2.0", + "application", + "label", + "Fixture", + "uses-permission", + "name", + "android.permission.INTERNET", + "activity", + ".MainActivity", + "exported", + "true", + "icon", + "res/icon.png", + ]; + let mut data = Vec::new(); + let mut offsets = Vec::new(); + for s in strings { + offsets.push(data.len() as u32); + data.extend([s.len() as u8, s.len() as u8]); + data.extend(s.as_bytes()); + data.push(0); + } + while data.len() % 4 != 0 { + data.push(0); + } + let mut pool = Vec::new(); + for value in [ + 0x001c0001, + (28 + offsets.len() * 4 + data.len()) as u32, + offsets.len() as u32, + 0, + 0x100, + (28 + offsets.len() * 4) as u32, + 0, + ] { + pool.extend(value.to_le_bytes()); + } + for offset in offsets { + pool.extend(offset.to_le_bytes()); + } + pool.extend(data); + pool.extend(0x00080180u32.to_le_bytes()); + pool.extend(8u32.to_le_bytes()); + fn start(out: &mut Vec, tag: u32, attrs: &[(u32, u32)]) { + for v in [ + 0x00100102, + 36 + attrs.len() as u32 * 20, + 1, + u32::MAX, + u32::MAX, + tag, + 0x00140014, + attrs.len() as u32, + 0, + ] { + out.extend(v.to_le_bytes()); + } + for &(name, value) in attrs { + for v in [u32::MAX, name, value, 0x03000008, value] { + out.extend(v.to_le_bytes()); + } + } + } + fn end(out: &mut Vec, tag: u32) { + for v in [0x00100103u32, 24, 1, u32::MAX, u32::MAX, tag] { + out.extend(v.to_le_bytes()); + } + } + start(&mut pool, 0, &[(1, 2), (3, 4), (5, 6)]); + start(&mut pool, 10, &[(11, 12)]); + end(&mut pool, 10); + start(&mut pool, 7, &[(8, 9), (17, 18)]); + start(&mut pool, 13, &[(11, 14), (15, 16)]); + end(&mut pool, 13); + end(&mut pool, 7); + end(&mut pool, 0); + let mut output = Vec::new(); + output.extend(0x00080003u32.to_le_bytes()); + output.extend((8 + pool.len() as u32).to_le_bytes()); + output.extend(pool); + output +} + +fn fixture(manifest: Option<&[u8]>) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("fixture.apk"); + let mut zip = ZipWriter::new(std::fs::File::create(&path).expect("fixture file")); + if let Some(xml) = manifest { + zip.start_file("AndroidManifest.xml", SimpleFileOptions::default()) + .expect("manifest entry"); + zip.write_all(xml).expect("manifest content"); + } + zip.start_file("lib/arm64-v8a/libfixture.so", SimpleFileOptions::default()) + .expect("library entry"); + zip.write_all(b"fixture").expect("library content"); + zip.finish().expect("archive"); + (dir, path) +} + +#[test] +fn inspects_binary_manifest_archive_and_hash_without_sdk() { + let (_dir, path) = fixture(Some(&manifest())); + let apk = apk_info::Apk::new(&path).expect("binary fixture parses"); + assert!(apk.get_package_name().is_some(), "{}", apk.get_xml_string()); + let report = analyze(&path, &NativeApkInspector).expect("valid APK"); + let app = &report.sections[0]; + assert!(app.items.iter().any(|i| i.label == "Package name" && i.value.as_deref() == Some("com.example.fixture"))); + assert!( + app.items + .iter() + .any(|i| i.label == "Version code" && i.value.as_deref() == Some("42")) + ); + assert!( + report + .sections + .iter() + .any(|s| s.title == "Permissions" && s.items[0].label == "android.permission.INTERNET") + ); + assert!(report.sections.iter().any(|s| { + s.title == "Activities" + && s.items[0] + .value + .as_deref() + .is_some_and(|v| v.contains("exported: true")) + })); + assert!(report.manifest.contains("com.example.fixture")); + assert_eq!(report.files.len(), 2); + assert_eq!(report.signature.status, VerificationStatus::Unsigned); + assert!( + report + .warnings + .iter() + .any(|w| w.contains("No signing certificates")) + ); + let bytes = std::fs::read(&path).expect("fixture bytes"); + let hash: String = openssl::sha::sha256(&bytes) + .iter() + .map(|b| format!("{b:02X}")) + .collect(); + assert_eq!(report.sha256, hash); + assert_eq!(report.size, bytes.len() as u64); + let json = serde_json::to_value(&report).expect("wire report"); + assert!(json["files"][0]["compressedSize"].is_number()); + assert!(json["iconDataUrl"].is_null()); +} + +#[test] +fn previews_the_referenced_raster_icon_and_ignores_unsupported_or_oversized_icons() { + let png = openssl::base64::decode_block("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1sAAAAASUVORK5CYII=").expect("PNG"); + for (bytes, expected) in [ + ( + png.clone(), + Some(format!( + "data:image/png;base64,{}", + openssl::base64::encode_block(&png) + )), + ), + (b"".to_vec(), None), + (vec![0; 2 * 1024 * 1024 + 1], None), + ] { + let (_dir, path) = fixture(Some(&manifest())); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("archive"); + let mut zip = ZipWriter::new_append(file).expect("append"); + zip.start_file("res/icon.png", SimpleFileOptions::default()) + .expect("icon entry"); + zip.write_all(&bytes).expect("icon"); + zip.finish().expect("finish"); + let report = + analyze(&path, &NativeApkInspector).expect("report survives unsupported icons"); + assert_eq!(report.icon_data_url, expected); + } +} + +#[test] +fn rejects_missing_corrupt_and_non_apk_inputs() { + for xml in [None, Some(&b"not binary XML"[..])] { + let (_dir, path) = fixture(xml); + assert_eq!( + analyze(&path, &NativeApkInspector) + .expect_err("invalid APK") + .code, + "invalid_apk" + ); + } + assert_eq!( + analyze(Path::new("/missing.apk"), &NativeApkInspector) + .expect_err("missing") + .code, + "read_failed" + ); + struct Unreachable; + impl ApkInspector for Unreachable { + fn inspect(&self, _: &Path) -> Result { + panic!("invalid extension must not reach the adapter") + } + } + assert_eq!( + analyze(Path::new("file.zip"), &Unreachable) + .expect_err("extension") + .code, + "invalid_extension" + ); +} + +#[test] +fn rejects_oversized_apk_before_reading_contents() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("large.apk"); + std::fs::File::create(&path) + .expect("file") + .set_len(1024 * 1024 * 1024 + 1) + .expect("sparse file"); + assert_eq!( + analyze(&path, &NativeApkInspector).expect_err("limit").code, + "limit_exceeded" + ); +} + +#[test] +fn reads_v1_certificate_identity_and_fingerprints_without_claiming_verification() { + use openssl::{ + asn1::Asn1Time, + hash::MessageDigest, + pkcs7::{Pkcs7, Pkcs7Flags}, + pkey::PKey, + rsa::Rsa, + stack::Stack, + x509::{X509, X509NameBuilder}, + }; + let key = PKey::from_rsa(Rsa::generate(2048).expect("RSA")).expect("key"); + let mut name = X509NameBuilder::new().expect("name"); + name.append_entry_by_text("CN", "APK fixture signer") + .expect("CN"); + let name = name.build(); + let mut cert = X509::builder().expect("certificate"); + cert.set_version(2).expect("version"); + let serial = openssl::bn::BigNum::from_u32(42) + .expect("serial") + .to_asn1_integer() + .expect("ASN1"); + cert.set_serial_number(&serial).expect("serial"); + cert.set_subject_name(&name).expect("subject"); + cert.set_issuer_name(&name).expect("issuer"); + cert.set_pubkey(&key).expect("public key"); + cert.set_not_before(&Asn1Time::from_unix(1704067200).expect("date")) + .expect("start"); + cert.set_not_after(&Asn1Time::from_unix(2019686400).expect("date")) + .expect("end"); + cert.sign(&key, MessageDigest::sha256()).expect("sign"); + let cert = cert.build(); + let signature = Pkcs7::sign( + &cert, + &key, + &Stack::new().expect("chain"), + b"fixture", + Pkcs7Flags::BINARY | Pkcs7Flags::DETACHED, + ) + .expect("PKCS7") + .to_der() + .expect("DER"); + let (_dir, path) = fixture(Some(&manifest())); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("archive"); + let mut zip = ZipWriter::new_append(file).expect("append"); + zip.start_file("META-INF/CERT.RSA", SimpleFileOptions::default()) + .expect("signature entry"); + zip.write_all(&signature).expect("signature"); + zip.finish().expect("finish"); + let report = analyze(&path, &NativeApkInspector).expect("report"); + let certificate = report + .sections + .iter() + .find(|s| s.title.starts_with("Certificate Β· v1")) + .expect("v1 certificate"); + assert!(certificate.items.iter().any(|i| { + i.label == "Subject" + && i.value + .as_deref() + .is_some_and(|v| v.contains("APK fixture signer")) + })); + let fingerprint = cert + .digest(MessageDigest::sha256()) + .expect("digest") + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + assert!(certificate.items.iter().any(|i| { + i.label == "SHA-256" + && i.value + .as_deref() + .is_some_and(|v| v.replace(':', "").to_lowercase() == fingerprint) + })); + assert_eq!(report.signature.status, VerificationStatus::Unverified); + assert_eq!(report.signature.schemes, ["v1"]); +} diff --git a/src-tauri/src/features/apk/verification.rs b/src-tauri/src/features/apk/verification.rs new file mode 100644 index 00000000..05cbcfd1 --- /dev/null +++ b/src-tauri/src/features/apk/verification.rs @@ -0,0 +1,364 @@ +//! APK v2/v3/v3.1 cryptographic integrity checks over the original signed bytes. +//! This does not implement Android install policy, rotation lineage or JAR/v1 verification. +use super::domain::{SignatureVerification, VerificationStatus}; +use apksig::{Algorithms, digest_apk, zip::FileOffsets}; +use openssl::{ + hash::MessageDigest, + pkey::PKey, + rsa::Padding, + sign::{RsaPssSaltlen, Verifier}, + x509::X509, +}; +use std::{ + collections::{BTreeMap, HashSet}, + fs::File, + io::{Read, Seek, SeekFrom}, + path::Path, +}; + +const V2: u32 = 0x7109871a; +const V3: u32 = 0xf05368c0; +const V31: u32 = 0x1b93ad61; +const MAX_BLOCK: usize = 16 * 1024 * 1024; + +#[derive(Debug)] +enum Failure { + Invalid, + Unsupported, + Io, +} +type Result = std::result::Result; + +/// Length-prefixed records must be exhausted; trailing/duplicate data is rejected. +struct Bytes<'a>(&'a [u8]); +impl<'a> Bytes<'a> { + fn take(&mut self, count: usize) -> Result<&'a [u8]> { + let (value, rest) = self.0.split_at_checked(count).ok_or(Failure::Invalid)?; + self.0 = rest; + Ok(value) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes( + self.take(4)?.try_into().map_err(|_| Failure::Invalid)?, + )) + } + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes( + self.take(8)?.try_into().map_err(|_| Failure::Invalid)?, + )) + } + fn record(&mut self) -> Result> { + let size = self.u32()? as usize; + Ok(Bytes(self.take(size)?)) + } + fn end(&self) -> Result<()> { + if self.0.is_empty() { + Ok(()) + } else { + Err(Failure::Invalid) + } + } +} + +fn read_at(file: &mut File, offset: u64, size: usize) -> Result> { + file.seek(SeekFrom::Start(offset)) + .map_err(|_| Failure::Io)?; + let mut bytes = vec![0; size]; + file.read_exact(&mut bytes).map_err(|_| Failure::Io)?; + Ok(bytes) +} + +/// Locate the signing block immediately before the central directory, not by scanning +/// for a magic string in arbitrary file data. ZIP64/multi-disk APKs are unsupported. +fn signing_block(file: &mut File) -> Result, FileOffsets)>> { + let size = file.metadata().map_err(|_| Failure::Io)?.len(); + let tail_len = size.min(65535 + 22) as usize; + let tail = read_at(file, size - tail_len as u64, tail_len)?; + let pos = (0..tail_len.saturating_sub(21)) + .rev() + .find(|&i| { + tail[i..].starts_with(b"PK\x05\x06") + && u16::from_le_bytes([tail[i + 20], tail[i + 21]]) as usize + i + 22 == tail_len + }) + .ok_or(Failure::Invalid)?; + let eocd = &tail[pos..]; + if eocd[4..8] != [0; 4] || eocd[8..10] != eocd[10..12] { + return Err(Failure::Unsupported); + } + let mut fields = Bytes(&eocd[12..20]); + let cd_size = fields.u32()? as u64; + let cd = fields.u32()? as u64; + let eocd_offset = size - tail_len as u64 + pos as u64; + if cd == u32::MAX as u64 || cd_size == u32::MAX as u64 { + return Err(Failure::Unsupported); + } + if cd + cd_size != eocd_offset { + return Err(Failure::Invalid); + } + if cd < 24 { + return Ok(None); + } + let footer = read_at(file, cd - 24, 24)?; + if &footer[8..] != b"APK Sig Block 42" { + return Ok(None); + } + let length = Bytes(&footer).u64()?; + if length < 24 || length > cd - 8 { + return Err(Failure::Invalid); + } + if length > MAX_BLOCK as u64 { + return Err(Failure::Unsupported); + } + let start = cd - length - 8; + let block = read_at(file, start, (length + 8) as usize)?; + if Bytes(&block).u64()? != length { + return Err(Failure::Invalid); + } + let entries = block[8..block.len() - 24].to_vec(); + Ok(Some(( + entries, + FileOffsets { + start_content: 0, + stop_content: start as usize, + start_cd: cd as usize, + stop_cd: eocd_offset as usize, + start_eocd: eocd_offset as usize, + stop_eocd: size as usize, + }, + ))) +} + +fn records<'a>(mut bytes: Bytes<'a>) -> Result> { + let mut result = Vec::new(); + let mut ids = HashSet::new(); + while !bytes.0.is_empty() { + let mut entry = bytes.record()?; + let id = entry.u32()?; + let data = entry.record()?.0; + entry.end()?; + if !ids.insert(id) { + return Err(Failure::Invalid); + } + result.push((id, data)); + } + Ok(result) +} + +fn algorithm(id: u32) -> Option<(MessageDigest, bool, u8)> { + match id { + 0x0101 => Some((MessageDigest::sha256(), true, 1)), + 0x0102 => Some((MessageDigest::sha512(), true, 2)), + 0x0103 | 0x0201 | 0x0301 => Some((MessageDigest::sha256(), false, 1)), + 0x0104 | 0x0202 => Some((MessageDigest::sha512(), false, 2)), + _ => None, + } +} + +fn verify_signer( + mut signer: Bytes<'_>, + id: u32, + file: &mut File, + offsets: &FileOffsets, + cache: &mut BTreeMap>, + present: &HashSet, +) -> Result<()> { + let signed_bytes = signer.record()?.0; + let range = if id != V2 { + Some((signer.u32()?, signer.u32()?)) + } else { + None + }; + let signatures = records(signer.record()?)?; + let public_bytes = signer.record()?.0; + signer.end()?; + let mut data = Bytes(signed_bytes); + let digests = records(data.record()?)?; + if signatures.iter().map(|s| s.0).collect::>() + != digests.iter().map(|d| d.0).collect::>() + { + return Err(Failure::Invalid); + } + let mut certificates = data.record()?; + let first = certificates.record()?.0; + let cert = X509::from_der(first).map_err(|_| Failure::Invalid)?; + while !certificates.0.is_empty() { + X509::from_der(certificates.record()?.0).map_err(|_| Failure::Invalid)?; + } + if let Some((min, max)) = range + && (min > max || (data.u32()?, data.u32()?) != (min, max)) + { + return Err(Failure::Invalid); + } + let mut attributes = data.record()?; + let mut seen = HashSet::new(); + while !attributes.0.is_empty() { + let mut attr = attributes.record()?; + let kind = attr.u32()?; + if !seen.insert(kind) { + return Err(Failure::Invalid); + } + // v2 stripping protection: a signed declaration of v3 requires its block. + if id == V2 && kind == 0xbeeff00d && attr.u32()? == 3 && !present.contains(&V3) { + return Err(Failure::Invalid); + } + } + data.end()?; + let public = PKey::public_key_from_der(public_bytes).map_err(|_| Failure::Invalid)?; + if !cert + .public_key() + .map_err(|_| Failure::Invalid)? + .public_eq(&public) + { + return Err(Failure::Invalid); + } + // Android selects the strongest supported algorithm, not every optional algorithm. + let (index, (md, pss, hash_kind)) = signatures + .iter() + .enumerate() + .filter_map(|(i, s)| algorithm(s.0).map(|a| (i, a))) + .max_by_key(|(_, a)| a.2) + .ok_or(Failure::Unsupported)?; + let (algo, signature) = signatures[index]; + let expected_key_type = match algo { + 0x0101..=0x0104 => openssl::pkey::Id::RSA, + 0x0201..=0x0202 => openssl::pkey::Id::EC, + _ => openssl::pkey::Id::DSA, + }; + if public.id() != expected_key_type { + return Err(Failure::Invalid); + } + let mut verifier = Verifier::new(md, &public).map_err(|_| Failure::Unsupported)?; + if pss { + verifier + .set_rsa_padding(Padding::PKCS1_PSS) + .map_err(|_| Failure::Unsupported)?; + verifier + .set_rsa_mgf1_md(md) + .map_err(|_| Failure::Unsupported)?; + verifier + .set_rsa_pss_saltlen(RsaPssSaltlen::DIGEST_LENGTH) + .map_err(|_| Failure::Unsupported)?; + } + if !verifier + .verify_oneshot(signature, signed_bytes) + .map_err(|_| Failure::Invalid)? + { + return Err(Failure::Invalid); + } + if let std::collections::btree_map::Entry::Vacant(entry) = cache.entry(hash_kind) { + let hashing = if hash_kind == 2 { + Algorithms::RSASSA_PKCS1_v1_5_512 + } else { + Algorithms::RSASSA_PKCS1_v1_5_256 + }; + entry.insert(digest_apk(file, offsets, &hashing).map_err(|_| Failure::Io)?); + } + if cache[&hash_kind].as_slice() != digests[index].1 { + return Err(Failure::Invalid); + } + Ok(()) +} + +fn verify(path: &Path) -> Result> { + let mut file = File::open(path).map_err(|_| Failure::Io)?; + let Some((entries, offsets)) = signing_block(&mut file)? else { + return Ok(Vec::new()); + }; + let mut entries = Bytes(&entries); + let mut blocks = Vec::new(); + let mut present = HashSet::new(); + while !entries.0.is_empty() { + let length = entries.u64()?; + let mut entry = + Bytes(entries.take(usize::try_from(length).map_err(|_| Failure::Invalid)?)?); + let id = entry.u32()?; + if !present.insert(id) { + return Err(Failure::Invalid); + } + if [V2, V3, V31].contains(&id) { + blocks.push((id, entry.0)); + } + } + if blocks.is_empty() { + return Err(Failure::Unsupported); + } + let mut cache = BTreeMap::new(); + let mut verified = Vec::new(); + let mut unsupported = false; + for (id, bytes) in blocks { + let mut outer = Bytes(bytes); + let mut signers = outer.record()?; + outer.end()?; + if signers.0.is_empty() { + return Err(Failure::Invalid); + } + while !signers.0.is_empty() { + match verify_signer( + signers.record()?, + id, + &mut file, + &offsets, + &mut cache, + &present, + ) { + Err(Failure::Unsupported) => unsupported = true, + result => result?, + } + } + verified.push( + match id { + V2 => "v2", + V3 => "v3", + _ => "v3.1", + } + .into(), + ); + } + if unsupported { + return Err(Failure::Unsupported); + } + Ok(verified) +} + +pub(super) fn inspect(path: &Path, has_v1: bool) -> SignatureVerification { + let (status, schemes, message) = match verify(path) { + Ok(schemes) if !schemes.is_empty() => ( + VerificationStatus::Verified, + schemes, + "APK content digests and signer signatures match. This verifies file integrity, not publisher trust or Android installation compatibility.", + ), + Ok(_) if has_v1 => ( + VerificationStatus::Unverified, + vec!["v1".into()], + "This APK uses JAR/v1 signing. Its certificates are readable, but v1 cryptographic verification is not supported yet.", + ), + Ok(_) => ( + VerificationStatus::Unsigned, + vec![], + "No APK signing block or JAR signature was found.", + ), + Err(Failure::Invalid) => ( + VerificationStatus::Invalid, + vec![], + "The signature, signed content digest, certificate key or signing block is invalid. The APK may have been modified after signing.", + ), + Err(Failure::Unsupported) => ( + VerificationStatus::Unverified, + vec![], + "This APK uses a signing algorithm or structure that cannot be fully verified.", + ), + Err(Failure::Io) => ( + VerificationStatus::Unverified, + vec![], + "The APK could not be read completely for signature verification.", + ), + }; + SignatureVerification { + status, + schemes, + message: message.into(), + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/features/apk/verification/tests.rs b/src-tauri/src/features/apk/verification/tests.rs new file mode 100644 index 00000000..6f697017 --- /dev/null +++ b/src-tauri/src/features/apk/verification/tests.rs @@ -0,0 +1,206 @@ +use super::*; +use openssl::{ + asn1::Asn1Time, + bn::BigNum, + ec::{EcGroup, EcKey}, + nid::Nid, + pkey::Private, + rsa::Rsa, + sign::Signer, + x509::X509NameBuilder, +}; +use std::io::{Cursor, Write}; +use zip::{ZipWriter, write::SimpleFileOptions}; + +fn record(bytes: &[u8]) -> Vec { + [(bytes.len() as u32).to_le_bytes().as_slice(), bytes].concat() +} +fn algorithm_record(id: u32, bytes: &[u8]) -> Vec { + record(&[id.to_le_bytes().to_vec(), record(bytes)].concat()) +} + +fn identity(ec: bool) -> (PKey, Vec) { + let key = if ec { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("curve"); + PKey::from_ec_key(EcKey::generate(&group).expect("EC key")).expect("key") + } else { + PKey::from_rsa(Rsa::generate(2048).expect("RSA key")).expect("key") + }; + let mut name = X509NameBuilder::new().expect("name"); + name.append_entry_by_text("CN", "Integrity fixture") + .expect("CN"); + let name = name.build(); + let mut cert = X509::builder().expect("certificate"); + cert.set_version(2).expect("version"); + cert.set_serial_number( + &BigNum::from_u32(1) + .expect("serial") + .to_asn1_integer() + .expect("ASN1"), + ) + .expect("serial"); + cert.set_subject_name(&name).expect("subject"); + cert.set_issuer_name(&name).expect("issuer"); + cert.set_pubkey(&key).expect("public key"); + cert.set_not_before(&Asn1Time::from_unix(1704067200).expect("date")) + .expect("start"); + cert.set_not_after(&Asn1Time::from_unix(2019686400).expect("date")) + .expect("end"); + cert.sign(&key, MessageDigest::sha256()) + .expect("sign certificate"); + (key, cert.build().to_der().expect("DER")) +} + +struct Fixture { + file: tempfile::NamedTempFile, + signature: Vec, +} +fn fixture(scheme: u32, algo: u32, wrong_certificate: bool) -> Fixture { + let (key, mut certificate) = identity(algo == 0x0201); + if wrong_certificate { + certificate = identity(false).1; + } + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file( + "classes.dex", + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), + ) + .expect("entry"); + zip.write_all(b"known application contents") + .expect("entry data"); + let raw = zip.finish().expect("ZIP").into_inner(); + let mut unsigned = tempfile::NamedTempFile::new().expect("file"); + unsigned.write_all(&raw).expect("write"); + let digest = apksig::Apk::new_raw(unsigned.path().into()) + .expect("APK") + .digest(&Algorithms::RSASSA_PKCS1_v1_5_256) + .expect("digest"); + let range = [28u32.to_le_bytes(), 35u32.to_le_bytes()].concat(); + let mut signed = [ + record(&algorithm_record(algo, &digest)), + record(&record(&certificate)), + ] + .concat(); + if scheme != V2 { + signed.extend(&range); + } + signed.extend(record(&[])); + let mut signer = Signer::new(MessageDigest::sha256(), &key).expect("signer"); + if algo == 0x0101 { + signer.set_rsa_padding(Padding::PKCS1_PSS).expect("PSS"); + signer + .set_rsa_mgf1_md(MessageDigest::sha256()) + .expect("MGF"); + signer + .set_rsa_pss_saltlen(RsaPssSaltlen::DIGEST_LENGTH) + .expect("salt"); + } + let signature = signer.sign_oneshot_to_vec(&signed).expect("signature"); + let mut signer = record(&signed); + if scheme != V2 { + signer.extend(&range); + } + signer.extend(record(&algorithm_record(algo, &signature))); + signer.extend(record(&key.public_key_to_der().expect("public key"))); + let value = record(&record(&signer)); + let pair = [ + ((value.len() + 4) as u64).to_le_bytes().to_vec(), + scheme.to_le_bytes().to_vec(), + value, + ] + .concat(); + let size = (pair.len() as u64 + 24).to_le_bytes(); + let block = [size.as_slice(), &pair, &size, b"APK Sig Block 42"].concat(); + let eocd = raw.len() - 22; + let cd = u32::from_le_bytes(raw[eocd + 16..eocd + 20].try_into().expect("CD offset")) as usize; + let mut output = [raw[..cd].to_vec(), block.clone(), raw[cd..].to_vec()].concat(); + output[eocd + block.len() + 16..eocd + block.len() + 20] + .copy_from_slice(&((cd + block.len()) as u32).to_le_bytes()); + let mut file = tempfile::NamedTempFile::new().expect("file"); + file.write_all(&output).expect("APK"); + Fixture { file, signature } +} + +#[test] +fn verifies_v2_v3_v31_rsa_pss_and_ecdsa_against_apk_contents() { + for (scheme, algo, name) in [ + (V2, 0x0103, "v2"), + (V3, 0x0103, "v3"), + (V31, 0x0103, "v3.1"), + (V2, 0x0101, "v2"), + (V3, 0x0201, "v3"), + ] { + let f = fixture(scheme, algo, false); + let result = inspect(f.file.path(), false); + assert_eq!( + result.status, + VerificationStatus::Verified, + "{scheme:x} / {algo:x}: {}", + result.message + ); + assert_eq!(result.schemes, [name]); + } +} + +#[test] +fn rejects_modified_content_signature_and_certificate_key() { + for mutate_signature in [false, true] { + let f = fixture(V2, 0x0103, false); + let mut data = std::fs::read(f.file.path()).expect("APK"); + let needle = if mutate_signature { + f.signature.as_slice() + } else { + b"known application contents" + }; + let index = data + .windows(needle.len()) + .position(|w| w == needle) + .expect("test data"); + data[index] ^= 1; + std::fs::write(f.file.path(), data).expect("tamper"); + assert_eq!( + inspect(f.file.path(), false).status, + VerificationStatus::Invalid + ); + } + let f = fixture(V2, 0x0103, true); + assert_eq!( + inspect(f.file.path(), false).status, + VerificationStatus::Invalid + ); +} + +#[test] +fn distinguishes_unsupported_algorithms_v1_unsigned_and_malformed_blocks() { + let f = fixture(V2, 0x9999, false); + assert_eq!( + inspect(f.file.path(), false).status, + VerificationStatus::Unverified + ); + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file("classes.dex", SimpleFileOptions::default()) + .expect("entry"); + let bytes = zip.finish().expect("ZIP").into_inner(); + let mut file = tempfile::NamedTempFile::new().expect("file"); + file.write_all(&bytes).expect("ZIP"); + assert_eq!( + inspect(file.path(), false).status, + VerificationStatus::Unsigned + ); + assert_eq!( + inspect(file.path(), true).status, + VerificationStatus::Unverified + ); + let f = fixture(V2, 0x0103, false); + let mut bytes = std::fs::read(f.file.path()).expect("APK"); + let footer = bytes + .windows(16) + .position(|w| w == b"APK Sig Block 42") + .expect("footer"); + bytes[footer - 8..footer].copy_from_slice(&u64::MAX.to_le_bytes()); + std::fs::write(f.file.path(), bytes).expect("corrupt block"); + assert_eq!( + inspect(f.file.path(), false).status, + VerificationStatus::Invalid + ); +} diff --git a/src-tauri/src/features/devices/application.rs b/src-tauri/src/features/devices/application.rs new file mode 100644 index 00000000..b0729b09 --- /dev/null +++ b/src-tauri/src/features/devices/application.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use super::domain::{DeviceError, DeviceRepository, DeviceSummary}; + +/// Lists devices independently of Tauri and the concrete discovery transport. +#[derive(Clone)] +pub struct DeviceService { + repository: Arc, +} + +impl DeviceService { + pub fn new(repository: impl DeviceRepository + 'static) -> Self { + Self { + repository: Arc::new(repository), + } + } + + pub fn list(&self) -> Result, DeviceError> { + let mut devices = self.repository.list()?; + devices.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id))); + Ok(devices) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::features::devices::domain::DeviceErrorCode; + + struct FakeRepository(Result, DeviceError>); + + impl DeviceRepository for FakeRepository { + fn list(&self) -> Result, DeviceError> { + self.0.clone() + } + } + + fn device(id: &str) -> DeviceSummary { + DeviceSummary { + id: id.into(), + name: "Pixel".into(), + serial: None, + manufacturer: None, + product: None, + vendor_id: 0x18d1, + product_id: 0x4ee7, + warning: None, + } + } + + #[test] + fn preserves_same_model_devices_and_orders_them_by_connection() { + let service = + DeviceService::new(FakeRepository(Ok(vec![device("usb:2"), device("usb:1")]))); + assert_eq!(service.list(), Ok(vec![device("usb:1"), device("usb:2")])); + } + + #[test] + fn returns_an_empty_list_without_inventing_demo_devices() { + assert_eq!( + DeviceService::new(FakeRepository(Ok(vec![]))).list(), + Ok(vec![]) + ); + } + + #[test] + fn preserves_discovery_failures_instead_of_returning_an_empty_list() { + let error = DeviceError { + code: DeviceErrorCode::UsbUnavailable, + message: "Access denied".into(), + }; + assert_eq!( + DeviceService::new(FakeRepository(Err(error.clone()))).list(), + Err(error) + ); + } + + #[test] + fn serializes_the_frontend_contract_with_explicit_missing_metadata() { + assert_eq!( + serde_json::to_value(device("usb:1")).expect("serialize device"), + serde_json::json!({"id": "usb:1", "name": "Pixel", "serial": null, + "manufacturer": null, "product": null, "vendorId": 0x18d1, + "productId": 0x4ee7, "warning": null}) + ); + assert_eq!( + serde_json::to_value(DeviceError { + code: DeviceErrorCode::UsbUnavailable, + message: "USB failed".into() + }) + .expect("serialize error"), + serde_json::json!({"code": "usb_unavailable", "message": "USB failed"}) + ); + } +} diff --git a/src-tauri/src/features/devices/commands.rs b/src-tauri/src/features/devices/commands.rs new file mode 100644 index 00000000..94c7e037 --- /dev/null +++ b/src-tauri/src/features/devices/commands.rs @@ -0,0 +1,23 @@ +use tauri::State; + +use super::{ + application::DeviceService, + domain::{DeviceError, DeviceErrorCode, DeviceSummary}, +}; + +/// Runs blocking USB discovery on the worker pool, never on the UI thread. +#[tauri::command] +pub async fn list_devices( + service: State<'_, DeviceService>, +) -> Result, DeviceError> { + let service = service.inner().clone(); + crate::logging::observe("list_devices", async move { + tauri::async_runtime::spawn_blocking(move || service.list()) + .await + .map_err(|_| DeviceError { + code: DeviceErrorCode::Internal, + message: "Device discovery could not finish. Please retry.".into(), + })? + }) + .await +} diff --git a/src-tauri/src/features/devices/domain.rs b/src-tauri/src/features/devices/domain.rs new file mode 100644 index 00000000..61c21200 --- /dev/null +++ b/src-tauri/src/features/devices/domain.rs @@ -0,0 +1,40 @@ +use serde::Serialize; + +/// A currently connected USB device exposing an ADB interface. +/// Presence does not imply that Android has authorized an ADB session. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceSummary { + /// Connection identity, valid only for the current USB attachment. + pub id: String, + pub name: String, + pub serial: Option, + /// Optional strings reported by the USB device, not Android system properties. + pub manufacturer: Option, + pub product: Option, + /// USB descriptor identifiers remain available when string metadata cannot be read. + pub vendor_id: u16, + pub product_id: u16, + /// Non-fatal failure to read USB metadata; the device is still listed. + pub warning: Option, +} + +/// Structured discovery failure, serialized across the IPC boundary. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceError { + pub code: DeviceErrorCode, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeviceErrorCode { + UsbUnavailable, + Internal, +} + +/// Replaceable boundary for external device discovery. +pub trait DeviceRepository: Send + Sync { + fn list(&self) -> Result, DeviceError>; +} diff --git a/src-tauri/src/features/devices/infrastructure.rs b/src-tauri/src/features/devices/infrastructure.rs new file mode 100644 index 00000000..c559cbc4 --- /dev/null +++ b/src-tauri/src/features/devices/infrastructure.rs @@ -0,0 +1,124 @@ +use std::time::Duration; + +use rusb::{Context, UsbContext}; + +use super::domain::{DeviceError, DeviceErrorCode, DeviceRepository, DeviceSummary}; + +/// Discovers USB ADB interfaces without claiming them or starting an ADB session. +pub struct UsbDeviceRepository; + +fn usb_error(error: rusb::Error) -> DeviceError { + DeviceError { + code: DeviceErrorCode::UsbUnavailable, + message: format!("Unable to discover USB devices: {error}. Check USB access and retry."), + } +} + +fn is_adb_interface(class: u8, subclass: u8, protocol: u8) -> bool { + class == 0xff && subclass == 0x42 && protocol == 0x01 +} + +fn non_empty(value: String) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +impl DeviceRepository for UsbDeviceRepository { + fn list(&self) -> Result, DeviceError> { + let context = Context::new().map_err(usb_error)?; + let devices = context.devices().map_err(usb_error)?; + let mut result = Vec::new(); + for device in devices.iter() { + // A device can disappear between enumeration and descriptor reads. + let Ok(descriptor) = device.device_descriptor() else { + continue; + }; + let has_adb = (0..descriptor.num_configurations()).any(|index| { + device.config_descriptor(index).is_ok_and(|config| { + config.interfaces().any(|interface| { + interface.descriptors().any(|descriptor| { + is_adb_interface( + descriptor.class_code(), + descriptor.sub_class_code(), + descriptor.protocol_code(), + ) + }) + }) + }) + }); + if !has_adb { + continue; + } + + let mut summary = DeviceSummary { + id: format!( + "usb:{}:{}:{:04x}:{:04x}", + device.bus_number(), + device.address(), + descriptor.vendor_id(), + descriptor.product_id() + ), + name: "Android device".into(), + serial: None, + manufacturer: None, + product: None, + vendor_id: descriptor.vendor_id(), + product_id: descriptor.product_id(), + warning: None, + }; + let metadata = (|| -> Result<(), rusb::Error> { + let handle = device.open()?; + let timeout = Duration::from_millis(200); + let languages = handle.read_languages(timeout)?; + let language = languages.first().ok_or(rusb::Error::NotFound)?; + // USB strings are optional; preserve any metadata we can read. + let manufacturer = handle.read_manufacturer_string(*language, &descriptor, timeout); + let product = handle.read_product_string(*language, &descriptor, timeout); + let serial = handle.read_serial_number_string(*language, &descriptor, timeout); + if let Ok(value) = &product { + summary.product = non_empty(value.clone()); + summary.name = summary + .product + .clone() + .unwrap_or_else(|| "Android device".into()); + } + if let Ok(value) = &manufacturer { + summary.manufacturer = non_empty(value.clone()); + } + if let Ok(value) = &serial { + summary.serial = non_empty(value.clone()); + } + if product.is_err() || serial.is_err() || manufacturer.is_err() { + summary.warning = Some("Some USB metadata is unavailable. The device may require additional USB permissions.".into()); + } + Ok(()) + })(); + if let Err(error) = metadata { + summary.warning = Some(format!( + "USB metadata is unavailable: {error}. Check USB permissions." + )); + } + result.push(summary); + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_adb_but_not_mtp_fastboot_or_unrelated_usb_interfaces() { + assert!(is_adb_interface(0xff, 0x42, 0x01)); + assert!(!is_adb_interface(0x06, 0x01, 0x01)); + assert!(!is_adb_interface(0xff, 0x42, 0x03)); + assert!(!is_adb_interface(0xff, 0x00, 0x01)); + } + + #[test] + fn normalizes_missing_and_padded_metadata() { + assert_eq!(non_empty(" ".into()), None); + assert_eq!(non_empty(" Pixel 9 ".into()), Some("Pixel 9".into())); + } +} diff --git a/src-tauri/src/features/devices/mod.rs b/src-tauri/src/features/devices/mod.rs new file mode 100644 index 00000000..c4fd1fd8 --- /dev/null +++ b/src-tauri/src/features/devices/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod application; +pub(crate) mod commands; +pub(crate) mod domain; +pub(crate) mod infrastructure; diff --git a/src-tauri/src/features/keystore/application.rs b/src-tauri/src/features/keystore/application.rs new file mode 100644 index 00000000..ebda3176 --- /dev/null +++ b/src-tauri/src/features/keystore/application.rs @@ -0,0 +1,93 @@ +use super::domain::*; +use std::path::Path; + +pub fn validate(request: &GenerateRequest) -> Result<(), KeystoreError> { + let path = Path::new(&request.path); + if !path.is_absolute() + || path.file_name().is_none() + || path.extension().and_then(|s| s.to_str()) != Some(request.format.extension()) + { + return Err(KeystoreError::invalid( + "path", + "Choose an absolute file path with the selected format's extension.", + )); + } + for (field, password) in [ + ("password", request.password.as_str()), + ("keyPassword", request.effective_key_password()), + ] { + if !(6..=128).contains(&password.len()) + || !password.bytes().all(|b| (32..=126).contains(&b)) + { + return Err(KeystoreError::invalid( + field, + "Use 6–128 printable ASCII characters for Java/Android compatibility.", + )); + } + } + if request.format == Format::Pkcs12 && request.password != request.effective_key_password() { + return Err(KeystoreError::invalid( + "keyPassword", + "PKCS12 requires the same password for the keystore and key.", + )); + } + if request.alias.is_empty() + || request.alias.len() > 64 + || !request + .alias + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)) + { + return Err(KeystoreError::invalid( + "alias", + "Use 1–64 lowercase letters, numbers, dots, underscores or hyphens.", + )); + } + if !(1..=100).contains(&request.validity_years) { + return Err(KeystoreError::invalid( + "validityYears", + "Choose a validity between 1 and 100 years.", + )); + } + if request.common_name.trim().is_empty() { + return Err(KeystoreError::invalid( + "commonName", + "Enter a certificate name.", + )); + } + for (field, value) in [ + ("commonName", &request.common_name), + ("organization", &request.organization), + ("organizationalUnit", &request.organizational_unit), + ("locality", &request.locality), + ("state", &request.state), + ] { + if value.len() > 128 || value.chars().any(char::is_control) { + return Err(KeystoreError::invalid( + field, + "Use at most 128 UTF-8 bytes without control characters.", + )); + } + } + if !request.country.is_empty() + && (request.country.len() != 2 || !request.country.bytes().all(|b| b.is_ascii_uppercase())) + { + return Err(KeystoreError::invalid( + "country", + "Enter a two-letter uppercase country code.", + )); + } + Ok(()) +} + +pub fn generate( + request: &GenerateRequest, + encoder: &impl KeystoreEncoder, + files: &impl KeystoreFiles, +) -> Result { + validate(request)?; + files.ensure_available(&request.path)?; + let (bytes, result) = encoder.encode(request)?; + files.publish(&request.path, &bytes)?; + Ok(result) +} diff --git a/src-tauri/src/features/keystore/commands.rs b/src-tauri/src/features/keystore/commands.rs new file mode 100644 index 00000000..34b8bba7 --- /dev/null +++ b/src-tauri/src/features/keystore/commands.rs @@ -0,0 +1,65 @@ +use super::{ + application, + domain::*, + infrastructure::{NativeEncoder, NativeFiles}, +}; +use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; + +fn native_error() -> KeystoreError { + KeystoreError::new( + ErrorCode::NativeOperationFailed, + "The desktop operation could not finish. Please retry.", + ) +} + +/// Crypto and disk operations run outside the UI thread. +#[tauri::command] +pub async fn generate_keystore( + request: GenerateRequest, +) -> Result { + crate::logging::observe("generate_keystore", async move { + tauri::async_runtime::spawn_blocking(move || { + application::generate(&request, &NativeEncoder, &NativeFiles) + }) + .await + .map_err(|_| native_error())? + }) + .await +} + +#[tauri::command] +pub async fn choose_keystore_path( + app: tauri::AppHandle, + format: Format, +) -> Result, KeystoreError> { + tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .file() + .set_title("Save keystore") + .add_filter("Keystore", &[format.extension()]) + .set_file_name(format!("upload.{}", format.extension())) + .blocking_save_file() + .map(|path| { + path.into_path() + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|_| native_error()) + }) + .transpose() + }) + .await + .map_err(|_| native_error())? +} + +#[tauri::command] +pub fn copy_keystore_text(app: tauri::AppHandle, text: String) -> Result<(), KeystoreError> { + app.clipboard().write_text(text).map_err(|_| native_error()) +} + +#[tauri::command] +pub fn reveal_keystore(app: tauri::AppHandle, path: String) -> Result<(), KeystoreError> { + app.opener() + .reveal_item_in_dir(path) + .map_err(|_| native_error()) +} diff --git a/src-tauri/src/features/keystore/domain.rs b/src-tauri/src/features/keystore/domain.rs new file mode 100644 index 00000000..13a89b48 --- /dev/null +++ b/src-tauri/src/features/keystore/domain.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum Format { + #[serde(rename = "JKS")] + Jks, + #[serde(rename = "PKCS12")] + Pkcs12, +} + +impl Format { + pub fn extension(self) -> &'static str { + match self { + Self::Jks => "jks", + Self::Pkcs12 => "p12", + } + } +} + +/// Only creation of a new store containing one signing key is supported. +/// Passwords are deliberately excluded from Debug and erased on drop. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerateRequest { + pub path: String, + pub format: Format, + pub password: String, + pub key_password: String, + pub alias: String, + pub validity_years: u32, + pub common_name: String, + pub organization: String, + pub organizational_unit: String, + pub locality: String, + pub state: String, + pub country: String, +} + +impl GenerateRequest { + /// An empty key password reuses the store password; whitespace is a literal password. + pub fn effective_key_password(&self) -> &str { + if self.key_password.is_empty() { + &self.password + } else { + &self.key_password + } + } +} + +impl Drop for GenerateRequest { + fn drop(&mut self) { + self.password.zeroize(); + self.key_password.zeroize(); + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedKeystore { + pub path: String, + pub format: Format, + pub alias: String, + pub expires_at: String, + pub sha1: String, + pub sha256: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + InvalidInput, + AlreadyExists, + WriteFailed, + GenerationFailed, + NativeOperationFailed, +} + +#[derive(Debug, Serialize)] +pub struct KeystoreError { + pub code: ErrorCode, + pub message: String, + pub field: Option, +} + +impl KeystoreError { + pub fn new(code: ErrorCode, message: &str) -> Self { + Self { + code, + message: message.into(), + field: None, + } + } + pub fn invalid(field: &str, message: &str) -> Self { + Self { + code: ErrorCode::InvalidInput, + message: message.into(), + field: Some(field.into()), + } + } +} + +/// Cryptography is replaceable in application tests; no Tauri runtime is needed. +pub trait KeystoreEncoder { + fn encode( + &self, + request: &GenerateRequest, + ) -> Result<(Vec, GeneratedKeystore), KeystoreError>; +} + +/// Publishing must be atomic and must never replace an existing file. +pub trait KeystoreFiles { + fn ensure_available(&self, path: &str) -> Result<(), KeystoreError>; + fn publish(&self, path: &str, bytes: &[u8]) -> Result<(), KeystoreError>; +} diff --git a/src-tauri/src/features/keystore/explorer.rs b/src-tauri/src/features/keystore/explorer.rs new file mode 100644 index 00000000..5428d82d --- /dev/null +++ b/src-tauri/src/features/keystore/explorer.rs @@ -0,0 +1,86 @@ +//! Read-only exploration contracts and input validation, independent of the desktop runtime. +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExploreRequest { + pub path: String, + pub password: String, + pub key_alias: Option, + pub key_password: String, +} + +impl Drop for ExploreRequest { + fn drop(&mut self) { + self.password.zeroize(); + self.key_password.zeroize(); + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CertificateInfo { + pub subject: String, + pub issuer: String, + pub serial: String, + pub valid_from: String, + pub valid_until: String, + pub sha1: String, + pub sha256: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExploredEntry { + pub alias: Option, + pub kind: &'static str, + pub key_status: &'static str, + pub certificates: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeystoreReport { + pub format: &'static str, + pub entries: Vec, + pub limitation: Option<&'static str>, +} + +#[derive(Debug, Serialize)] +pub struct ExploreError { + pub code: &'static str, + pub message: &'static str, +} + +impl ExploreError { + pub fn new(code: &'static str, message: &'static str) -> Self { + Self { code, message } + } +} + +/// Reads a snapshot and verifies store protection without modifying the source file. +pub trait KeystoreInspector { + fn inspect(&self, request: &ExploreRequest) -> Result; +} + +pub fn explore( + request: &ExploreRequest, + inspector: &impl KeystoreInspector, +) -> Result { + if request.path.trim().is_empty() + || request.path.len() > 4096 + || request.password.len() > 1024 + || request.key_password.len() > 1024 + || request + .key_alias + .as_ref() + .is_some_and(|a| a.is_empty() || a.len() > 1024) + { + return Err(ExploreError::new( + "invalid_input", + "Enter a keystore path and credentials of at most 1024 bytes.", + )); + } + inspector.inspect(request) +} diff --git a/src-tauri/src/features/keystore/explorer_commands.rs b/src-tauri/src/features/keystore/explorer_commands.rs new file mode 100644 index 00000000..1ff5c753 --- /dev/null +++ b/src-tauri/src/features/keystore/explorer_commands.rs @@ -0,0 +1,17 @@ +use super::{ + explorer::{self, ExploreError, ExploreRequest, KeystoreReport}, + explorer_native::NativeInspector, +}; + +/// Explores a keystore on a blocking worker; passwords and private keys are never returned. +#[tauri::command] +pub async fn explore_keystore(request: ExploreRequest) -> Result { + tauri::async_runtime::spawn_blocking(move || explorer::explore(&request, &NativeInspector)) + .await + .map_err(|_| { + ExploreError::new( + "native_failed", + "The keystore operation could not finish. Please retry.", + ) + })? +} diff --git a/src-tauri/src/features/keystore/explorer_jks.rs b/src-tauri/src/features/keystore/explorer_jks.rs new file mode 100644 index 00000000..0b476dd3 --- /dev/null +++ b/src-tauri/src/features/keystore/explorer_jks.rs @@ -0,0 +1,99 @@ +//! Validate lengths before the JKS dependency allocates buffers from untrusted counts. +use super::explorer::ExploreError; + +fn invalid() -> ExploreError { + ExploreError::new( + "invalid_keystore", + "The JKS structure is damaged or exceeds supported limits (4096 entries, 64 certificates per chain).", + ) +} + +fn take<'a>(input: &mut &'a [u8], size: usize) -> Result<&'a [u8], ExploreError> { + if size > input.len() { + return Err(invalid()); + } + let (value, rest) = input.split_at(size); + *input = rest; + Ok(value) +} + +fn number(input: &mut &[u8], size: usize) -> Result { + Ok(take(input, size)? + .iter() + .fold(0, |value, byte| (value << 8) | usize::from(*byte))) +} + +fn blob(input: &mut &[u8], size: usize) -> Result<(), ExploreError> { + let length = number(input, size)?; + take(input, length)?; + Ok(()) +} + +fn certificate(input: &mut &[u8], version: usize) -> Result<(), ExploreError> { + if version == 2 { + blob(input, 2)?; + } + blob(input, 4) +} + +pub fn validate(mut input: &[u8]) -> Result { + take(&mut input, 4)?; + let version = number(&mut input, 4)?; + if !matches!(version, 1 | 2) { + return Err(invalid()); + } + let count = number(&mut input, 4)?; + if count > 4096 { + return Err(invalid()); + } + for _ in 0..count { + let tag = number(&mut input, 4)?; + blob(&mut input, 2)?; + take(&mut input, 8)?; + match tag { + 1 => { + blob(&mut input, 4)?; + let certificates = number(&mut input, 4)?; + if certificates > 64 { + return Err(invalid()); + } + for _ in 0..certificates { + certificate(&mut input, version)?; + } + } + 2 => certificate(&mut input, version)?, + _ => return Err(invalid()), + } + } + // The remaining bytes must be exactly the password-protected SHA-1 digest. + if input.len() != 20 { + return Err(invalid()); + } + Ok(count) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_oversized_lengths_counts_unknown_versions_and_trailing_data() { + let mut header = vec![0xfe, 0xed, 0xfe, 0xed, 0, 0, 0, 2]; + header.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(validate(&header).is_err()); + header[8..12].copy_from_slice(&1_u32.to_be_bytes()); + header.extend_from_slice(&[0, 0, 0, 1, 0, 0]); + header.extend_from_slice(&[0; 8]); + header.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(validate(&header).is_err()); + header.truncate(12); + header[8..12].copy_from_slice(&0_u32.to_be_bytes()); + header.extend_from_slice(&[0; 20]); + assert!(validate(&header).is_ok()); + header[7] = 3; + assert!(validate(&header).is_err()); + header[7] = 2; + header.push(0); + assert!(validate(&header).is_err()); + } +} diff --git a/src-tauri/src/features/keystore/explorer_native.rs b/src-tauri/src/features/keystore/explorer_native.rs new file mode 100644 index 00000000..0d30c144 --- /dev/null +++ b/src-tauri/src/features/keystore/explorer_native.rs @@ -0,0 +1,249 @@ +use super::explorer::*; +use openssl::{ + hash::MessageDigest, + pkcs12::Pkcs12, + pkey::PKey, + x509::{X509, X509NameRef}, +}; +use std::{fs::File, io::Read}; +use zeroize::Zeroizing; + +pub struct NativeInspector; + +fn invalid() -> ExploreError { + ExploreError::new( + "invalid_keystore", + "The keystore could not be decoded. It may be damaged or use an unsupported format or encryption algorithm.", + ) +} +fn unlock() -> ExploreError { + ExploreError::new( + "unlock_failed", + "Could not verify the keystore password and integrity. The password may be incorrect, the file may be damaged, or its encryption may be unsupported.", + ) +} +fn read_error() -> ExploreError { + ExploreError::new( + "read_failed", + "Could not read the keystore. Choose a readable regular file up to 16 MiB.", + ) +} + +fn name(value: &X509NameRef) -> Result { + value + .entries() + .map(|e| { + Ok(format!( + "{}={}", + e.object().nid().short_name().unwrap_or("OID"), + e.data().to_string().map_err(|_| invalid())? + )) + }) + .collect::, ExploreError>>() + .map(|v| v.join(", ")) +} +fn certificate(cert: &X509) -> Result { + let fingerprint = |digest| -> Result { + Ok(cert + .digest(digest) + .map_err(|_| invalid())? + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":")) + }; + Ok(CertificateInfo { + subject: name(cert.subject_name())?, + issuer: name(cert.issuer_name())?, + serial: cert + .serial_number() + .to_bn() + .and_then(|n| n.to_hex_str()) + .map_err(|_| invalid())? + .to_string(), + valid_from: cert.not_before().to_string(), + valid_until: cert.not_after().to_string(), + sha1: fingerprint(MessageDigest::sha1())?, + sha256: fingerprint(MessageDigest::sha256())?, + }) +} + +impl KeystoreInspector for NativeInspector { + fn inspect(&self, request: &ExploreRequest) -> Result { + let file = File::open(&request.path).map_err(|_| read_error())?; + let metadata = file.metadata().map_err(|_| read_error())?; + if !metadata.is_file() || metadata.len() > 16 * 1024 * 1024 { + return Err(read_error()); + } + let mut bytes = Zeroizing::new(Vec::new()); + file.take(16 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| read_error())?; + if bytes.len() > 16 * 1024 * 1024 { + return Err(read_error()); + } + if bytes.starts_with(&[0xfe, 0xed, 0xfe, 0xed]) { + inspect_jks(&bytes, request) + } else { + inspect_pkcs12(&bytes, request) + } + } +} + +fn inspect_jks(bytes: &[u8], request: &ExploreRequest) -> Result { + let expected_entries = super::explorer_jks::validate(bytes)?; + let mut store = jks::KeyStore::new(); + let mut input = bytes; + store + .load(&mut input, request.password.as_bytes()) + .map_err(|e| match e { + jks::KeyStoreError::InvalidDigest => unlock(), + _ => invalid(), + })?; + if !input.is_empty() || store.len() != expected_entries { + return Err(invalid()); + } + let mut aliases = store.aliases(); + aliases.sort(); + if request + .key_alias + .as_ref() + .is_some_and(|a| !store.is_private_key_entry(a)) + { + return Err(ExploreError::new( + "invalid_alias", + "The selected alias is not a private key entry.", + )); + } + let mut entries = Vec::new(); + for alias in aliases { + let private = store.is_private_key_entry(&alias); + let chain = if private { + store + .get_private_key_entry_certificate_chain(&alias) + .map_err(|_| invalid())? + } else { + vec![ + store + .get_trusted_certificate_entry(&alias) + .map_err(|_| invalid())? + .certificate, + ] + }; + let certs = chain + .iter() + .map(|c| X509::from_der(&c.content).map_err(|_| invalid())) + .collect::, _>>()?; + let mut key_status = if private { + "not_checked" + } else { + "not_applicable" + }; + if request + .key_alias + .as_deref() + .is_some_and(|a| a.eq_ignore_ascii_case(&alias)) + { + let password = if request.key_password.is_empty() { + &request.password + } else { + &request.key_password + }; + key_status = match store.get_private_key_entry(&alias, password.as_bytes()) { + Ok(mut entry) => { + let der = Zeroizing::new(std::mem::take(&mut entry.private_key)); + match ( + PKey::private_key_from_pkcs8(&der), + certs.first().and_then(|c| c.public_key().ok()), + ) { + (Ok(key), Some(public)) if key.public_eq(&public) => "verified", + _ => "failed", + } + } + Err(_) => "failed", + }; + } + entries.push(ExploredEntry { + alias: Some(alias), + kind: if private { + "private_key" + } else { + "trusted_certificate" + }, + key_status, + certificates: certs.iter().map(certificate).collect::>()?, + }); + } + Ok(KeystoreReport { + format: "jks", + entries, + limitation: None, + }) +} + +fn inspect_pkcs12(bytes: &[u8], request: &ExploreRequest) -> Result { + let parsed = Pkcs12::from_der(bytes) + .map_err(|_| invalid())? + .parse2(&request.password) + .map_err(|_| unlock())?; + let mut entries = Vec::new(); + if let Some(cert) = parsed.cert { + let alias = cert + .alias() + .map(|a| String::from_utf8_lossy(a).into_owned()); + if request + .key_alias + .as_ref() + .is_some_and(|a| Some(a) != alias.as_ref()) + { + return Err(ExploreError::new( + "invalid_alias", + "The selected alias does not match the PKCS12 signing identity.", + )); + } + let private = parsed.pkey.is_some(); + let status = if let Some(key) = parsed.pkey { + let public = cert.public_key().map_err(|_| invalid())?; + if (!request.key_password.is_empty() && request.key_password != request.password) + || !key.public_eq(&public) + { + "failed" + } else { + "verified" + } + } else { + "not_applicable" + }; + entries.push(ExploredEntry { + alias, + kind: if private { + "private_key" + } else { + "trusted_certificate" + }, + key_status: status, + certificates: vec![certificate(&cert)?], + }); + } else if parsed.pkey.is_some() { + return Err(invalid()); + } + if let Some(ca) = parsed.ca { + for cert in ca { + entries.push(ExploredEntry { + alias: cert + .alias() + .map(|a| String::from_utf8_lossy(a).into_owned()), + kind: "certificate", + key_status: "not_applicable", + certificates: vec![certificate(&cert)?], + }); + } + } + Ok(KeystoreReport { + format: "pkcs12", + entries, + limitation: Some( + "PKCS12 shows the first signing identity and additional certificates exposed by OpenSSL. Multi-key inventory is not supported. Key and store passwords must match. OpenSSL checks password protection and the integrity MAC when present; MAC-less stores cannot establish store integrity. A successful read is not a complete audit of all bags.", + ), + }) +} diff --git a/src-tauri/src/features/keystore/explorer_tests.rs b/src-tauri/src/features/keystore/explorer_tests.rs new file mode 100644 index 00000000..a4af74e5 --- /dev/null +++ b/src-tauri/src/features/keystore/explorer_tests.rs @@ -0,0 +1,189 @@ +#![allow(clippy::unwrap_used)] +use super::{ + domain::*, explorer::*, explorer_native::NativeInspector, infrastructure::NativeEncoder, +}; +use std::{fs, time::SystemTime}; + +fn generated(format: Format) -> Vec { + NativeEncoder + .encode(&GenerateRequest { + path: "/unused".into(), + format, + password: "store-password".into(), + key_password: if matches!(format, Format::Jks) { + "key-password" + } else { + "store-password" + } + .into(), + alias: "upload".into(), + validity_years: 30, + common_name: "Signing Team".into(), + organization: "Example".into(), + organizational_unit: String::new(), + locality: String::new(), + state: String::new(), + country: "FR".into(), + }) + .unwrap() + .0 +} +fn request(path: &std::path::Path) -> ExploreRequest { + ExploreRequest { + path: path.to_string_lossy().into_owned(), + password: "store-password".into(), + key_alias: None, + key_password: String::new(), + } +} + +#[test] +fn explores_multiple_jks_entries_and_checks_separate_key_password_without_modifying_file() { + let mut store = jks::KeyStore::new(); + store + .load(generated(Format::Jks).as_slice(), b"store-password") + .unwrap(); + let entry = store + .get_private_key_entry("upload", b"key-password") + .unwrap(); + store + .set_trusted_certificate_entry( + "trusted", + jks::TrustedCertificateEntry { + creation_time: SystemTime::now(), + certificate: entry.certificate_chain[0].clone(), + }, + ) + .unwrap(); + store + .set_private_key_entry("second", entry, b"other-password") + .unwrap(); + let mut bytes = Vec::new(); + store.store(&mut bytes, b"store-password").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.jks"); + fs::write(&path, &bytes).unwrap(); + let mut input = request(&path); + let report = explore(&input, &NativeInspector).unwrap(); + assert_eq!(report.entries.len(), 3); + assert_eq!(report.entries[0].alias.as_deref(), Some("second")); + assert_eq!(report.entries[1].kind, "trusted_certificate"); + assert_eq!(report.entries[2].key_status, "not_checked"); + assert!( + report.entries[2].certificates[0] + .subject + .contains("Signing Team") + ); + input.key_alias = Some("upload".into()); + input.key_password = "wrong".into(); + assert_eq!( + explore(&input, &NativeInspector).unwrap().entries[2].key_status, + "failed" + ); + input.key_password = "key-password".into(); + let report = explore(&input, &NativeInspector).unwrap(); + assert_eq!(report.entries[2].key_status, "verified"); + assert_eq!(report.entries[0].key_status, "not_checked"); + let wire = serde_json::to_value(&report).unwrap(); + assert_eq!(wire["entries"][2]["keyStatus"], "verified"); + assert!(wire["entries"][2]["certificates"][0]["validUntil"].is_string()); + assert!(!wire.to_string().contains("password")); + input.key_alias = Some("missing".into()); + assert_eq!( + explore(&input, &NativeInspector).unwrap_err().code, + "invalid_alias" + ); + input.password = "wrong".into(); + assert_eq!( + explore(&input, &NativeInspector).unwrap_err().code, + "unlock_failed" + ); + assert_eq!(fs::read(path).unwrap(), bytes); +} + +#[test] +fn verifies_pkcs12_identity_and_rejects_wrong_passwords_and_damaged_stores() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.p12"); + let bytes = generated(Format::Pkcs12); + fs::write(&path, &bytes).unwrap(); + let mut input = request(&path); + let report = explore(&input, &NativeInspector).unwrap(); + assert_eq!(report.format, "pkcs12"); + assert!(report.limitation.is_some()); + assert_eq!(report.entries[0].alias.as_deref(), Some("upload")); + assert_eq!(report.entries[0].key_status, "verified"); + assert_eq!(report.entries[0].certificates[0].sha256.len(), 95); + input.password = "wrong".into(); + assert_eq!( + explore(&input, &NativeInspector).unwrap_err().code, + "unlock_failed" + ); + fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); + assert_eq!( + explore(&input, &NativeInspector).unwrap_err().code, + "invalid_keystore" + ); +} + +#[test] +fn supports_empty_jks_and_empty_password_and_rejects_corruption_and_unreadable_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty.jks"); + let mut bytes = Vec::new(); + jks::KeyStore::with_options(jks::KeyStoreOptions { + min_password_len: 0, + ..Default::default() + }) + .store(&mut bytes, b"") + .unwrap(); + fs::write(&path, &bytes).unwrap(); + let mut input = request(&path); + input.password.clear(); + assert!( + explore(&input, &NativeInspector) + .unwrap() + .entries + .is_empty() + ); + *bytes.last_mut().unwrap() ^= 1; + fs::write(&path, &bytes).unwrap(); + assert_eq!( + explore(&input, &NativeInspector).unwrap_err().code, + "unlock_failed" + ); + fs::write(&path, &bytes[..10]).unwrap(); + assert_eq!( + explore(&input, &NativeInspector).unwrap_err().code, + "invalid_keystore" + ); + fs::remove_file(&path).unwrap(); + assert_eq!( + explore(&input, &NativeInspector).unwrap_err().code, + "read_failed" + ); + assert_eq!( + explore(&request(dir.path()), &NativeInspector) + .unwrap_err() + .code, + "read_failed" + ); +} + +#[test] +fn validates_input_before_calling_an_injected_inspector() { + struct Fake; + impl KeystoreInspector for Fake { + fn inspect(&self, _: &ExploreRequest) -> Result { + Ok(KeystoreReport { + format: "jks", + entries: vec![], + limitation: None, + }) + } + } + let mut input = request(std::path::Path::new("fake.jks")); + assert!(explore(&input, &Fake).is_ok()); + input.path.clear(); + assert_eq!(explore(&input, &Fake).unwrap_err().code, "invalid_input"); +} diff --git a/src-tauri/src/features/keystore/infrastructure.rs b/src-tauri/src/features/keystore/infrastructure.rs new file mode 100644 index 00000000..6d816329 --- /dev/null +++ b/src-tauri/src/features/keystore/infrastructure.rs @@ -0,0 +1,156 @@ +use super::domain::*; +use chrono::{Months, Utc}; +use openssl::{ + asn1::Asn1Time, + bn::{BigNum, MsbOption}, + hash::MessageDigest, + pkcs12::Pkcs12, + pkey::PKey, + rsa::Rsa, + x509::{X509, X509NameBuilder}, +}; +use std::{fs, io::Write, path::Path, time::SystemTime}; + +pub struct NativeEncoder; + +impl KeystoreEncoder for NativeEncoder { + fn encode( + &self, + request: &GenerateRequest, + ) -> Result<(Vec, GeneratedKeystore), KeystoreError> { + encode(request).map_err(|_| { + KeystoreError::new( + ErrorCode::GenerationFailed, + "Could not generate the signing key and certificate. Please retry.", + ) + }) + } +} + +fn fingerprint(cert: &X509, digest: MessageDigest) -> Result { + Ok(cert + .digest(digest)? + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join(":")) +} + +fn encode( + request: &GenerateRequest, +) -> Result<(Vec, GeneratedKeystore), Box> { + let key = PKey::from_rsa(Rsa::generate(2048)?)?; + let mut name = X509NameBuilder::new()?; + for (attribute, value) in [ + ("CN", &request.common_name), + ("OU", &request.organizational_unit), + ("O", &request.organization), + ("L", &request.locality), + ("ST", &request.state), + ("C", &request.country), + ] { + if !value.trim().is_empty() { + name.append_entry_by_text(attribute, value.trim())?; + } + } + let name = name.build(); + let now = Utc::now(); + // Calendar years, clamping February 29 to the last day of February. + let expiry = now + .checked_add_months(Months::new(request.validity_years * 12)) + .ok_or("Invalid expiry")?; + let mut serial = BigNum::new()?; + serial.rand(128, MsbOption::ONE, false)?; + let serial = serial.to_asn1_integer()?; + let mut builder = X509::builder()?; + builder.set_version(2)?; + builder.set_serial_number(&serial)?; + builder.set_subject_name(&name)?; + builder.set_issuer_name(&name)?; + builder.set_pubkey(&key)?; + let not_before = Asn1Time::from_unix(now.timestamp())?; + let not_after = Asn1Time::from_unix(expiry.timestamp())?; + builder.set_not_before(¬_before)?; + builder.set_not_after(¬_after)?; + builder.sign(&key, MessageDigest::sha256())?; + let cert = builder.build(); + let bytes = match request.format { + Format::Jks => { + let mut store = jks::KeyStore::new(); + store.set_private_key_entry( + &request.alias, + jks::PrivateKeyEntry { + creation_time: SystemTime::now(), + private_key: key.private_key_to_pkcs8()?, + certificate_chain: vec![jks::Certificate { + cert_type: "X.509".into(), + content: cert.to_der()?, + }], + }, + request.effective_key_password().as_bytes(), + )?; + let mut bytes = Vec::new(); + store.store(&mut bytes, request.password.as_bytes())?; + bytes + } + Format::Pkcs12 => Pkcs12::builder() + .name(&request.alias) + .pkey(&key) + .cert(&cert) + .build2(&request.password)? + .to_der()?, + }; + Ok(( + bytes, + GeneratedKeystore { + path: request.path.clone(), + format: request.format, + alias: request.alias.clone(), + expires_at: expiry.format("%Y-%m-%d").to_string(), + sha1: fingerprint(&cert, MessageDigest::sha1())?, + sha256: fingerprint(&cert, MessageDigest::sha256())?, + }, + )) +} + +pub struct NativeFiles; + +fn write_error(error: &std::io::Error) -> KeystoreError { + if error.kind() == std::io::ErrorKind::AlreadyExists { + KeystoreError::new( + ErrorCode::AlreadyExists, + "A file already exists at this location. Choose another name.", + ) + } else { + KeystoreError::new( + ErrorCode::WriteFailed, + "Could not save the keystore. Check that the folder exists and is writable.", + ) + } +} + +impl KeystoreFiles for NativeFiles { + fn ensure_available(&self, path: &str) -> Result<(), KeystoreError> { + match fs::symlink_metadata(path) { + Ok(_) => Err(write_error(&std::io::Error::from( + std::io::ErrorKind::AlreadyExists, + ))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(write_error(&error)), + } + } + + fn publish(&self, path: &str, bytes: &[u8]) -> Result<(), KeystoreError> { + let parent = Path::new(path) + .parent() + .ok_or_else(|| KeystoreError::invalid("path", "Choose a destination folder."))?; + // A sibling temporary file allows atomic no-clobber publication. tempfile + // creates owner-only files on Unix and cleans up failed writes on drop. + let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| write_error(&e))?; + file.write_all(bytes).map_err(|e| write_error(&e))?; + file.as_file().sync_all().map_err(|e| write_error(&e))?; + file.persist_noclobber(path) + .map_err(|e| write_error(&e.error))?; + Ok(()) + } +} diff --git a/src-tauri/src/features/keystore/mod.rs b/src-tauri/src/features/keystore/mod.rs new file mode 100644 index 00000000..dba8f3c8 --- /dev/null +++ b/src-tauri/src/features/keystore/mod.rs @@ -0,0 +1,17 @@ +mod application; +pub mod commands; +pub(crate) mod domain; +mod explorer; +pub mod explorer_commands; +mod explorer_jks; +mod explorer_native; +pub(crate) mod infrastructure; + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod password_tests; + +#[cfg(test)] +mod explorer_tests; diff --git a/src-tauri/src/features/keystore/password_tests.rs b/src-tauri/src/features/keystore/password_tests.rs new file mode 100644 index 00000000..a537d91f --- /dev/null +++ b/src-tauri/src/features/keystore/password_tests.rs @@ -0,0 +1,59 @@ +use super::{ + application::generate, + domain::{Format, GenerateRequest}, + infrastructure::{NativeEncoder, NativeFiles}, +}; +use openssl::pkcs12::Pkcs12; +use std::fs; + +#[test] +fn empty_key_password_reuses_store_password_in_both_formats() { + let directory = tempfile::tempdir().expect("tempdir"); + for format in [Format::Jks, Format::Pkcs12] { + let request = GenerateRequest { + path: directory + .path() + .join(format!("shared.{}", format.extension())) + .to_string_lossy() + .into_owned(), + format, + password: "store-password".into(), + key_password: String::new(), + alias: "upload".into(), + validity_years: 30, + common_name: "Signing".into(), + organization: String::new(), + organizational_unit: String::new(), + locality: String::new(), + state: String::new(), + country: String::new(), + }; + generate(&request, &NativeEncoder, &NativeFiles).expect("generate with fallback"); + let bytes = fs::read(&request.path).expect("read store"); + match format { + Format::Jks => { + let mut store = jks::KeyStore::new(); + store + .load(&mut bytes.as_slice(), request.password.as_bytes()) + .expect("open store"); + assert!(store.get_private_key_entry("upload", b"").is_err()); + assert!( + store + .get_private_key_entry("upload", request.password.as_bytes()) + .is_ok() + ); + } + Format::Pkcs12 => { + let store = Pkcs12::from_der(&bytes).expect("PKCS12"); + assert!(store.parse2("").is_err()); + assert!( + store + .parse2(&request.password) + .expect("decrypt with store password") + .pkey + .is_some() + ); + } + } + } +} diff --git a/src-tauri/src/features/keystore/tests.rs b/src-tauri/src/features/keystore/tests.rs new file mode 100644 index 00000000..cc4b08d6 --- /dev/null +++ b/src-tauri/src/features/keystore/tests.rs @@ -0,0 +1,305 @@ +use super::{ + application::generate, + domain::*, + infrastructure::{NativeEncoder, NativeFiles}, +}; +use openssl::{hash::MessageDigest, pkcs12::Pkcs12, pkey::PKey, x509::X509}; +use std::{cell::Cell, fs, path::Path}; + +fn request(path: &Path, format: Format) -> GenerateRequest { + GenerateRequest { + path: path.to_string_lossy().into_owned(), + format, + password: "test-store-password".into(), + key_password: "test-key-password".into(), + alias: "upload".into(), + validity_years: 30, + common_name: "Example, Signing + Team".into(), + organization: "Example Studio".into(), + organizational_unit: String::new(), + locality: "Paris".into(), + state: "Île-de-France".into(), + country: "FR".into(), + } +} + +struct FakeEncoder(Cell); +impl KeystoreEncoder for FakeEncoder { + fn encode(&self, r: &GenerateRequest) -> Result<(Vec, GeneratedKeystore), KeystoreError> { + self.0.set(self.0.get() + 1); + Ok(( + vec![1, 2, 3], + GeneratedKeystore { + path: r.path.clone(), + format: r.format, + alias: r.alias.clone(), + expires_at: "2056-10-03".into(), + sha1: "AA".into(), + sha256: "BB".into(), + }, + )) + } +} + +#[test] +fn rejects_invalid_input_before_crypto_or_filesystem_operations() { + let directory = tempfile::tempdir().expect("tempdir"); + let mut r = request(&directory.path().join("test.jks"), Format::Jks); + let encoder = FakeEncoder(Cell::new(0)); + r.password = "short".into(); + assert_eq!( + generate(&r, &encoder, &NativeFiles) + .expect_err("short password") + .field + .as_deref(), + Some("password") + ); + r.password = "long-enough".into(); + r.validity_years = 0; + assert_eq!( + generate(&r, &encoder, &NativeFiles) + .expect_err("invalid years") + .field + .as_deref(), + Some("validityYears") + ); + r.validity_years = 30; + r.country = "France".into(); + assert_eq!( + generate(&r, &encoder, &NativeFiles) + .expect_err("invalid country") + .field + .as_deref(), + Some("country") + ); + r.country = "FR".into(); + r.format = Format::Pkcs12; + r.path = directory + .path() + .join("test.p12") + .to_string_lossy() + .into_owned(); + assert_eq!( + generate(&r, &encoder, &NativeFiles) + .expect_err("different passwords") + .field + .as_deref(), + Some("keyPassword") + ); + assert_eq!(encoder.0.get(), 0); + assert_eq!(fs::read_dir(directory.path()).expect("list").count(), 0); +} + +#[test] +fn preserves_existing_files_and_rejects_a_destination_created_during_generation() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("test.jks"); + let r = request(&path, Format::Jks); + let encoder = FakeEncoder(Cell::new(0)); + fs::write(&path, b"existing").expect("write"); + assert!(matches!( + generate(&r, &encoder, &NativeFiles) + .expect_err("existing") + .code, + ErrorCode::AlreadyExists + )); + assert_eq!(encoder.0.get(), 0); + // Simulates a race after the application's availability check. + assert!(matches!( + NativeFiles + .publish(&r.path, b"replacement") + .expect_err("race") + .code, + ErrorCode::AlreadyExists + )); + assert_eq!(fs::read(path).expect("read"), b"existing"); + assert_eq!(fs::read_dir(directory.path()).expect("list").count(), 1); +} + +#[test] +fn reports_write_failures_and_cleans_up_temporary_files() { + let directory = tempfile::tempdir().expect("tempdir"); + let r = request(&directory.path().join("missing/test.jks"), Format::Jks); + let encoder = FakeEncoder(Cell::new(0)); + assert!(matches!( + generate(&r, &encoder, &NativeFiles) + .expect_err("missing folder") + .code, + ErrorCode::WriteFailed + )); + assert_eq!(fs::read_dir(directory.path()).expect("list").count(), 0); +} + +#[test] +fn crypto_failure_does_not_publish_a_file() { + struct BrokenEncoder; + impl KeystoreEncoder for BrokenEncoder { + fn encode( + &self, + _: &GenerateRequest, + ) -> Result<(Vec, GeneratedKeystore), KeystoreError> { + Err(KeystoreError::new( + ErrorCode::GenerationFailed, + "Simulated failure", + )) + } + } + let directory = tempfile::tempdir().expect("tempdir"); + let r = request(&directory.path().join("test.jks"), Format::Jks); + assert!(matches!( + generate(&r, &BrokenEncoder, &NativeFiles) + .expect_err("crypto failure") + .code, + ErrorCode::GenerationFailed + )); + assert_eq!(fs::read_dir(directory.path()).expect("list").count(), 0); +} + +#[test] +fn creates_reopenable_stores_with_matching_rsa_keys_certificates_and_fingerprints() { + let directory = tempfile::tempdir().expect("tempdir"); + for format in [Format::Jks, Format::Pkcs12] { + let path = directory + .path() + .join(format!("test.{}", format.extension())); + let mut r = request(&path, format); + if format == Format::Pkcs12 { + r.key_password = r.password.clone(); + } + let result = generate(&r, &NativeEncoder, &NativeFiles).expect("generate"); + let bytes = fs::read(&path).expect("read"); + let (key, cert) = match format { + Format::Jks => { + let mut store = jks::KeyStore::new(); + assert!(store.load(&mut bytes.as_slice(), b"incorrect").is_err()); + let mut store = jks::KeyStore::new(); + store + .load(&mut bytes.as_slice(), r.password.as_bytes()) + .expect("load JKS"); + assert!(store.get_private_key_entry("upload", b"incorrect").is_err()); + let entry = store + .get_private_key_entry("upload", r.key_password.as_bytes()) + .expect("decrypt key"); + ( + PKey::private_key_from_pkcs8(&entry.private_key).expect("key"), + X509::from_der(&entry.certificate_chain[0].content).expect("certificate"), + ) + } + Format::Pkcs12 => { + let store = Pkcs12::from_der(&bytes).expect("PKCS12"); + assert!(store.parse2("incorrect").is_err()); + let parsed = store.parse2(&r.password).expect("decrypt PKCS12"); + (parsed.pkey.expect("key"), parsed.cert.expect("cert")) + } + }; + assert_eq!(key.bits(), 2048); + assert!(cert.public_key().expect("public key").public_eq(&key)); + assert!(cert.verify(&key).expect("verify self signature")); + assert_eq!( + cert.signature_algorithm().object().nid(), + openssl::nid::Nid::SHA256WITHRSAENCRYPTION + ); + assert_eq!( + cert.subject_name() + .entries_by_nid(openssl::nid::Nid::STATEORPROVINCENAME) + .next() + .expect("state") + .data() + .to_string() + .expect("UTF-8"), + "Île-de-France" + ); + assert!( + (10957..=10958).contains( + &cert + .not_before() + .diff(cert.not_after()) + .expect("duration") + .days + ) + ); + for (actual, digest) in [ + (&result.sha1, MessageDigest::sha1()), + (&result.sha256, MessageDigest::sha256()), + ] { + assert_eq!( + *actual, + cert.digest(digest) + .expect("digest") + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":") + ); + } + let wire = serde_json::to_value(&result).expect("serialize"); + assert_eq!(wire["path"], r.path); + assert!(wire.get("expiresAt").is_some()); + assert!(!wire.to_string().contains("password")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).expect("metadata").permissions().mode() & 0o777, + 0o600 + ); + } + } +} + +/// Optional cross-tool verification; never required by the SDK/Java-free suite. +#[test] +#[ignore = "requires a local Java keytool executable"] +fn keytool_can_import_both_formats_including_the_private_key() { + let directory = tempfile::tempdir().expect("tempdir"); + for format in [Format::Jks, Format::Pkcs12] { + let source = directory + .path() + .join(format!("source.{}", format.extension())); + let destination = directory + .path() + .join(format!("imported-{}.p12", format.extension())); + let mut r = request(&source, format); + if format == Format::Pkcs12 { + r.key_password = r.password.clone(); + } + generate(&r, &NativeEncoder, &NativeFiles).expect("generate"); + let output = std::process::Command::new("keytool") + .args(["-importkeystore", "-noprompt", "-srckeystore"]) + .arg(&source) + .args([ + "-srcstoretype", + if format == Format::Jks { + "JKS" + } else { + "PKCS12" + }, + "-srcstorepass", + &r.password, + "-srcalias", + &r.alias, + "-srckeypass", + &r.key_password, + "-destkeystore", + ]) + .arg(&destination) + .args([ + "-deststoretype", + "PKCS12", + "-deststorepass", + "test-destination-password", + ]) + .output() + .expect("run keytool"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let imported = Pkcs12::from_der(&fs::read(destination).expect("imported file")) + .expect("PKCS12") + .parse2("test-destination-password") + .expect("decrypt imported"); + assert!(imported.pkey.is_some()); + } +} diff --git a/src-tauri/src/features/mod.rs b/src-tauri/src/features/mod.rs new file mode 100644 index 00000000..f60eaa34 --- /dev/null +++ b/src-tauri/src/features/mod.rs @@ -0,0 +1,6 @@ +pub mod adb; +pub mod apk; +pub(crate) mod devices; +pub(crate) mod keystore; +pub(crate) mod settings; +pub(crate) mod signing; diff --git a/src-tauri/src/features/settings/commands.rs b/src-tauri/src/features/settings/commands.rs new file mode 100644 index 00000000..96c975d5 --- /dev/null +++ b/src-tauri/src/features/settings/commands.rs @@ -0,0 +1,88 @@ +use super::{ + domain::{AppSettings, ErrorCode, ProjectLink, SettingsError}, + infrastructure, +}; +use tauri::Manager; +use tauri_plugin_opener::OpenerExt; + +/// Hands update discovery and installation to Sparkle's native UI. +/// Development binaries and other platforms never simulate a successful check. +#[tauri::command] +pub fn check_app_updates(app: tauri::AppHandle) -> Result<(), SettingsError> { + #[cfg(all(target_os = "macos", feature = "macos-updater"))] + { + use tauri_plugin_sparkle_updater::SparkleUpdaterExt; + if let Some(updater) = app.sparkle_updater() { + return updater.check_for_updates().map_err(|_| SettingsError { + code: ErrorCode::UpdateFailed, + message: "The update check could not be started. Please retry.", + }); + } + } + let _ = app; + Err(SettingsError { + code: ErrorCode::UpdaterUnavailable, + message: "Updates require an installed macOS release of Android Tools.", + }) +} + +/// Opens only the application's own log directory, never a webview-supplied path. +#[tauri::command] +pub async fn open_logs_folder(app: tauri::AppHandle) -> Result<(), SettingsError> { + crate::logging::observe("open_logs_folder", async move { + let directory = app + .path() + .app_log_dir() + .map_err(|_| SettingsError::storage())?; + tauri::async_runtime::spawn_blocking(move || { + std::fs::create_dir_all(&directory).map_err(|_| SettingsError::storage())?; + app.opener() + .open_path(directory.to_string_lossy(), None::<&str>) + .map_err(|_| SettingsError { + code: ErrorCode::OpenFailed, + message: "The logs folder could not be opened. Please retry.", + }) + }) + .await + .map_err(|_| SettingsError::storage())? + }) + .await +} + +#[tauri::command] +pub async fn load_settings(app: tauri::AppHandle) -> Result { + let directory = app + .path() + .app_config_dir() + .map_err(|_| SettingsError::storage())?; + tauri::async_runtime::spawn_blocking(move || infrastructure::load(&directory)) + .await + .map_err(|_| SettingsError::storage())? +} + +#[tauri::command] +pub async fn set_crash_reporting( + app: tauri::AppHandle, + enabled: bool, +) -> Result { + let directory = app + .path() + .app_config_dir() + .map_err(|_| SettingsError::storage())?; + tauri::async_runtime::spawn_blocking(move || { + app.state::() + .save(&directory, enabled) + }) + .await + .map_err(|_| SettingsError::storage())? +} + +#[tauri::command] +pub fn open_project_link(app: tauri::AppHandle, link: ProjectLink) -> Result<(), SettingsError> { + app.opener() + .open_url(link.url(), None::<&str>) + .map_err(|_| SettingsError { + code: ErrorCode::OpenFailed, + message: "GitHub could not be opened in your browser. Please retry.", + }) +} diff --git a/src-tauri/src/features/settings/domain.rs b/src-tauri/src/features/settings/domain.rs new file mode 100644 index 00000000..a870b1e2 --- /dev/null +++ b/src-tauri/src/features/settings/domain.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +/// Persisted application preferences. Reporting defaults to off. +#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppSettings { + pub crash_reporting_enabled: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + StorageFailed, + OpenFailed, + UpdaterUnavailable, + #[cfg(all(target_os = "macos", feature = "macos-updater"))] + UpdateFailed, +} + +#[derive(Debug, Serialize)] +pub struct SettingsError { + pub code: ErrorCode, + pub message: &'static str, +} + +impl SettingsError { + pub fn storage() -> Self { + Self { + code: ErrorCode::StorageFailed, + message: "Application settings could not be read or saved. Please retry.", + } + } +} + +/// Only these fixed project destinations may be opened through settings IPC. +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProjectLink { + Repository, + Issue, + Changelog, +} + +impl ProjectLink { + pub fn url(&self) -> &'static str { + match self { + Self::Repository => "https://github.com/ThomasBernard03/AndroidTools", + Self::Issue => "https://github.com/ThomasBernard03/AndroidTools/issues/new/choose", + Self::Changelog => "https://github.com/ThomasBernard03/AndroidTools/releases", + } + } +} diff --git a/src-tauri/src/features/settings/infrastructure.rs b/src-tauri/src/features/settings/infrastructure.rs new file mode 100644 index 00000000..2b9f3404 --- /dev/null +++ b/src-tauri/src/features/settings/infrastructure.rs @@ -0,0 +1,74 @@ +use super::domain::{AppSettings, SettingsError}; +use std::{io::Write, path::Path}; + +/// Missing settings are a first launch; unreadable or malformed files are errors. +pub fn load(directory: &Path) -> Result { + match std::fs::read(directory.join("settings.json")) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|_| SettingsError::storage()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AppSettings::default()), + Err(_) => Err(SettingsError::storage()), + } +} + +/// Atomically replace preferences so an interrupted write cannot truncate them. +pub fn set_crash_reporting(directory: &Path, enabled: bool) -> Result { + let mut settings = load(directory)?; + settings.crash_reporting_enabled = enabled; + std::fs::create_dir_all(directory).map_err(|_| SettingsError::storage())?; + let bytes = serde_json::to_vec_pretty(&settings).map_err(|_| SettingsError::storage())?; + let mut file = + tempfile::NamedTempFile::new_in(directory).map_err(|_| SettingsError::storage())?; + file.write_all(&bytes) + .map_err(|_| SettingsError::storage())?; + file.as_file() + .sync_all() + .map_err(|_| SettingsError::storage())?; + file.persist(directory.join("settings.json")) + .map_err(|_| SettingsError::storage())?; + Ok(settings) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_off_and_persists_both_choices() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("app"); + assert!(!load(&path).expect("default").crash_reporting_enabled); + for enabled in [true, false] { + set_crash_reporting(&path, enabled).expect("save preference"); + assert_eq!( + load(&path).expect("reload").crash_reporting_enabled, + enabled + ); + } + } + + #[test] + fn malformed_preferences_are_not_silently_overwritten() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("settings.json"); + std::fs::write(&path, b"broken").expect("fixture"); + assert!(load(directory.path()).is_err()); + assert!(set_crash_reporting(directory.path(), true).is_err()); + assert_eq!(std::fs::read(path).expect("original file"), b"broken"); + } + + #[test] + fn storage_errors_are_reported() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("file"); + std::fs::write(&path, b"not a directory").expect("fixture"); + assert!(set_crash_reporting(&path, true).is_err()); + } + + #[test] + fn settings_wire_contract_uses_camel_case() { + assert_eq!( + serde_json::to_value(AppSettings::default()).expect("serialize"), + serde_json::json!({"crashReportingEnabled": false}) + ); + } +} diff --git a/src-tauri/src/features/settings/mod.rs b/src-tauri/src/features/settings/mod.rs new file mode 100644 index 00000000..c9d61f05 --- /dev/null +++ b/src-tauri/src/features/settings/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod commands; +pub(crate) mod domain; +pub(crate) mod infrastructure; diff --git a/src-tauri/src/features/signing/application.rs b/src-tauri/src/features/signing/application.rs new file mode 100644 index 00000000..da479640 --- /dev/null +++ b/src-tauri/src/features/signing/application.rs @@ -0,0 +1,43 @@ +use super::domain::*; +use std::path::Path; + +/// Opens the save dialog only after signing succeeds. Cancellation drops the artifact. +pub fn sign_and_save( + request: &SignRequest, + signer: &impl ApkSigner, + choose_destination: impl FnOnce(&str) -> Result, SigningError>, +) -> Result, SigningError> { + let apk = Path::new(&request.apk_path); + if !apk.is_absolute() + || !Path::new(&request.keystore_path).is_absolute() + || !apk + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("apk")) + || request.alias.trim().is_empty() + || request.password.is_empty() + { + return Err(SigningError::new( + ErrorCode::InvalidInput, + "Enter absolute APK and keystore paths, an alias and the keystore password.", + )); + } + let stem = apk.file_stem().and_then(|s| s.to_str()).ok_or_else(|| { + SigningError::new(ErrorCode::InvalidInput, "The APK filename is not valid.") + })?; + let artifact = signer.sign(request)?; + let Some(destination) = choose_destination(&format!("{stem}-signed.apk"))? else { + return Ok(None); + }; + if !Path::new(&destination).is_absolute() + || !Path::new(&destination) + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("apk")) + { + return Err(SigningError::new( + ErrorCode::InvalidInput, + "Choose an absolute destination ending in .apk.", + )); + } + artifact.save(&destination)?; + Ok(Some(destination)) +} diff --git a/src-tauri/src/features/signing/commands.rs b/src-tauri/src/features/signing/commands.rs new file mode 100644 index 00000000..7589ef3a --- /dev/null +++ b/src-tauri/src/features/signing/commands.rs @@ -0,0 +1,59 @@ +use super::{application, domain::*, infrastructure::NativeSigner}; +use tauri_plugin_dialog::DialogExt; + +fn native_error() -> SigningError { + SigningError::new( + ErrorCode::NativeOperationFailed, + "The desktop operation could not finish. Please retry.", + ) +} + +/// Signs on a blocking worker, then asks where to publish the prepared APK. +#[tauri::command] +pub async fn sign_apk( + app: tauri::AppHandle, + request: SignRequest, +) -> Result, SigningError> { + crate::logging::observe("sign_apk", async move { + tauri::async_runtime::spawn_blocking(move || { + application::sign_and_save(&request, &NativeSigner, |name| { + app.dialog() + .file() + .set_title("Save signed APK") + .add_filter("Android package", &["apk"]) + .set_file_name(name) + .blocking_save_file() + .map(|p| { + p.into_path() + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|_| native_error()) + }) + .transpose() + }) + }) + .await + .map_err(|_| native_error())? + }) + .await +} + +#[tauri::command] +pub async fn choose_signing_keystore( + app: tauri::AppHandle, +) -> Result, SigningError> { + tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .file() + .set_title("Choose signing keystore") + .add_filter("Keystore", &["jks", "keystore", "p12", "pfx", "pkcs12"]) + .blocking_pick_file() + .map(|p| { + p.into_path() + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|_| native_error()) + }) + .transpose() + }) + .await + .map_err(|_| native_error())? +} diff --git a/src-tauri/src/features/signing/domain.rs b/src-tauri/src/features/signing/domain.rs new file mode 100644 index 00000000..b5b31b8b --- /dev/null +++ b/src-tauri/src/features/signing/domain.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +/// Credentials are never logged and Rust-owned password buffers are erased on drop. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignRequest { + pub apk_path: String, + pub keystore_path: String, + pub alias: String, + pub password: String, + pub key_password: String, +} + +impl Drop for SignRequest { + fn drop(&mut self) { + self.password.zeroize(); + self.key_password.zeroize(); + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + InvalidInput, + InvalidKeystore, + InvalidApk, + SigningFailed, + WriteFailed, + NativeOperationFailed, +} + +#[derive(Debug, Serialize)] +pub struct SigningError { + pub code: ErrorCode, + pub message: String, +} + +impl SigningError { + pub fn new(code: ErrorCode, message: &str) -> Self { + Self { + code, + message: message.into(), + } + } +} + +/// A prepared artifact owns its temporary resources until publication or cancellation. +pub trait SignedArtifact { + fn save(self, destination: &str) -> Result<(), SigningError>; +} + +pub trait ApkSigner { + type Artifact: SignedArtifact; + fn sign(&self, request: &SignRequest) -> Result; +} diff --git a/src-tauri/src/features/signing/infrastructure.rs b/src-tauri/src/features/signing/infrastructure.rs new file mode 100644 index 00000000..5495d40e --- /dev/null +++ b/src-tauri/src/features/signing/infrastructure.rs @@ -0,0 +1,225 @@ +use super::domain::*; +use apksig::{Algorithms, Apk}; +use openssl::{ + pkcs12::Pkcs12, + pkey::{PKey, Private}, + x509::X509, +}; +use rsa::pkcs8::DecodePrivateKey; +use std::{ + collections::HashSet, + fs::File, + io::{self, Read, Write}, + path::Path, +}; +use tempfile::NamedTempFile; +use zeroize::Zeroizing; +use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions}; + +pub struct NativeSigner; +pub struct NativeArtifact(NamedTempFile); + +fn apk_error() -> SigningError { + SigningError::new( + ErrorCode::InvalidApk, + "Could not read the APK. Use a valid APK up to 1 GiB (2 GiB unpacked).", + ) +} +fn key_error() -> SigningError { + SigningError::new( + ErrorCode::InvalidKeystore, + "Could not unlock the signing key. Check the JKS or PKCS12 file, alias and passwords. PKCS12 requires one RSA key and matching passwords.", + ) +} +fn signing_error() -> SigningError { + SigningError::new( + ErrorCode::SigningFailed, + "Could not sign and verify the APK with this RSA key.", + ) +} +fn write_error() -> SigningError { + SigningError::new( + ErrorCode::WriteFailed, + "Could not save the signed APK. Choose a writable folder and a filename that does not already exist.", + ) +} + +fn load_key(request: &SignRequest) -> Result<(PKey, X509), SigningError> { + let file = File::open(&request.keystore_path).map_err(|_| key_error())?; + if !file.metadata().map_err(|_| key_error())?.is_file() { + return Err(key_error()); + } + let mut bytes = Zeroizing::new(Vec::new()); + file.take(16 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| key_error())?; + if bytes.len() > 16 * 1024 * 1024 { + return Err(key_error()); + } + let key_password = if request.key_password.is_empty() { + &request.password + } else { + &request.key_password + }; + let (key, cert) = if bytes.starts_with(&[0xfe, 0xed, 0xfe, 0xed]) { + let mut store = jks::KeyStore::new(); + store + .load(bytes.as_slice(), request.password.as_bytes()) + .map_err(|_| key_error())?; + let mut entry = store + .get_private_key_entry(&request.alias, key_password.as_bytes()) + .map_err(|_| key_error())?; + let private_key = Zeroizing::new(std::mem::take(&mut entry.private_key)); + let cert = entry.certificate_chain.first().ok_or_else(key_error)?; + ( + PKey::private_key_from_pkcs8(&private_key).map_err(|_| key_error())?, + X509::from_der(&cert.content).map_err(|_| key_error())?, + ) + } else { + if key_password != &request.password { + return Err(key_error()); + } + let parsed = Pkcs12::from_der(&bytes) + .and_then(|p| p.parse2(&request.password)) + .map_err(|_| key_error())?; + let cert = parsed.cert.ok_or_else(key_error)?; + if cert.alias() != Some(request.alias.as_bytes()) { + return Err(key_error()); + } + (parsed.pkey.ok_or_else(key_error)?, cert) + }; + let public = cert.public_key().map_err(|_| key_error())?; + if key.rsa().is_err() || !key.public_eq(&public) { + return Err(key_error()); + } + Ok((key, cert)) +} + +fn old_signature(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + let Some(name) = upper.strip_prefix("META-INF/") else { + return false; + }; + !name.contains('/') + && (name == "MANIFEST.MF" + || name.starts_with("SIG-") + || [".SF", ".RSA", ".DSA", ".EC"] + .iter() + .any(|ext| name.ends_with(ext))) +} + +/// Rebuilds ZIP entries to discard previous signing blocks and v1 signatures. +/// Stored native libraries are 16 KiB aligned; other stored entries are 4-byte aligned. +fn prepare_apk(path: &str) -> Result { + let input = File::open(path).map_err(|_| apk_error())?; + let metadata = input.metadata().map_err(|_| apk_error())?; + if !metadata.is_file() || metadata.len() > 1024 * 1024 * 1024 { + return Err(apk_error()); + } + // Snapshot before parsing to avoid reading different revisions during signing. + let mut snapshot = NamedTempFile::new().map_err(|_| write_error())?; + let copied = io::copy(&mut input.take(1024 * 1024 * 1024 + 1), &mut snapshot) + .map_err(|_| apk_error())?; + if copied > 1024 * 1024 * 1024 { + return Err(apk_error()); + } + let mut archive = + ZipArchive::new(snapshot.reopen().map_err(|_| apk_error())?).map_err(|_| apk_error())?; + if archive.len() > 100_000 || archive.by_name("AndroidManifest.xml").is_err() { + return Err(apk_error()); + } + let mut output = NamedTempFile::new().map_err(|_| write_error())?; + let mut writer = ZipWriter::new(output.as_file_mut()); + let mut total = 0_u64; + let mut names = HashSet::new(); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|_| apk_error())?; + if !names.insert(entry.name().to_owned()) || entry.encrypted() { + return Err(apk_error()); + } + if old_signature(entry.name()) { + continue; + } + if !matches!( + entry.compression(), + CompressionMethod::Stored | CompressionMethod::Deflated + ) { + return Err(apk_error()); + } + let limit = 2 * 1024 * 1024 * 1024_u64 - total; + if entry.size() > limit { + return Err(apk_error()); + } + let alignment = if entry.compression() == CompressionMethod::Stored { + if entry.name().starts_with("lib/") && entry.name().ends_with(".so") { + 16384 + } else { + 4 + } + } else { + 1 + }; + let options = SimpleFileOptions::default() + .compression_method(entry.compression()) + .with_alignment(alignment); + writer + .start_file(entry.name(), options) + .map_err(|_| apk_error())?; + let expected = entry.size(); + let copied = + io::copy(&mut entry.by_ref().take(limit + 1), &mut writer).map_err(|_| apk_error())?; + if copied != expected || copied > limit { + return Err(apk_error()); + } + total += copied; + } + writer.finish().map_err(|_| apk_error())?; + Ok(output) +} + +impl ApkSigner for NativeSigner { + type Artifact = NativeArtifact; + fn sign(&self, request: &SignRequest) -> Result { + let (key, cert) = load_key(request)?; + let unsigned = prepare_apk(&request.apk_path)?; + let der = Zeroizing::new(key.private_key_to_pkcs8().map_err(|_| signing_error())?); + let private = rsa::RsaPrivateKey::from_pkcs8_der(&der).map_err(|_| key_error())?; + let mut apk = Apk::new_raw(unsigned.path().to_owned()).map_err(|_| apk_error())?; + let algorithm = Algorithms::RSASSA_PKCS1_v1_5_256; + let expected_digest = apk.digest(&algorithm).map_err(|_| signing_error())?; + apk.sign_v2( + &algorithm, + &cert.to_der().map_err(|_| key_error())?, + private, + ) + .map_err(|_| signing_error())?; + let mut signed = NamedTempFile::new().map_err(|_| write_error())?; + apk.write_with_signature(&mut signed) + .map_err(|_| signing_error())?; + signed.flush().map_err(|_| write_error())?; + let result = Apk::new(signed.path().to_owned()).map_err(|_| signing_error())?; + result.verify().map_err(|_| signing_error())?; + if result.digest(&algorithm).map_err(|_| signing_error())? != expected_digest { + return Err(signing_error()); + } + Ok(NativeArtifact(signed)) + } +} + +impl SignedArtifact for NativeArtifact { + fn save(self, destination: &str) -> Result<(), SigningError> { + let parent = Path::new(destination).parent().ok_or_else(write_error)?; + let mut output = NamedTempFile::new_in(parent).map_err(|_| write_error())?; + io::copy( + &mut self.0.reopen().map_err(|_| write_error())?, + &mut output, + ) + .map_err(|_| write_error())?; + output.as_file().sync_all().map_err(|_| write_error())?; + // Atomic no-clobber publication also protects the source APK and keystore. + output + .persist_noclobber(destination) + .map_err(|_| write_error())?; + Ok(()) + } +} diff --git a/src-tauri/src/features/signing/mod.rs b/src-tauri/src/features/signing/mod.rs new file mode 100644 index 00000000..6d5796cd --- /dev/null +++ b/src-tauri/src/features/signing/mod.rs @@ -0,0 +1,7 @@ +mod application; +pub mod commands; +mod domain; +mod infrastructure; + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/features/signing/tests.rs b/src-tauri/src/features/signing/tests.rs new file mode 100644 index 00000000..602f407d --- /dev/null +++ b/src-tauri/src/features/signing/tests.rs @@ -0,0 +1,276 @@ +use super::{application::sign_and_save, domain::*, infrastructure::NativeSigner}; +use crate::features::keystore::{ + domain::{Format, GenerateRequest, KeystoreEncoder}, + infrastructure::NativeEncoder, +}; +use std::{ + cell::RefCell, + fs, + io::{Read, Write}, + rc::Rc, +}; +use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions}; + +fn request() -> SignRequest { + SignRequest { + apk_path: "/input/my.app.APK".into(), + keystore_path: "/keys/upload.jks".into(), + alias: "upload".into(), + password: "password".into(), + key_password: String::new(), + } +} + +struct FakeSigner { + events: Rc>>, + fail: bool, +} +struct FakeArtifact(Rc>>); +impl ApkSigner for FakeSigner { + type Artifact = FakeArtifact; + fn sign(&self, _: &SignRequest) -> Result { + self.events.borrow_mut().push("sign".into()); + if self.fail { + return Err(SigningError::new(ErrorCode::SigningFailed, "Failed")); + } + Ok(FakeArtifact(self.events.clone())) + } +} +impl SignedArtifact for FakeArtifact { + fn save(self, path: &str) -> Result<(), SigningError> { + self.0.borrow_mut().push(path.into()); + Ok(()) + } +} +impl Drop for FakeArtifact { + fn drop(&mut self) { + self.0.borrow_mut().push("cleanup".into()); + } +} + +#[test] +fn signs_before_dialog_and_cleans_up_after_save_or_cancel() { + for cancel in [true, false] { + let events = Rc::new(RefCell::new(vec![])); + let signer = FakeSigner { + events: events.clone(), + fail: false, + }; + let result = sign_and_save(&request(), &signer, |name| { + assert_eq!(name, "my.app-signed.apk"); + events.borrow_mut().push("dialog".into()); + Ok(if cancel { + None + } else { + Some("/output/my.app-signed.apk".into()) + }) + }) + .expect("workflow"); + assert_eq!(result.is_none(), cancel); + let expected = if cancel { + vec!["sign", "dialog", "cleanup"] + } else { + vec!["sign", "dialog", "/output/my.app-signed.apk", "cleanup"] + }; + assert_eq!(*events.borrow(), expected); + } +} + +#[test] +fn invalid_input_and_signing_failures_never_open_the_dialog() { + let events = Rc::new(RefCell::new(vec![])); + let signer = FakeSigner { + events: events.clone(), + fail: true, + }; + assert!(sign_and_save(&request(), &signer, |_| panic!("No dialog on failure")).is_err()); + events.borrow_mut().clear(); + let mut input = request(); + input.password.clear(); + assert!(sign_and_save(&input, &signer, |_| panic!("No dialog on invalid input")).is_err()); + assert!(events.borrow().is_empty()); +} + +fn fixture(format: Format, directory: &std::path::Path) -> SignRequest { + let key = GenerateRequest { + path: directory + .join("upload.keystore") + .to_string_lossy() + .into_owned(), + format, + password: "store-password".into(), + key_password: if format == Format::Jks { + "key-password".into() + } else { + String::new() + }, + alias: "upload".into(), + validity_years: 30, + common_name: "APK Signing Test".into(), + organization: String::new(), + organizational_unit: String::new(), + locality: String::new(), + state: String::new(), + country: String::new(), + }; + let (bytes, _) = NativeEncoder.encode(&key).expect("encode keystore"); + fs::write(&key.path, bytes).expect("save keystore"); + let apk_path = directory.join("example.apk"); + let mut zip = ZipWriter::new(fs::File::create(&apk_path).expect("create APK")); + for (name, content) in [ + ("AndroidManifest.xml", b"manifest".as_slice()), + ("classes.dex", b"dex content"), + ("lib/arm64-v8a/libtest.so", b"native library"), + ("META-INF/OLD.SF", b"old signature"), + ("META-INF/services/example", b"keep me"), + ] { + zip.start_file( + name, + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), + ) + .expect("entry"); + zip.write_all(content).expect("content"); + } + zip.finish().expect("finish ZIP"); + SignRequest { + apk_path: apk_path.to_string_lossy().into_owned(), + keystore_path: key.path.clone(), + alias: key.alias.clone(), + password: key.password.clone(), + key_password: key.key_password.clone(), + } +} + +#[test] +fn signs_both_keystore_formats_verifies_content_and_preserves_sources() { + for format in [Format::Jks, Format::Pkcs12] { + let dir = tempfile::tempdir().expect("directory"); + let input = fixture(format, dir.path()); + let original = fs::read(&input.apk_path).expect("source"); + let destination = dir.path().join("example-signed.apk"); + let path = destination.to_string_lossy().into_owned(); + sign_and_save(&input, &NativeSigner, |name| { + assert_eq!(name, "example-signed.apk"); + Ok(Some(path.clone())) + }) + .expect("sign and save"); + assert_eq!( + fs::read(&input.apk_path).expect("source unchanged"), + original + ); + let apk = apksig::Apk::new(destination.clone()).expect("signed APK"); + apk.verify().expect("signature verification"); + let block = apk.get_signing_block().expect("signing block"); + let signer = block + .content + .iter() + .find_map(|b| match b { + apksig::ValueSigningBlock::SignatureSchemeV2Block(v) => { + v.signers.signers_data.first() + } + _ => None, + }) + .expect("v2 signer"); + let digest = &signer.signed_data.digests.digests_data[0]; + assert_eq!( + apk.digest(&digest.signature_algorithm_id) + .expect("content digest"), + digest.digest + ); + // Independently verify the signature with OpenSSL and the embedded certificate. + let cert = openssl::x509::X509::from_der( + &signer.signed_data.certificates.certificates_data[0].certificate, + ) + .expect("certificate"); + let public = cert.public_key().expect("public key"); + let mut verifier = + openssl::sign::Verifier::new(openssl::hash::MessageDigest::sha256(), &public) + .expect("verifier"); + verifier + .update(&signer.signed_data.to_u8()[4..]) + .expect("signed data"); + assert!( + verifier + .verify(&signer.signatures.signatures_data[0].signature) + .expect("verify") + ); + let mut archive = + ZipArchive::new(fs::File::open(&destination).expect("open")).expect("ZIP"); + assert!(archive.by_name("META-INF/OLD.SF").is_err()); + assert!(archive.by_name("META-INF/services/example").is_ok()); + assert_eq!( + archive + .by_name("lib/arm64-v8a/libtest.so") + .expect("library") + .data_start() + % 16384, + 0 + ); + let mut content = String::new(); + archive + .by_name("classes.dex") + .expect("dex") + .read_to_string(&mut content) + .expect("read dex"); + assert_eq!(content, "dex content"); + let saved = fs::read(&destination).expect("saved"); + assert!(sign_and_save(&input, &NativeSigner, |_| Ok(Some(path))).is_err()); + assert_eq!( + fs::read(&destination).expect("unchanged destination"), + saved + ); + // Re-signing removes the previous signing block and still produces a valid APK. + let mut second = request(); + second.apk_path = destination.to_string_lossy().into_owned(); + second.keystore_path = input.keystore_path.clone(); + second.password = input.password.clone(); + second.key_password = input.key_password.clone(); + NativeSigner.sign(&second).expect("re-sign"); + let mut tampered = saved; + let position = tampered + .windows(11) + .position(|b| b == b"dex content") + .expect("dex position"); + tampered[position] ^= 1; + fs::write(&destination, tampered).expect("tamper"); + assert_ne!( + apksig::Apk::new(destination) + .expect("tampered") + .digest(&digest.signature_algorithm_id) + .expect("digest"), + digest.digest + ); + } +} + +#[test] +fn rejects_wrong_password_alias_invalid_apk_and_unwritable_destination() { + let dir = tempfile::tempdir().expect("directory"); + let mut input = fixture(Format::Jks, dir.path()); + input.password = "wrong".into(); + assert!(NativeSigner.sign(&input).is_err()); + input.password = "store-password".into(); + input.alias = "missing".into(); + assert!(NativeSigner.sign(&input).is_err()); + input.alias = "upload".into(); + input.key_password = "wrong".into(); + assert!(NativeSigner.sign(&input).is_err()); + input.key_password = "key-password".into(); + let artifact = NativeSigner.sign(&input).expect("sign"); + assert!( + artifact + .save(&dir.path().join("missing/output.apk").to_string_lossy()) + .is_err() + ); + fs::write(&input.apk_path, b"not a ZIP").expect("invalid APK"); + assert!(NativeSigner.sign(&input).is_err()); +} + +#[test] +fn errors_use_the_ipc_contract_without_credentials() { + let error = SigningError::new(ErrorCode::InvalidKeystore, "Check the keystore."); + assert_eq!( + serde_json::to_value(error).expect("serialize"), + serde_json::json!({"code": "invalid_keystore", "message": "Check the keystore."}) + ); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 00000000..21293da5 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,67 @@ +//! Desktop composition root. Feature logic lives outside the Tauri bootstrap. + +mod features; +mod logging; +mod reporting; + +use features::adb::{application::AdbService, infrastructure::UsbAdbConnector}; +use features::devices::{application::DeviceService, infrastructure::UsbDeviceRepository}; +use tauri::Manager; + +/// Starts the desktop runtime and its main window. +/// +/// # Panics +/// +/// Panics if the native runtime cannot be initialized or fails while running. +pub fn run() { + let builder = tauri::Builder::default(); + #[cfg(all(target_os = "macos", feature = "macos-updater"))] + let builder = builder.plugin(tauri_plugin_sparkle_updater::init()); + + builder + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_opener::init()) + .manage(DeviceService::new(UsbDeviceRepository)) + .setup(|app| { + app.manage(logging::start(&app.path().app_log_dir()?)?); + log::info!("Application started version={}", app.package_info().version); + let reporting = reporting::Reporting::new(&app.path().app_config_dir()?); + app.manage(sentry::init(reporting.options())); + logging::install_panic_hook(); + app.manage(reporting); + app.manage(AdbService::new(UsbAdbConnector::new( + app.path().app_data_dir()?.join("adb"), + ))); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + logging::log_frontend_event, + features::settings::commands::open_logs_folder, + reporting::capture_frontend_error, + features::settings::commands::load_settings, + features::settings::commands::set_crash_reporting, + features::settings::commands::open_project_link, + features::settings::commands::check_app_updates, + features::signing::commands::sign_apk, + features::signing::commands::choose_signing_keystore, + features::apk::commands::analyze_apk, + features::apk::commands::choose_apk_path, + features::devices::commands::list_devices, + features::adb::commands::read_android_info, + features::adb::commands::disconnect_adb, + features::keystore::commands::generate_keystore, + features::keystore::explorer_commands::explore_keystore, + features::keystore::commands::choose_keystore_path, + features::keystore::commands::copy_keystore_text, + features::keystore::commands::reveal_keystore + ]) + .build(tauri::generate_context!()) + .expect("failed to initialize Android Tools") + .run(|app, event| { + if matches!(event, tauri::RunEvent::Exit) { + log::info!("Application exiting"); + app.state::().shutdown(); + } + }); +} diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs new file mode 100644 index 00000000..be555ab3 --- /dev/null +++ b/src-tauri/src/logging.rs @@ -0,0 +1,173 @@ +//! Local diagnostics. Only deliberate application events are persisted; dependency +//! logs and arbitrary IPC payloads are excluded to protect credentials and device data. + +use flexi_logger::{Cleanup, Criterion, FileSpec, Logger, LoggerHandle, Naming, WriteMode}; +use std::{future::Future, path::Path, time::Instant}; + +const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; +const ARCHIVE_COUNT: usize = 5; + +fn configured_logger( + directory: &Path, + max_bytes: u64, +) -> Result { + Ok(Logger::try_with_str("off,android_tools_lib=info")? + .log_to_file( + FileSpec::default() + .directory(directory) + .basename("android-tools") + .suppress_timestamp(), + ) + .append() + .use_utc() + .format(|writer, now, record| { + writeln!( + writer, + "{} {:5} [{}] {}", + now.format_rfc3339(), + record.level(), + record.target(), + record.args() + ) + }) + .rotate( + Criterion::Size(max_bytes), + Naming::Numbers, + Cleanup::KeepLogFiles(ARCHIVE_COUNT), + ) + .cleanup_in_background_thread(false) + .write_mode(WriteMode::Async)) +} + +/// Starts the process logger. Retain its handle until the desktop runtime exits. +pub fn start(directory: &Path) -> Result { + configured_logger(directory, MAX_FILE_BYTES)?.start() +} + +/// Chain the existing panic handler (including Sentry) without logging panic +/// payloads, which may contain secrets. Install after other panic integrations. +pub fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Some(location) = info.location() { + log::error!("Rust panic at {}:{}", location.file(), location.line()); + } else { + log::error!("Rust panic at unknown location"); + } + log::logger().flush(); + previous(info); + })); +} + +/// Records an operation's outcome and duration without formatting its input, +/// output or error, which may contain passwords, paths or USB identifiers. +pub async fn observe( + operation: &'static str, + work: impl Future>, +) -> Result { + let started = Instant::now(); + let result = work.await; + let elapsed_ms = started.elapsed().as_millis(); + if result.is_ok() { + log::info!("operation={operation} outcome=success elapsed_ms={elapsed_ms}"); + } else { + log::warn!("operation={operation} outcome=failure elapsed_ms={elapsed_ms}"); + } + result +} + +/// A bounded vocabulary prevents accidental logging of webview secrets. +#[derive(serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FrontendEvent { + VueError, + UnhandledError, + UnhandledRejection, +} + +#[tauri::command] +pub fn log_frontend_event(event: FrontendEvent) { + let kind = match event { + FrontendEvent::VueError => "vue_error", + FrontendEvent::UnhandledError => "unhandled_error", + FrontendEvent::UnhandledRejection => "unhandled_rejection", + }; + log::error!("frontend event={kind}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rotates_bounds_retention_and_appends_after_restart() { + let directory = tempfile::tempdir().expect("temporary logs"); + let (logger, handle) = configured_logger(directory.path(), 256) + .expect("config") + .build() + .expect("logger"); + for _ in 0..100 { + logger.log( + &log::Record::builder() + .args(format_args!("rotation test event")) + .level(log::Level::Info) + .target("android_tools_lib::test") + .build(), + ); + } + handle.shutdown(); + drop(logger); + let files: Vec<_> = std::fs::read_dir(directory.path()) + .expect("logs") + .map(|entry| entry.expect("file").path()) + .collect(); + assert_eq!(files.len(), ARCHIVE_COUNT + 1); + let active = files + .iter() + .find(|path| path.to_string_lossy().contains("rCURRENT")) + .expect("active log"); + let before = std::fs::read_to_string(active).expect("read log"); + assert!(before.contains("INFO")); + assert!(before.contains("[android_tools_lib::test]")); + let (logger, handle) = configured_logger(directory.path(), MAX_FILE_BYTES) + .expect("config") + .build() + .expect("restart"); + logger.log( + &log::Record::builder() + .args(format_args!("restart event")) + .level(log::Level::Info) + .target("android_tools_lib::test") + .build(), + ); + logger.log( + &log::Record::builder() + .args(format_args!("dependency secret")) + .level(log::Level::Error) + .target("external_dependency") + .build(), + ); + handle.shutdown(); + let after = std::fs::read_to_string(active).expect("read appended log"); + assert!(after.starts_with(&before)); + assert!(after.contains("restart event")); + assert!(!after.contains("dependency secret")); + } + + #[test] + fn rejects_arbitrary_frontend_messages() { + assert!(serde_json::from_str::("\"password=secret\"").is_err()); + assert!(serde_json::from_str::("\"vue_error\"").is_ok()); + } + + #[test] + fn reports_an_unwritable_destination() { + let file = tempfile::NamedTempFile::new().expect("file"); + assert!( + configured_logger(file.path(), MAX_FILE_BYTES) + .expect("config") + .build() + .is_err() + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 00000000..2da14da2 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + android_tools_lib::run(); +} diff --git a/src-tauri/src/reporting.rs b/src-tauri/src/reporting.rs new file mode 100644 index 00000000..5f215be6 --- /dev/null +++ b/src-tauri/src/reporting.rs @@ -0,0 +1,153 @@ +//! Sentry composition and the authoritative runtime consent gate. + +use crate::features::settings::{domain::SettingsError, infrastructure}; +use std::{ + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; + +/// Owns runtime consent and serializes preference updates. +pub struct Reporting { + enabled: Arc, + settings_lock: Mutex<()>, +} + +impl Reporting { + pub fn new(directory: &Path) -> Self { + // A corrupt or unreadable preference must never grant consent. + let enabled = Arc::new(AtomicBool::new( + infrastructure::load(directory).is_ok_and(|s| s.crash_reporting_enabled), + )); + Self { + enabled, + settings_lock: Mutex::new(()), + } + } + + pub fn options(&self) -> sentry::ClientOptions { + let gate = self.enabled.clone(); + let mut options = sentry::ClientOptions::default(); + options.dsn = option_env!("SENTRY_DSN").and_then(|value| value.parse().ok()); + options.release = sentry::release_name!(); + options.environment = Some( + if cfg!(debug_assertions) { + "development" + } else { + "production" + } + .into(), + ); + options.send_default_pii = false; + options.auto_session_tracking = false; + options.before_send = Some(Arc::new(move |event| { + gate.load(Ordering::SeqCst).then_some(event) + })); + options + } + + /// Persist first: failed writes leave the active consent unchanged. + pub fn save( + &self, + directory: &Path, + enabled: bool, + ) -> Result { + let _lock = self + .settings_lock + .lock() + .map_err(|_| SettingsError::storage())?; + let settings = infrastructure::set_crash_reporting(directory, enabled)?; + self.enabled + .store(settings.crash_reporting_enabled, Ordering::SeqCst); + Ok(settings) + } + + fn capture(&self, event: &str) { + if !self.enabled.load(Ordering::SeqCst) || event.len() > 1_048_576 { + return; + } + if let Ok(mut event) = serde_json::from_str::>(event) { + event.platform = "javascript".into(); + event.release = None; + event.environment = None; + sentry::capture_event(event); + } + } +} + +/// Accepts error events only; sessions, replay and arbitrary envelopes are not forwarded. +#[tauri::command] +pub fn capture_frontend_error(reporting: tauri::State<'_, Reporting>, event: String) { + reporting.capture(&event); +} + +#[cfg(test)] +mod tests { + use super::*; + + const EVENT: &str = r#"{"exception":{"values":[{"type":"Error","value":"Vue failure"}]}}"#; + + #[test] + fn consent_controls_native_and_frontend_events_immediately_and_after_restart() { + let directory = tempfile::tempdir().expect("directory"); + let reporting = Reporting::new(directory.path()); + let events = sentry::test::with_captured_events_options( + || { + reporting.capture(EVENT); + sentry::capture_message("disabled", sentry::Level::Error); + reporting.save(directory.path(), true).expect("enable"); + reporting.capture(EVENT); + sentry::capture_message("enabled", sentry::Level::Error); + assert!( + Reporting::new(directory.path()) + .enabled + .load(Ordering::SeqCst) + ); + reporting.save(directory.path(), false).expect("disable"); + reporting.capture(EVENT); + sentry::capture_message("disabled again", sentry::Level::Error); + }, + reporting.options(), + ); + assert_eq!(events.len(), 2); + assert_eq!(events[0].platform, "javascript"); + assert_eq!(events[1].message.as_deref(), Some("enabled")); + assert!( + !Reporting::new(directory.path()) + .enabled + .load(Ordering::SeqCst) + ); + } + + #[test] + fn failed_save_preserves_consent_and_corrupt_preferences_start_disabled() { + let directory = tempfile::tempdir().expect("directory"); + let reporting = Reporting::new(directory.path()); + reporting.save(directory.path(), true).expect("enable"); + std::fs::write(directory.path().join("settings.json"), b"broken").expect("corrupt"); + assert!(reporting.save(directory.path(), false).is_err()); + assert!(reporting.enabled.load(Ordering::SeqCst)); + assert!( + !Reporting::new(directory.path()) + .enabled + .load(Ordering::SeqCst) + ); + } + + #[test] + fn malformed_and_oversized_frontend_events_are_ignored() { + let directory = tempfile::tempdir().expect("directory"); + let reporting = Reporting::new(directory.path()); + reporting.save(directory.path(), true).expect("enable"); + let events = sentry::test::with_captured_events_options( + || { + reporting.capture("broken"); + reporting.capture(&" ".repeat(1_048_577)); + }, + reporting.options(), + ); + assert!(events.is_empty()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 00000000..d4fd1a41 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Android Tools", + "version": "2026.10.3", + "identifier": "com.example.androidTools", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://127.0.0.1:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "Android Tools", + "width": 1100, + "height": 760, + "minWidth": 640, + "minHeight": 480 + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; connect-src ipc: http://ipc.localhost", + "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; connect-src ipc: http://ipc.localhost http://127.0.0.1:1420 ws://127.0.0.1:1420" + } + }, + "bundle": { + "active": false, + "icon": ["icons/32x32.png", "icons/icon.icns"], + "macOS": { + "bundleVersion": "39", + "minimumSystemVersion": "10.13" + } + } +} diff --git a/src-tauri/tauri.release.conf.json b/src-tauri/tauri.release.conf.json new file mode 100644 index 00000000..34eb22be --- /dev/null +++ b/src-tauri/tauri.release.conf.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "active": true, + "targets": ["app"], + "category": "DeveloperTool", + "macOS": { + "frameworks": ["Sparkle.framework"], + "signingIdentity": "-", + "hardenedRuntime": false, + "infoPlist": "Info.release.plist" + } + } +} diff --git a/src/App.test.ts b/src/App.test.ts new file mode 100644 index 00000000..05780c0d --- /dev/null +++ b/src/App.test.ts @@ -0,0 +1,199 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import App from './App.vue'; +import { createDemoDeviceService } from './features/devices/infrastructure/demoDeviceService'; +import { createDemoAdbService } from './features/adb/infrastructure/demoAdbService'; + +describe('Application composition', () => { + it('places Settings last in navigation and opens it without a device', async () => { + const wrapper = mount(App, { + props: { deviceService: createDemoDeviceService('empty') }, + }); + await flushPromises(); + const buttons = wrapper + .get('nav[aria-label="Workspace"]') + .findAll('button'); + const settings = buttons.at(-1)!; + expect(settings.text()).toBe('Settings'); + await settings.trigger('click'); + expect(settings.attributes('aria-current')).toBe('page'); + expect(wrapper.get('main h2').text()).toBe('Settings'); + wrapper.unmount(); + }); + it('opens each planned tool and preserves the device connection when returning to the overview', async () => { + const wrapper = mount(App, { + props: { + deviceService: createDemoDeviceService('devices'), + adbService: createDemoAdbService(), + demo: true, + }, + }); + try { + await flushPromises(); + await wrapper.get('aside select').setValue('usb:1:4:04e8:6860'); + await flushPromises(); + const connectionStatus = wrapper + .get('[aria-label="Device connection status"]') + .text(); + const navigation = wrapper.get('nav[aria-label="Workspace"]'); + for (const label of [ + 'File explorer', + 'Logcat', + 'APK analysis', + 'Generate keystore', + 'APK signing', + 'Keystore explorer', + ]) { + const button = navigation + .findAll('button') + .find((entry) => entry.text() === label)!; + await button.trigger('click'); + expect(navigation.findAll('[aria-current="page"]')).toHaveLength(1); + expect(button.attributes('aria-current')).toBe('page'); + expect(wrapper.get('main h2').text()).toBe(label); + expect(wrapper.get('main').text().includes('Coming soon')).toBe( + ![ + 'Generate keystore', + 'APK analysis', + 'APK signing', + 'Keystore explorer', + ].includes(label), + ); + expect(wrapper.get('main header').text()).toContain(label); + expect( + wrapper.get('aside select').element.value, + ).toBe('usb:1:4:04e8:6860'); + } + await navigation.findAll('button')[0]!.trigger('click'); + expect(wrapper.get('main h2').text()).toBe('Android device'); + expect(wrapper.get('main dl').text()).toContain('usb:1:4:04e8:6860'); + expect( + wrapper.get('[aria-label="Device connection status"]').text(), + ).toBe(connectionStatus); + expect(wrapper.get('main').text()).not.toContain('Coming soon'); + } finally { + wrapper.unmount(); + } + }); + + it('keeps planned tools accessible without a connected device', async () => { + const wrapper = mount(App, { + props: { deviceService: createDemoDeviceService('empty') }, + }); + try { + await flushPromises(); + const navigation = wrapper.get('nav[aria-label="Workspace"]'); + for (const button of navigation.findAll('button').slice(1)) { + await button.trigger('click'); + expect(wrapper.get('main h2').text()).toBe(button.text()); + expect(wrapper.get('main').text().includes('Coming soon')).toBe( + ![ + 'Generate keystore', + 'APK analysis', + 'APK signing', + 'Settings', + 'Keystore explorer', + ].includes(button.text()), + ); + } + } finally { + wrapper.unmount(); + } + }); + + it('automatically connects ADB for the first device and shows authorization failures without stale Android data', async () => { + const wrapper = mount(App, { + props: { + deviceService: createDemoDeviceService('devices'), + adbService: createDemoAdbService(), + demo: true, + }, + }); + try { + await flushPromises(); + expect(wrapper.get('aside select').element.value).toBe( + 'usb:1:2:18d1:4ee7', + ); + const status = () => + wrapper.get('[aria-label="Device connection status"]').text(); + const panel = () => wrapper.get('[aria-labelledby="android-info-title"]'); + expect(status()).toContain('ADB: Connected'); + expect(panel().text()).toContain('Simulated Android information'); + expect(panel().text()).toContain('82%'); + expect(panel().text()).toContain('2026-09-05'); + await panel().get('button').trigger('click'); + await flushPromises(); + expect(status()).toContain('ADB: Connected'); + await wrapper.get('aside select').setValue('usb:1:3:18d1:4ee7'); + await flushPromises(); + expect(status()).toContain('ADB: Authorization required'); + expect(panel().get('[role="alert"]').text()).toContain( + 'allow USB debugging', + ); + expect(panel().find('dl').exists()).toBe(false); + expect(panel().get('button').text()).toBe('Retry ADB connection'); + await wrapper.get('aside select').setValue(''); + await flushPromises(); + expect( + wrapper.find('[aria-labelledby="android-info-title"]').exists(), + ).toBe(false); + expect(status()).not.toContain('ADB:'); + } finally { + wrapper.unmount(); + } + }); + it('runs with explicit simulated devices without a native runtime or phone', async () => { + const wrapper = mount(App, { + props: { deviceService: createDemoDeviceService('devices'), demo: true }, + }); + try { + await flushPromises(); + expect(wrapper.get('main h2').text()).toBe('Pixel 9'); + expect(wrapper.get('[role="note"]').text()).toContain('Demo mode'); + expect(wrapper.findAll('option')).toHaveLength(4); + expect( + wrapper.get('[aria-label="Device connection status"]').text(), + ).toContain('Demo β€” simulated connection'); + } finally { + wrapper.unmount(); + } + }); + + it('shows the sidebar selection in the workspace and clears it after disconnection', async () => { + let devices = await createDemoDeviceService('devices').list(); + const wrapper = mount(App, { + props: { deviceService: { list: async () => devices } }, + }); + try { + await flushPromises(); + expect(wrapper.get('main h2').text()).toBe('Pixel 9'); + const status = () => + wrapper + .get('[aria-label="Device connection status"]') + .text() + .replace(/\s+/g, ' '); + expect(status()).toContain('Pixel 9 β€” DEMO-A'); + await wrapper.get('aside select').setValue('usb:1:3:18d1:4ee7'); + expect(status()).toContain('Pixel 9 β€” DEMO-B'); + expect(status()).toContain('USB'); + expect(status()).toContain('ADB: Not checked'); + await wrapper.get('aside select').setValue('usb:1:4:04e8:6860'); + expect(status()).toContain('Android device β€” usb:1:4:04e8:6860'); + expect(status()).not.toContain('DEMO-B'); + await wrapper.get('aside select').setValue('usb:1:3:18d1:4ee7'); + expect(wrapper.get('main h2').text()).toBe('Pixel 9'); + expect(wrapper.get('main dl').text()).toContain('DEMO-B'); + expect(wrapper.get('main dl').text()).not.toContain('DEMO-A'); + devices = []; + await wrapper.get('aside button').trigger('click'); + await flushPromises(); + expect(wrapper.get('main').text()).toContain('Start with a device'); + expect(wrapper.find('main dl').exists()).toBe(false); + expect(status()).toContain('No device selected'); + expect(status()).not.toContain('DEMO-B'); + expect(status()).not.toContain('ADB:'); + } finally { + wrapper.unmount(); + } + }); +}); diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 00000000..df2c8dcf --- /dev/null +++ b/src/App.vue @@ -0,0 +1,179 @@ + + + diff --git a/src/features/adb/domain/adb.ts b/src/features/adb/domain/adb.ts new file mode 100644 index 00000000..b357d30f --- /dev/null +++ b/src/features/adb/domain/adb.ts @@ -0,0 +1,51 @@ +export interface AndroidInfo { + manufacturer: string | null; + model: string | null; + androidVersion: string | null; + apiLevel: string | null; + securityPatch: string | null; + buildId: string | null; + architecture: string | null; + batteryPercent: number | null; + batteryStatus: string | null; + warning: string | null; +} + +/** The adapter owns native communication; components never invoke Tauri directly. */ +export interface AdbService { + read(connectionId: string): Promise; + disconnect(): Promise; +} + +export class AdbError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'AdbError'; + } +} + +export type AdbStatus = + | 'idle' + | 'connecting' + | 'connected' + | 'unauthorized' + | 'disconnected' + | 'error'; + +export interface AdbState { + status: AdbStatus; + info: AndroidInfo | null; + error: string | null; +} + +export const adbStatusLabels: Record = { + idle: 'Not checked', + connecting: 'Connecting…', + connected: 'Connected', + unauthorized: 'Authorization required', + disconnected: 'Disconnected', + error: 'Unavailable', +}; diff --git a/src/features/adb/infrastructure/demoAdbService.ts b/src/features/adb/infrastructure/demoAdbService.ts new file mode 100644 index 00000000..fe4b10d1 --- /dev/null +++ b/src/features/adb/infrastructure/demoAdbService.ts @@ -0,0 +1,34 @@ +import { AdbError, type AdbService } from '../domain/adb'; + +/** Explicit simulation: the second phone needs authorization; the third lacks USB access. */ +export function createDemoAdbService(): AdbService { + return { + async read(id) { + if (id === 'usb:1:3:18d1:4ee7') { + throw new AdbError( + 'unauthorized', + 'Simulated authorization required. Unlock your phone and allow USB debugging, then retry.', + ); + } + if (id !== 'usb:1:2:18d1:4ee7') { + throw new AdbError( + 'usb_access', + 'Simulated USB access failure. Check USB permissions and retry.', + ); + } + return { + manufacturer: 'Google', + model: 'Pixel 9', + androidVersion: '16', + apiLevel: '36', + securityPatch: '2026-09-05', + buildId: 'DEMO-BUILD', + architecture: 'arm64-v8a', + batteryPercent: 82, + batteryStatus: 'Charging', + warning: null, + }; + }, + async disconnect() {}, + }; +} diff --git a/src/features/adb/infrastructure/tauriAdbService.test.ts b/src/features/adb/infrastructure/tauriAdbService.test.ts new file mode 100644 index 00000000..4aaf5c81 --- /dev/null +++ b/src/features/adb/infrastructure/tauriAdbService.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTauriAdbService } from './tauriAdbService'; +import { createDemoAdbService } from './demoAdbService'; + +describe('ADB IPC boundary', () => { + it('targets the selected connection and releases the native session', async () => { + const info = await createDemoAdbService().read('usb:1:2:18d1:4ee7'); + const call = vi + .fn() + .mockResolvedValueOnce(info) + .mockResolvedValueOnce(null); + const service = createTauriAdbService(call); + expect(await service.read('selected-id')).toEqual(info); + expect(call).toHaveBeenNthCalledWith(1, 'read_android_info', { + connectionId: 'selected-id', + }); + await service.disconnect(); + expect(call).toHaveBeenNthCalledWith(2, 'disconnect_adb', undefined); + }); + + it.each([undefined, -1, 101, 2.5, '82'])( + 'rejects invalid battery values: %j', + async (batteryPercent) => { + const info = await createDemoAdbService().read('usb:1:2:18d1:4ee7'); + await expect( + createTauriAdbService(async () => ({ ...info, batteryPercent })).read( + 'id', + ), + ).rejects.toMatchObject({ code: 'invalid_response' }); + }, + ); + + it.each([null, {}, { model: 'Pixel' }])( + 'rejects incomplete Android information: %j', + async (info) => { + await expect( + createTauriAdbService(async () => info).read('id'), + ).rejects.toMatchObject({ code: 'invalid_response' }); + }, + ); + + it('accepts explicitly missing properties and battery information', async () => { + const info = { + manufacturer: null, + model: null, + androidVersion: null, + apiLevel: null, + securityPatch: null, + buildId: null, + architecture: null, + batteryPercent: null, + batteryStatus: null, + warning: 'Battery unavailable', + }; + expect(await createTauriAdbService(async () => info).read('id')).toEqual( + info, + ); + }); + + it('preserves authorization failures and normalizes unknown failures', async () => { + await expect( + createTauriAdbService(async () => { + throw { code: 'unauthorized', message: 'Allow USB debugging' }; + }).read('id'), + ).rejects.toMatchObject({ + code: 'unauthorized', + message: 'Allow USB debugging', + }); + await expect( + createTauriAdbService(async () => { + throw new Error('IPC gone'); + }).disconnect(), + ).rejects.toMatchObject({ code: 'unavailable' }); + }); +}); diff --git a/src/features/adb/infrastructure/tauriAdbService.ts b/src/features/adb/infrastructure/tauriAdbService.ts new file mode 100644 index 00000000..cc0a04ce --- /dev/null +++ b/src/features/adb/infrastructure/tauriAdbService.ts @@ -0,0 +1,67 @@ +import { invoke } from '@tauri-apps/api/core'; +import { AdbError, type AdbService, type AndroidInfo } from '../domain/adb'; + +type Invoke = ( + command: string, + args?: Record, +) => Promise; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isInfo(value: unknown): value is AndroidInfo { + return ( + isRecord(value) && + [ + 'manufacturer', + 'model', + 'androidVersion', + 'apiLevel', + 'securityPatch', + 'buildId', + 'architecture', + 'batteryStatus', + 'warning', + ].every((key) => value[key] === null || typeof value[key] === 'string') && + (value.batteryPercent === null || + (typeof value.batteryPercent === 'number' && + Number.isInteger(value.batteryPercent) && + value.batteryPercent >= 0 && + value.batteryPercent <= 100)) + ); +} + +export function createTauriAdbService(call: Invoke = invoke): AdbService { + async function request(command: string, args?: Record) { + try { + return await call(command, args); + } catch (error) { + if ( + isRecord(error) && + typeof error.code === 'string' && + typeof error.message === 'string' + ) { + throw new AdbError(error.code, error.message); + } + throw new AdbError( + 'unavailable', + 'ADB communication is unavailable. Please retry.', + ); + } + } + return { + async read(connectionId) { + const info = await request('read_android_info', { connectionId }); + if (!isInfo(info)) + throw new AdbError( + 'invalid_response', + 'Android information returned an invalid response. Please retry.', + ); + return info; + }, + async disconnect() { + await request('disconnect_adb'); + }, + }; +} diff --git a/src/features/adb/presentation/AndroidInformation.vue b/src/features/adb/presentation/AndroidInformation.vue new file mode 100644 index 00000000..8cbc318d --- /dev/null +++ b/src/features/adb/presentation/AndroidInformation.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/features/adb/presentation/useAdb.test.ts b/src/features/adb/presentation/useAdb.test.ts new file mode 100644 index 00000000..676a22ab --- /dev/null +++ b/src/features/adb/presentation/useAdb.test.ts @@ -0,0 +1,103 @@ +import { effectScope, nextTick, shallowRef } from 'vue'; +import { flushPromises } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { AdbError, type AndroidInfo } from '../domain/adb'; +import { createDemoAdbService } from '../infrastructure/demoAdbService'; +import { createDemoDeviceService } from '../../devices/infrastructure/demoDeviceService'; +import type { DeviceSummary } from '../../devices/domain/devices'; +import { useAdb } from './useAdb'; + +describe('ADB session lifecycle', () => { + it('discards old-device results and serializes switching and disconnecting', async () => { + const devices = await createDemoDeviceService('devices').list(); + const info = await createDemoAdbService().read(devices[0]!.id); + let resolve!: (info: AndroidInfo) => void; + const pending = new Promise((done) => { + resolve = done; + }); + const read = vi + .fn() + .mockReturnValueOnce(pending) + .mockResolvedValueOnce({ ...info, model: 'Second phone' }); + const disconnect = vi.fn().mockResolvedValue(undefined); + const selected = shallowRef(devices[0]!); + const scope = effectScope(); + const adb = scope.run(() => useAdb(selected, { read, disconnect }))!; + try { + await flushPromises(); + expect(adb.state.value.status).toBe('connecting'); + selected.value = devices[1]!; + await nextTick(); + expect(read).toHaveBeenCalledTimes(1); + resolve(info); + await flushPromises(); + expect(read).toHaveBeenLastCalledWith(devices[1]!.id); + expect(adb.state.value.info?.model).toBe('Second phone'); + selected.value = null; + await flushPromises(); + expect(adb.state.value).toEqual({ + status: 'idle', + info: null, + error: null, + }); + expect(disconnect).toHaveBeenCalledTimes(1); + } finally { + scope.stop(); + } + }); + + it('supports authorization retry and removes stale information on failure', async () => { + const devices = await createDemoDeviceService('devices').list(); + const info = await createDemoAdbService().read(devices[0]!.id); + const service = { + read: vi + .fn() + .mockRejectedValueOnce(new AdbError('unauthorized', 'Allow debugging')) + .mockResolvedValueOnce(info) + .mockRejectedValueOnce( + new AdbError('disconnected', 'Phone disconnected'), + ), + disconnect: vi.fn().mockResolvedValue(undefined), + }; + const scope = effectScope(); + const adb = scope.run(() => useAdb(shallowRef(devices[0]!), service))!; + try { + await flushPromises(); + expect(adb.state.value.status).toBe('unauthorized'); + await adb.refresh(); + expect(adb.state.value.status).toBe('connected'); + expect(adb.state.value.info).toEqual(info); + await adb.refresh(); + expect(adb.state.value).toEqual({ + status: 'disconnected', + info: null, + error: 'Phone disconnected', + }); + } finally { + scope.stop(); + } + }); + + it('releases an in-flight session after disposal without publishing its result', async () => { + const devices = await createDemoDeviceService('devices').list(); + const info = await createDemoAdbService().read(devices[0]!.id); + let resolve!: (info: AndroidInfo) => void; + const read = vi.fn( + () => + new Promise((done) => { + resolve = done; + }), + ); + const disconnect = vi.fn().mockResolvedValue(undefined); + const scope = effectScope(); + const adb = scope.run(() => + useAdb(shallowRef(devices[0]!), { read, disconnect }), + )!; + await flushPromises(); + scope.stop(); + resolve(info); + await flushPromises(); + expect(adb.state.value.info).toBeNull(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/adb/presentation/useAdb.ts b/src/features/adb/presentation/useAdb.ts new file mode 100644 index 00000000..c4eaaae9 --- /dev/null +++ b/src/features/adb/presentation/useAdb.ts @@ -0,0 +1,69 @@ +import { onScopeDispose, ref, watch, type Ref } from 'vue'; +import type { DeviceSummary } from '../../devices/domain/devices'; +import { AdbError, type AdbService, type AdbState } from '../domain/adb'; + +/** Serialize native session changes and discard responses for an obsolete selection. */ +export function useAdb( + device: Ref, + service?: AdbService, +) { + const state = ref({ status: 'idle', info: null, error: null }); + let revision = 0; + let queue = Promise.resolve(); + let disposed = false; + + function refresh() { + const id = device.value?.id; + const request = ++revision; + state.value = { + status: id && service ? 'connecting' : 'idle', + info: null, + error: null, + }; + if (!service) return Promise.resolve(); + queue = queue.then(async () => { + if (disposed || request !== revision) return; + try { + if (!id) { + await service.disconnect(); + return; + } + const info = await service.read(id); + if (disposed || request !== revision) return; + state.value = { status: 'connected', info, error: null }; + } catch (error) { + if (disposed || request !== revision) return; + const code = error instanceof AdbError ? error.code : 'unavailable'; + state.value = { + status: + code === 'unauthorized' + ? 'unauthorized' + : code === 'disconnected' + ? 'disconnected' + : 'error', + info: null, + error: + error instanceof AdbError + ? error.message + : 'Unable to read Android information. Please retry.', + }; + } + }); + return queue; + } + + watch( + device, + () => { + void refresh(); + }, + { immediate: true }, + ); + onScopeDispose(() => { + disposed = true; + revision++; + // In-flight calls are bounded natively; release their session after completion. + if (service) void queue.then(() => service.disconnect()).catch(() => {}); + }); + return { state, refresh }; +} diff --git a/src/features/apk/domain/apk.ts b/src/features/apk/domain/apk.ts new file mode 100644 index 00000000..56716f13 --- /dev/null +++ b/src/features/apk/domain/apk.ts @@ -0,0 +1,37 @@ +export interface ApkReport { + path: string; + size: number; + iconDataUrl: string | null; + signature: { + status: 'verified' | 'invalid' | 'unverified' | 'unsigned'; + schemes: string[]; + message: string; + }; + sha256: string; + sections: { + title: string; + items: { label: string; value: string | null }[]; + }[]; + files: { name: string; size: number; compressedSize: number }[]; + manifest: string; + warnings: string[]; +} + +export type ApkDrop = + { type: 'enter' | 'leave' } | { type: 'drop'; paths: string[] }; + +export interface ApkService { + choosePath(): Promise; + analyze(path: string): Promise; + listenDrop(handler: (event: ApkDrop) => void): Promise<() => void>; +} + +export class ApkError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'ApkError'; + } +} diff --git a/src/features/apk/infrastructure/demoApkService.ts b/src/features/apk/infrastructure/demoApkService.ts new file mode 100644 index 00000000..bd2cbf95 --- /dev/null +++ b/src/features/apk/infrastructure/demoApkService.ts @@ -0,0 +1,90 @@ +import { ApkError, type ApkReport, type ApkService } from '../domain/apk'; + +export function demoReport(path = '/demo/sample.apk'): ApkReport { + return { + path, + size: 5242880, + iconDataUrl: null, + signature: { + status: 'verified', + schemes: ['v2'], + message: + 'Simulated verification result. No real APK signature was checked.', + }, + sha256: 'AB'.repeat(32), + sections: [ + { + title: 'Application', + items: [ + { label: 'Package name', value: 'com.example.demo' }, + { label: 'Application label', value: 'Demo application' }, + { label: 'Version name', value: '1.2.0' }, + { label: 'Version code', value: '12' }, + ], + }, + { + title: 'Android compatibility', + items: [ + { label: 'Minimum SDK', value: '26' }, + { label: 'Target SDK (declared)', value: '35' }, + { label: 'Maximum SDK', value: null }, + ], + }, + { + title: 'Application flags (declared)', + items: [{ label: 'Debuggable', value: 'false' }], + }, + { + title: 'Certificate Β· v2 Β· 1', + items: [ + { label: 'Subject', value: 'CN=Demo signer' }, + { label: 'Issuer', value: 'CN=Demo signer' }, + { label: 'Serial number', value: '42' }, + { label: 'Valid from', value: '2026-01-01' }, + { label: 'Valid until', value: '2056-01-01' }, + { label: 'Signature algorithm', value: 'SHA256withRSA' }, + { label: 'SHA-1', value: 'CD'.repeat(20) }, + { label: 'SHA-256', value: 'EF'.repeat(32) }, + ], + }, + { + title: 'Permissions', + items: [{ label: 'android.permission.INTERNET', value: 'Declared' }], + }, + { + title: 'Activities', + items: [ + { label: 'com.example.demo.MainActivity', value: 'exported: true' }, + ], + }, + ], + files: [ + { name: 'AndroidManifest.xml', size: 2048, compressedSize: 1024 }, + { name: 'classes.dex', size: 8192, compressedSize: 4096 }, + ], + manifest: + '\n \n', + warnings: [ + 'Simulated APK report. No file was read and no signature was verified.', + ], + }; +} + +export function createDemoApkService(fail = false): ApkService { + return { + async choosePath() { + return '/demo/sample.apk'; + }, + async analyze(path) { + if (fail) + throw new ApkError( + 'invalid_apk', + 'Simulated invalid APK. Choose another file to retry.', + ); + return demoReport(path); + }, + async listenDrop() { + return () => {}; + }, + }; +} diff --git a/src/features/apk/infrastructure/tauriApkService.test.ts b/src/features/apk/infrastructure/tauriApkService.test.ts new file mode 100644 index 00000000..8b62415c --- /dev/null +++ b/src/features/apk/infrastructure/tauriApkService.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { createTauriApkService, parseReport } from './tauriApkService'; +import { demoReport } from './demoApkService'; + +describe('APK IPC boundary', () => { + it('accepts reports and dialog cancellation', async () => { + expect(parseReport(demoReport()).files).toHaveLength(2); + expect( + await createTauriApkService(async () => null).choosePath(), + ).toBeNull(); + }); + it('rejects malformed nested data, invalid hashes and mismatched paths', async () => { + for (const data of [ + null, + { ...demoReport(), sha256: 'bad' }, + { ...demoReport(), files: [{ name: 'x', size: -1 }] }, + { + ...demoReport(), + sections: [{ title: 'App', items: [{ label: 'Package', value: 3 }] }], + }, + ]) { + expect(() => parseReport(data)).toThrow('invalid response'); + } + await expect( + createTauriApkService(async () => demoReport()).analyze('/different.apk'), + ).rejects.toThrow('invalid response'); + }); + it('preserves structured errors', async () => { + await expect( + createTauriApkService(async () => { + throw { code: 'read_failed', message: 'File unavailable' }; + }).analyze('/a.apk'), + ).rejects.toMatchObject({ + code: 'read_failed', + message: 'File unavailable', + }); + }); + it('rejects malformed or unsupported verification claims', () => { + for (const signature of [ + null, + {}, + { status: 'verified', schemes: [], message: 'OK' }, + { status: 'verified', schemes: ['v1'], message: 'OK' }, + { status: 'trusted', schemes: ['v2'], message: 'OK' }, + { status: 'verified', schemes: ['v4'], message: 'OK' }, + ]) { + expect(() => parseReport({ ...demoReport(), signature })).toThrow( + 'invalid response', + ); + } + expect(parseReport(demoReport()).signature.status).toBe('verified'); + }); + it('allows inline raster icons only', () => { + for (const iconDataUrl of [ + null, + 'data:image/png;base64,YQ==', + 'data:image/webp;base64,YQ==', + 'data:image/jpeg;base64,YQ==', + ]) { + expect(parseReport({ ...demoReport(), iconDataUrl }).iconDataUrl).toBe( + iconDataUrl, + ); + } + for (const iconDataUrl of [ + undefined, + 42, + 'https://example.com/icon.png', + 'file:///icon.png', + 'data:image/svg+xml;base64,YQ==', + 'data:image/png;base64,', + `data:image/png;base64,${'A'.repeat(2_800_000)}`, + ]) { + expect(() => parseReport({ ...demoReport(), iconDataUrl })).toThrow( + 'invalid response', + ); + } + }); +}); diff --git a/src/features/apk/infrastructure/tauriApkService.ts b/src/features/apk/infrastructure/tauriApkService.ts new file mode 100644 index 00000000..4eaf1aec --- /dev/null +++ b/src/features/apk/infrastructure/tauriApkService.ts @@ -0,0 +1,113 @@ +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWebview } from '@tauri-apps/api/webview'; +import { ApkError, type ApkReport, type ApkService } from '../domain/apk'; + +type Invoke = ( + command: string, + args?: Record, +) => Promise; +const record = (v: unknown): v is Record => + typeof v === 'object' && v !== null; +const size = (v: unknown): v is number => + typeof v === 'number' && Number.isSafeInteger(v) && v >= 0; +const icon = (v: unknown): boolean => + v === null || + (typeof v === 'string' && + v.length <= 2_796_240 && + /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test(v)); +function invalid(): never { + throw new ApkError( + 'invalid_response', + 'The APK service returned an invalid response.', + ); +} + +export function parseReport(value: unknown): ApkReport { + if ( + !record(value) || + typeof value.path !== 'string' || + !value.path || + !size(value.size) || + !icon(value.iconDataUrl) || + !record(value.signature) || + !['verified', 'invalid', 'unverified', 'unsigned'].includes( + String(value.signature.status), + ) || + typeof value.signature.message !== 'string' || + !Array.isArray(value.signature.schemes) || + !value.signature.schemes.every( + (s: unknown) => + typeof s === 'string' && ['v1', 'v2', 'v3', 'v3.1'].includes(s), + ) || + (value.signature.status === 'verified' && + (value.signature.schemes.length === 0 || + value.signature.schemes.includes('v1'))) || + typeof value.sha256 !== 'string' || + !/^[A-F0-9]{64}$/.test(value.sha256) || + typeof value.manifest !== 'string' || + !Array.isArray(value.warnings) || + !value.warnings.every((v) => typeof v === 'string') || + !Array.isArray(value.sections) || + !value.sections.every( + (s) => + record(s) && + typeof s.title === 'string' && + Array.isArray(s.items) && + s.items.every( + (i: unknown) => + record(i) && + typeof i.label === 'string' && + (i.value === null || typeof i.value === 'string'), + ), + ) || + !Array.isArray(value.files) || + !value.files.every( + (f) => + record(f) && + typeof f.name === 'string' && + size(f.size) && + size(f.compressedSize), + ) + ) + invalid(); + return value as unknown as ApkReport; +} + +export function createTauriApkService(call: Invoke = invoke): ApkService { + async function request(command: string, args?: Record) { + try { + return await call(command, args); + } catch (e) { + if ( + record(e) && + typeof e.code === 'string' && + typeof e.message === 'string' + ) + throw new ApkError(e.code, e.message); + throw new ApkError( + 'unavailable', + 'The APK operation is unavailable. Please retry.', + ); + } + } + return { + async choosePath() { + const path = await request('choose_apk_path'); + if (path !== null && (typeof path !== 'string' || !path)) invalid(); + return path as string | null; + }, + async analyze(path) { + const report = parseReport(await request('analyze_apk', { path })); + if (report.path !== path) invalid(); + return report; + }, + async listenDrop(handler) { + return getCurrentWebview().onDragDropEvent(({ payload }) => { + if (payload.type === 'drop') + handler({ type: 'drop', paths: payload.paths }); + else if (payload.type === 'enter' || payload.type === 'leave') + handler({ type: payload.type }); + }); + }, + }; +} diff --git a/src/features/apk/presentation/ApkWorkspace.test.ts b/src/features/apk/presentation/ApkWorkspace.test.ts new file mode 100644 index 00000000..597a7c9d --- /dev/null +++ b/src/features/apk/presentation/ApkWorkspace.test.ts @@ -0,0 +1,226 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h, KeepAlive, ref } from 'vue'; +import { describe, expect, it, vi } from 'vitest'; +import ApkWorkspace from './ApkWorkspace.vue'; +import { + ApkError, + type ApkDrop, + type ApkReport, + type ApkService, +} from '../domain/apk'; +import { + createDemoApkService, + demoReport, +} from '../infrastructure/demoApkService'; + +function fake() { + let handler: (event: ApkDrop) => void = () => {}; + const stop = vi.fn(); + const service: ApkService = { + ...createDemoApkService(), + listenDrop: async (callback) => { + handler = callback; + return stop; + }, + }; + return { + service, + stop, + drop: (paths: string[]) => handler({ type: 'drop', paths }), + }; +} +describe('APK workspace', () => { + it('shows a compact app and signature summary; cancellation preserves the report', async () => { + const f = fake(); + const wrapper = mount(ApkWorkspace, { props: { service: f.service } }); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('com.example.demo'); + const fields = wrapper.findAll('dl').at(0)!; + expect(fields.text()).toContain('APK size5.00 MiB'); + expect(fields.text()).toContain('App version1.2.0 (12)'); + expect(fields.text()).toContain('DebuggableNo'); + expect(fields.text()).toContain('Minimum SDK26'); + expect(fields.text()).toContain('Maximum SDKNo maximum declared'); + expect( + wrapper.get('[aria-labelledby="apk-signature-title"]').text(), + ).toContain('CN=Demo signer'); + expect(wrapper.find('[aria-label="App icon unavailable"]').exists()).toBe( + true, + ); + for (const hidden of [ + 'android.permission.INTERNET', + 'Archive contents', + 'AndroidManifest.xml', + 'Target SDK', + ]) + expect(wrapper.text()).not.toContain(hidden); + f.service.choosePath = async () => null; + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('com.example.demo'); + wrapper.unmount(); + expect(f.stop).toHaveBeenCalledOnce(); + }); + it('validates drops, handles failures, retries and discards stale results', async () => { + const f = fake(); + const wrapper = mount(ApkWorkspace, { props: { service: f.service } }); + await flushPromises(); + f.drop(['/a.apk', '/b.apk']); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toContain('single .apk'); + f.service.analyze = async () => { + throw new ApkError('invalid_apk', 'Invalid APK'); + }; + f.drop(['/a.apk']); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toBe('Invalid APK'); + let resolve!: (report: ApkReport) => void; + f.service.analyze = () => + new Promise((r) => { + resolve = r; + }); + f.drop(['/old.apk']); + await flushPromises(); + expect(wrapper.get('[role="status"]').text()).toContain('Analyzing'); + f.service.analyze = async (path) => demoReport(path); + f.drop(['/new.apk']); + await flushPromises(); + resolve(demoReport('/old.apk')); + await flushPromises(); + expect(wrapper.text()).toContain('new.apk'); + expect(wrapper.text()).not.toContain('old.apk'); + wrapper.unmount(); + }); + it('ignores drops while inactive and preserves results on navigation', async () => { + const f = fake(); + const show = ref(true); + const wrapper = mount( + defineComponent({ + setup: () => () => + h( + KeepAlive, + {}, + { + default: () => + show.value ? h(ApkWorkspace, { service: f.service }) : null, + }, + ), + }), + ); + await flushPromises(); + f.drop(['/first.apk']); + await flushPromises(); + show.value = false; + await flushPromises(); + expect(f.stop).toHaveBeenCalledOnce(); + f.drop(['/hidden.apk']); + show.value = true; + await flushPromises(); + expect(wrapper.text()).toContain('first.apk'); + expect(wrapper.text()).not.toContain('hidden.apk'); + wrapper.unmount(); + }); + it('cleans up a listener that resolves after unmount', async () => { + let resolve!: (stop: () => void) => void; + const service = { + ...createDemoApkService(), + listenDrop: () => + new Promise<() => void>((r) => { + resolve = r; + }), + }; + const wrapper = mount(ApkWorkspace, { props: { service } }); + wrapper.unmount(); + const stop = vi.fn(); + resolve(stop); + await flushPromises(); + expect(stop).toHaveBeenCalledOnce(); + }); + it('distinguishes verified, invalid, unsupported and unsigned APKs', async () => { + const f = fake(); + const data = demoReport(); + f.service.analyze = async () => structuredClone(data); + const wrapper = mount(ApkWorkspace, { props: { service: f.service } }); + for (const [status, label] of [ + ['verified', 'Verified'], + ['invalid', 'Invalid'], + ['unverified', 'Not verified'], + ['unsigned', 'Unsigned'], + ] as const) { + data.signature = { + status, + schemes: status === 'verified' ? ['v2', 'v3'] : [], + message: `Signature result: ${status}`, + }; + await wrapper.get('button').trigger('click'); + await flushPromises(); + const badge = wrapper.get('[aria-label="Signature verification status"]'); + expect(badge.text()).toBe( + status === 'verified' ? 'Verified Β· v2, v3' : label, + ); + expect(wrapper.text()).toContain(data.signature.message); + expect(wrapper.text()).not.toContain( + 'Certificate inspection does not verify', + ); + } + wrapper.unmount(); + }); + it('shows declared maximum SDK, handles debug flags and groups identical signing certificates', async () => { + const f = fake(); + const data = demoReport(); + const compatibility = data.sections.find( + (s) => s.title === 'Android compatibility', + )!; + compatibility.items = [ + { label: 'Minimum SDK', value: null }, + { label: 'Maximum SDK', value: '34' }, + ]; + const debug = data.sections.find( + (s) => s.title === 'Application flags (declared)', + )!.items[0]!; + const certificate = data.sections.find( + (s) => s.title === 'Certificate Β· v2 Β· 1', + )!; + data.sections.push({ ...certificate, title: 'Certificate Β· v3 Β· 1' }); + f.service.analyze = async () => structuredClone(data); + const wrapper = mount(ApkWorkspace, { props: { service: f.service } }); + for (const [flag, expected] of [ + ['true', 'Yes'], + [null, 'No'], + ['@bool/debug', 'Unavailable'], + ] as const) { + debug.value = flag; + await wrapper.get('button').trigger('click'); + await flushPromises(); + const fields = wrapper.get('dl').text(); + expect(fields).toContain(`Debuggable${expected}`); + expect(fields).toContain('Minimum SDK1 (Android default)'); + expect(fields).toContain('Maximum SDK34'); + expect(wrapper.findAll('details')).toHaveLength(1); + expect(wrapper.get('summary').text()).toContain('v2 Β· v3'); + } + wrapper.unmount(); + }); + it('shows a raster icon and falls back if decoding fails, then resets for the next APK', async () => { + const f = fake(); + const data = { + ...demoReport(), + iconDataUrl: 'data:image/png;base64,aGVsbG8=', + }; + f.service.analyze = async () => data; + const wrapper = mount(ApkWorkspace, { props: { service: f.service } }); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.get('img').attributes('src')).toBe(data.iconDataUrl); + await wrapper.get('img').trigger('error'); + expect(wrapper.find('img').exists()).toBe(false); + expect(wrapper.find('[aria-label="App icon unavailable"]').exists()).toBe( + true, + ); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.find('img').exists()).toBe(true); + wrapper.unmount(); + }); +}); diff --git a/src/features/apk/presentation/ApkWorkspace.vue b/src/features/apk/presentation/ApkWorkspace.vue new file mode 100644 index 00000000..cb4f6271 --- /dev/null +++ b/src/features/apk/presentation/ApkWorkspace.vue @@ -0,0 +1,356 @@ + + + diff --git a/src/features/devices/domain/devices.ts b/src/features/devices/domain/devices.ts new file mode 100644 index 00000000..fd791b0f --- /dev/null +++ b/src/features/devices/domain/devices.ts @@ -0,0 +1,27 @@ +/** USB presence does not imply ADB authorization. IDs identify current connections. */ +export interface DeviceSummary { + id: string; + name: string; + serial: string | null; + /** USB descriptor metadata; these are not Android system properties. */ + manufacturer: string | null; + product: string | null; + vendorId: number; + productId: number; + warning: string | null; +} + +/** Injectable boundary: UI code never needs to know about Tauri or USB. */ +export interface DeviceService { + list(): Promise; +} + +export class DeviceDiscoveryError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'DeviceDiscoveryError'; + } +} diff --git a/src/features/devices/infrastructure/demoDeviceService.ts b/src/features/devices/infrastructure/demoDeviceService.ts new file mode 100644 index 00000000..508db508 --- /dev/null +++ b/src/features/devices/infrastructure/demoDeviceService.ts @@ -0,0 +1,57 @@ +import { + DeviceDiscoveryError, + type DeviceService, + type DeviceSummary, +} from '../domain/devices'; + +export type DemoScenario = 'devices' | 'empty' | 'error'; + +/** Deterministic scenarios shared by the explicit browser demo and behavior tests. */ +export function createDemoDeviceService(scenario: DemoScenario): DeviceService { + const devices: DeviceSummary[] = [ + { + id: 'usb:1:2:18d1:4ee7', + name: 'Pixel 9', + serial: 'DEMO-A', + manufacturer: 'Google', + product: 'Pixel 9', + vendorId: 0x18d1, + productId: 0x4ee7, + warning: null, + }, + { + id: 'usb:1:3:18d1:4ee7', + name: 'Pixel 9', + serial: 'DEMO-B', + manufacturer: 'Google', + product: 'Pixel 9', + vendorId: 0x18d1, + productId: 0x4ee7, + warning: null, + }, + { + id: 'usb:1:4:04e8:6860', + name: 'Android device', + serial: null, + manufacturer: null, + product: null, + vendorId: 0x04e8, + productId: 0x6860, + warning: + 'USB metadata is unavailable: access denied. Check USB permissions.', + }, + ]; + return { + async list() { + if (scenario === 'error') { + throw new DeviceDiscoveryError( + 'usb_unavailable', + 'Simulated USB discovery failure. Please retry.', + ); + } + return scenario === 'empty' + ? [] + : devices.map((device) => ({ ...device })); + }, + }; +} diff --git a/src/features/devices/infrastructure/tauriDeviceService.test.ts b/src/features/devices/infrastructure/tauriDeviceService.test.ts new file mode 100644 index 00000000..ba2ae78c --- /dev/null +++ b/src/features/devices/infrastructure/tauriDeviceService.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTauriDeviceService } from './tauriDeviceService'; +import { createDemoDeviceService } from './demoDeviceService'; + +describe('Device IPC boundary', () => { + it('invokes the device command and accepts the serialized device contract', async () => { + const devices = await createDemoDeviceService('devices').list(); + const invoke = vi.fn().mockResolvedValue(devices); + expect(await createTauriDeviceService(invoke).list()).toEqual(devices); + expect(invoke).toHaveBeenCalledWith('list_devices'); + }); + + it.each([ + null, + {}, + [{}], + [{ id: 'usb:1', name: 'Pixel' }], + [ + { id: 'usb:1', name: 'Pixel', serial: null, warning: null }, + { id: 'usb:1', name: 'Pixel', serial: null, warning: null }, + ], + ])( + 'rejects malformed or ambiguous device responses: %j', + async (response) => { + await expect( + createTauriDeviceService(async () => response).list(), + ).rejects.toMatchObject({ code: 'invalid_response' }); + }, + ); + + it('preserves structured native failures', async () => { + await expect( + createTauriDeviceService(async () => { + throw { code: 'usb_unavailable', message: 'USB denied' }; + }).list(), + ).rejects.toMatchObject({ code: 'usb_unavailable', message: 'USB denied' }); + }); + + it.each([ + { manufacturer: 42 }, + { product: undefined }, + { vendorId: -1 }, + { vendorId: 65536 }, + { productId: 1.5 }, + { productId: '4ee7' }, + ])('rejects invalid USB metadata: %j', async (metadata) => { + const devices = await createDemoDeviceService('devices').list(); + await expect( + createTauriDeviceService(async () => [ + { ...devices[0], ...metadata }, + ]).list(), + ).rejects.toMatchObject({ code: 'invalid_response' }); + }); + + it('rejects duplicate connections with otherwise valid metadata', async () => { + const devices = await createDemoDeviceService('devices').list(); + await expect( + createTauriDeviceService(async () => [devices[0], devices[0]]).list(), + ).rejects.toMatchObject({ code: 'invalid_response' }); + }); + + it('normalizes unexpected transport failures without introducing fake data', async () => { + await expect( + createTauriDeviceService(async () => { + throw new Error('IPC gone'); + }).list(), + ).rejects.toMatchObject({ code: 'unavailable' }); + }); + + it('provides reproducible empty and error demo scenarios', async () => { + expect(await createDemoDeviceService('empty').list()).toEqual([]); + await expect(createDemoDeviceService('error').list()).rejects.toMatchObject( + { code: 'usb_unavailable' }, + ); + }); +}); diff --git a/src/features/devices/infrastructure/tauriDeviceService.ts b/src/features/devices/infrastructure/tauriDeviceService.ts new file mode 100644 index 00000000..adac3e20 --- /dev/null +++ b/src/features/devices/infrastructure/tauriDeviceService.ts @@ -0,0 +1,72 @@ +import { invoke } from '@tauri-apps/api/core'; +import { + DeviceDiscoveryError, + type DeviceService, + type DeviceSummary, +} from '../domain/devices'; + +type Invoke = (command: string) => Promise; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isUsbId(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= 0 && + value <= 0xffff + ); +} + +function isDevice(value: unknown): value is DeviceSummary { + return ( + isRecord(value) && + typeof value.id === 'string' && + value.id.length > 0 && + typeof value.name === 'string' && + value.name.length > 0 && + (value.serial === null || typeof value.serial === 'string') && + (value.manufacturer === null || typeof value.manufacturer === 'string') && + (value.product === null || typeof value.product === 'string') && + isUsbId(value.vendorId) && + isUsbId(value.productId) && + (value.warning === null || typeof value.warning === 'string') + ); +} + +/** Validate IPC at runtime instead of trusting an unchecked invoke cast. */ +export function createTauriDeviceService(call: Invoke = invoke): DeviceService { + return { + async list() { + let result: unknown; + try { + result = await call('list_devices'); + } catch (error) { + if ( + isRecord(error) && + typeof error.code === 'string' && + typeof error.message === 'string' + ) { + throw new DeviceDiscoveryError(error.code, error.message); + } + throw new DeviceDiscoveryError( + 'unavailable', + 'Device discovery is unavailable. Please retry.', + ); + } + if ( + !Array.isArray(result) || + !result.every(isDevice) || + new Set(result.map((device) => device.id)).size !== result.length + ) { + throw new DeviceDiscoveryError( + 'invalid_response', + 'The device list returned an invalid response. Please retry.', + ); + } + return result; + }, + }; +} diff --git a/src/features/devices/presentation/DevicePicker.test.ts b/src/features/devices/presentation/DevicePicker.test.ts new file mode 100644 index 00000000..0709d4ac --- /dev/null +++ b/src/features/devices/presentation/DevicePicker.test.ts @@ -0,0 +1,124 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DeviceDiscoveryError, type DeviceSummary } from '../domain/devices'; +import { createDemoDeviceService } from '../infrastructure/demoDeviceService'; +import DevicePicker from './DevicePicker.vue'; + +const wrappers: ReturnType[] = []; +afterEach(() => { + wrappers.splice(0).forEach((wrapper) => wrapper.unmount()); +}); + +function render(list = createDemoDeviceService('devices').list) { + const wrapper = mount(DevicePicker, { props: { service: { list } } }); + wrappers.push(wrapper); + return wrapper; +} + +describe('Device selection', () => { + it('shows loading and disables controls until discovery finishes', async () => { + let resolve!: (devices: DeviceSummary[]) => void; + const wrapper = render( + () => + new Promise((done) => { + resolve = done; + }), + ); + await flushPromises(); + expect(wrapper.get('select').attributes('disabled')).toBeDefined(); + expect(wrapper.get('button').attributes('disabled')).toBeDefined(); + expect(wrapper.get('[role="status"]').text()).toContain('Looking for'); + resolve([]); + await flushPromises(); + expect(wrapper.get('[role="status"]').text()).toContain( + 'No Android devices found', + ); + expect(wrapper.get('button').attributes('disabled')).toBeUndefined(); + }); + + it('selects the first detected device and allows choosing another identical model', async () => { + const wrapper = render(); + await flushPromises(); + expect(wrapper.findAll('option').map((option) => option.text())).toEqual( + expect.arrayContaining([ + expect.stringContaining('DEMO-A'), + expect.stringContaining('DEMO-B'), + expect.stringContaining('Limited metadata'), + ]), + ); + expect(wrapper.get('select').element.value).toBe('usb:1:2:18d1:4ee7'); + await wrapper.get('select').setValue('usb:1:3:18d1:4ee7'); + expect(wrapper.get('h3').text()).toBe('Selected device'); + expect(wrapper.text()).toContain('ADB authorization has not been checked'); + expect(wrapper.get('select').element.value).toBe('usb:1:3:18d1:4ee7'); + }); + + it('keeps selection when still present and clears it after disconnection', async () => { + const devices = await createDemoDeviceService('devices').list(); + const list = vi + .fn() + .mockResolvedValueOnce(devices) + .mockResolvedValueOnce([...devices].reverse()) + .mockResolvedValueOnce([]); + const wrapper = render(list); + await flushPromises(); + await wrapper.get('select').setValue(devices[1]!.id); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.get('select').element.value).toBe(devices[1]!.id); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.get('select').element.value).toBe(''); + expect(wrapper.find('h3').exists()).toBe(false); + }); + + it('reports failure, clears stale selection and allows retry', async () => { + const devices = await createDemoDeviceService('devices').list(); + const list = vi + .fn() + .mockResolvedValueOnce(devices) + .mockRejectedValueOnce( + new DeviceDiscoveryError('usb_unavailable', 'USB access denied'), + ) + .mockResolvedValueOnce(devices); + const wrapper = render(list); + await flushPromises(); + await wrapper.get('select').setValue(devices[0]!.id); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toBe('USB access denied'); + expect(wrapper.find('h3').exists()).toBe(false); + expect(wrapper.findAll('option')).toHaveLength(1); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); + expect(wrapper.findAll('option')).toHaveLength(4); + expect(wrapper.get('select').element.value).toBe(devices[0]!.id); + }); + + it('selects a newly discovered device and falls back when the selected device disappears', async () => { + const devices = await createDemoDeviceService('devices').list(); + const list = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([devices[1]]) + .mockResolvedValueOnce([devices[0], devices[2]]); + const wrapper = render(list); + await flushPromises(); + expect(wrapper.get('select').element.value).toBe(''); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.get('select').element.value).toBe(devices[1]!.id); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.get('select').element.value).toBe(devices[0]!.id); + }); + + it('retains selectable devices with missing metadata and explains the limitation', async () => { + const wrapper = render(); + await flushPromises(); + await wrapper.get('select').setValue('usb:1:4:04e8:6860'); + expect(wrapper.text()).toContain('Serial unavailable'); + expect(wrapper.text()).toContain('Check USB permissions.'); + }); +}); diff --git a/src/features/devices/presentation/DevicePicker.vue b/src/features/devices/presentation/DevicePicker.vue new file mode 100644 index 00000000..0fcfa3bc --- /dev/null +++ b/src/features/devices/presentation/DevicePicker.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/features/devices/presentation/DeviceStatusBar.vue b/src/features/devices/presentation/DeviceStatusBar.vue new file mode 100644 index 00000000..96b41fd8 --- /dev/null +++ b/src/features/devices/presentation/DeviceStatusBar.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/features/devices/presentation/DeviceWorkspace.test.ts b/src/features/devices/presentation/DeviceWorkspace.test.ts new file mode 100644 index 00000000..6242f1ab --- /dev/null +++ b/src/features/devices/presentation/DeviceWorkspace.test.ts @@ -0,0 +1,42 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createDemoDeviceService } from '../infrastructure/demoDeviceService'; +import DeviceWorkspace from './DeviceWorkspace.vue'; + +describe('Device overview', () => { + it('shows USB information and replaces it when the selected device changes', async () => { + const devices = await createDemoDeviceService('devices').list(); + const wrapper = mount(DeviceWorkspace, { + props: { device: devices[0]!, demo: true }, + }); + const details = () => + Object.fromEntries( + wrapper + .findAll('dl > div') + .map((row) => [row.get('dt').text(), row.get('dd').text()]), + ); + expect(details()).toEqual({ + Manufacturer: 'Google', + 'USB product': 'Pixel 9', + 'Serial number': 'DEMO-A', + 'Connection ID': 'usb:1:2:18d1:4ee7', + 'USB vendor ID': '0x18D1', + 'USB product ID': '0x4EE7', + }); + expect(wrapper.text()).toContain('Simulated USB device'); + await wrapper.setProps({ device: devices[2]! }); + expect(details()).toEqual({ + Manufacturer: 'Unavailable', + 'USB product': 'Unavailable', + 'Serial number': 'Unavailable', + 'Connection ID': 'usb:1:4:04e8:6860', + 'USB vendor ID': '0x04E8', + 'USB product ID': '0x6860', + }); + expect(wrapper.text()).toContain('Check USB permissions.'); + expect(wrapper.text()).not.toContain('Google'); + await wrapper.setProps({ device: null }); + expect(wrapper.find('dl').exists()).toBe(false); + wrapper.unmount(); + }); +}); diff --git a/src/features/devices/presentation/DeviceWorkspace.vue b/src/features/devices/presentation/DeviceWorkspace.vue new file mode 100644 index 00000000..0c50f014 --- /dev/null +++ b/src/features/devices/presentation/DeviceWorkspace.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/features/devices/presentation/useDevices.test.ts b/src/features/devices/presentation/useDevices.test.ts new file mode 100644 index 00000000..3c446ada --- /dev/null +++ b/src/features/devices/presentation/useDevices.test.ts @@ -0,0 +1,78 @@ +import { effectScope } from 'vue'; +import { describe, expect, it, vi } from 'vitest'; +import type { DeviceSummary } from '../domain/devices'; +import { useDevices } from './useDevices'; + +function deferred() { + let resolve!: (devices: DeviceSummary[]) => void; + let reject!: (cause: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +describe('Device request lifecycle', () => { + it('ignores an older failure after a newer refresh succeeds', async () => { + const first = deferred(); + const second = deferred(); + const scope = effectScope(); + const state = scope.run(() => + useDevices({ + list: vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise), + }), + )!; + try { + const oldRequest = state.refresh(); + const newRequest = state.refresh(); + const device = { + id: 'usb:1', + name: 'Pixel', + serial: null, + manufacturer: null, + product: null, + vendorId: 0x18d1, + productId: 0x4ee7, + warning: null, + }; + second.resolve([device]); + await newRequest; + state.select(device.id); + first.reject(new Error('Stale failure')); + await oldRequest; + expect(state.selectedDevice.value).toEqual(device); + expect(state.error.value).toBeNull(); + expect(state.loading.value).toBe(false); + state.select('unknown'); + expect(state.selectedDevice.value).toBeNull(); + } finally { + scope.stop(); + } + }); + + it('ignores discovery results after the owning scope is disposed', async () => { + const pending = deferred(); + const scope = effectScope(); + const state = scope.run(() => useDevices({ list: () => pending.promise }))!; + const request = state.refresh(); + scope.stop(); + pending.resolve([ + { + id: 'usb:1', + name: 'Pixel', + serial: null, + warning: null, + manufacturer: null, + product: null, + vendorId: 0x18d1, + productId: 0x4ee7, + }, + ]); + await request; + expect(state.devices.value).toEqual([]); + }); +}); diff --git a/src/features/devices/presentation/useDevices.ts b/src/features/devices/presentation/useDevices.ts new file mode 100644 index 00000000..552745dc --- /dev/null +++ b/src/features/devices/presentation/useDevices.ts @@ -0,0 +1,61 @@ +import { computed, onScopeDispose, ref } from 'vue'; +import { + DeviceDiscoveryError, + type DeviceService, + type DeviceSummary, +} from '../domain/devices'; + +/** Owns refresh and selection state; only the latest request may update the screen. */ +export function useDevices(service: DeviceService) { + const devices = ref([]); + const selectedId = ref(''); + const loading = ref(false); + const error = ref(null); + const selectedDevice = computed( + () => + devices.value.find((device) => device.id === selectedId.value) ?? null, + ); + let revision = 0; + onScopeDispose(() => { + revision++; + }); + + async function refresh() { + const request = ++revision; + loading.value = true; + error.value = null; + try { + const result = await service.list(); + if (request !== revision) return; + devices.value = result; + if (!result.some((device) => device.id === selectedId.value)) + selectedId.value = result[0]?.id ?? ''; + } catch (cause) { + if (request !== revision) return; + devices.value = []; + selectedId.value = ''; + error.value = + cause instanceof DeviceDiscoveryError + ? cause.message + : 'Unable to load devices. Please retry.'; + } finally { + if (request === revision) loading.value = false; + } + } + + function select(id: string) { + selectedId.value = devices.value.some((device) => device.id === id) + ? id + : ''; + } + + return { + devices, + selectedId, + selectedDevice, + loading, + error, + refresh, + select, + }; +} diff --git a/src/features/keystore/domain/explorer.ts b/src/features/keystore/domain/explorer.ts new file mode 100644 index 00000000..9b1fa41a --- /dev/null +++ b/src/features/keystore/domain/explorer.ts @@ -0,0 +1,40 @@ +export interface ExploreRequest { + path: string; + password: string; + keyAlias: string | null; + keyPassword: string; +} +export interface CertificateInfo { + subject: string; + issuer: string; + serial: string; + validFrom: string; + validUntil: string; + sha1: string; + sha256: string; +} +export interface ExploredEntry { + alias: string | null; + kind: 'private_key' | 'trusted_certificate' | 'certificate'; + keyStatus: 'not_checked' | 'not_applicable' | 'verified' | 'failed'; + certificates: CertificateInfo[]; +} +export interface KeystoreReport { + format: 'jks' | 'pkcs12'; + entries: ExploredEntry[]; + limitation: string | null; +} +export interface KeystoreExplorerService { + choose(): Promise; + inspect(request: ExploreRequest): Promise; + copy(text: string): Promise; +} +export class ExplorerError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'ExplorerError'; + } +} diff --git a/src/features/keystore/domain/keystore.ts b/src/features/keystore/domain/keystore.ts new file mode 100644 index 00000000..8e394f67 --- /dev/null +++ b/src/features/keystore/domain/keystore.ts @@ -0,0 +1,121 @@ +export type KeystoreFormat = 'JKS' | 'PKCS12'; + +export interface GenerateRequest { + path: string; + format: KeystoreFormat; + password: string; + keyPassword: string; + alias: string; + validityYears: number; + commonName: string; + organizationalUnit: string; + organization: string; + locality: string; + state: string; + country: string; +} + +export interface GeneratedKeystore { + path: string; + format: KeystoreFormat; + alias: string; + expiresAt: string; + sha1: string; + sha256: string; +} + +export interface KeystoreService { + choosePath(format: KeystoreFormat): Promise; + generate(request: GenerateRequest): Promise; + copy(text: string): Promise; + reveal(path: string): Promise; +} + +export class KeystoreError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly field?: string, + ) { + super(message); + this.name = 'KeystoreError'; + } +} + +export function extension(format: KeystoreFormat) { + return format === 'JKS' ? 'jks' : 'p12'; +} + +export function expirationDate(years: number, now = new Date()): string { + if (!Number.isInteger(years) || years < 1 || years > 100) return ''; + const year = now.getUTCFullYear() + years; + const month = now.getUTCMonth(); + const day = Math.min( + now.getUTCDate(), + new Date(Date.UTC(year, month + 1, 0)).getUTCDate(), + ); + return new Date(Date.UTC(year, month, day)).toISOString().slice(0, 10); +} + +export function validateRequest( + request: GenerateRequest, +): Record { + const errors: Record = {}; + if ( + !/^(\/|[a-zA-Z]:[\\/]|\\\\)/.test(request.path) || + !request.path.endsWith(`.${extension(request.format)}`) + ) + errors.path = `Choose an absolute path ending in .${extension(request.format)}.`; + for (const field of ['password', 'keyPassword'] as const) { + if (field === 'keyPassword' && !request.keyPassword) continue; + if (!/^[\x20-\x7e]{6,128}$/.test(request[field])) + errors[field] = + 'Use 6–128 printable ASCII characters for Java/Android compatibility.'; + } + if ( + request.format === 'PKCS12' && + request.keyPassword && + request.password !== request.keyPassword + ) + errors.keyPassword = 'PKCS12 uses the keystore password for the key.'; + if (!/^[a-z0-9._-]{1,64}$/.test(request.alias)) + errors.alias = + 'Use 1–64 lowercase letters, numbers, dots, underscores or hyphens.'; + if ( + !Number.isInteger(request.validityYears) || + request.validityYears < 1 || + request.validityYears > 100 + ) + errors.validityYears = 'Choose a validity between 1 and 100 years.'; + for (const field of [ + 'commonName', + 'organizationalUnit', + 'organization', + 'locality', + 'state', + ] as const) { + if ( + new TextEncoder().encode(request[field]).length > 128 || + Array.from(request[field]).some((c) => { + const code = c.codePointAt(0)!; + return code < 32 || (code >= 127 && code <= 159); + }) + ) + errors[field] = 'Use at most 128 UTF-8 bytes without control characters.'; + } + if (!request.commonName.trim()) + errors.commonName = 'Enter a certificate name.'; + if (request.country && !/^[A-Z]{2}$/.test(request.country)) + errors.country = 'Enter a two-letter country code.'; + return errors; +} + +/** Each of the 64 symbols is equally likely; Web Crypto supplies 144 bits of entropy. */ +export function generatePassword(): string { + const alphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + return Array.from( + crypto.getRandomValues(new Uint8Array(24)), + (byte) => alphabet[byte & 63], + ).join(''); +} diff --git a/src/features/keystore/infrastructure/demoKeystoreExplorerService.ts b/src/features/keystore/infrastructure/demoKeystoreExplorerService.ts new file mode 100644 index 00000000..0c7eaa69 --- /dev/null +++ b/src/features/keystore/infrastructure/demoKeystoreExplorerService.ts @@ -0,0 +1,49 @@ +import { + ExplorerError, + type KeystoreExplorerService, +} from '../domain/explorer'; + +export function createDemoKeystoreExplorerService( + fail = false, +): KeystoreExplorerService { + return { + async choose() { + return '/demo/upload.jks'; + }, + async copy() {}, + async inspect(request) { + if (fail || request.password !== 'demo-password') + throw new ExplorerError( + 'unlock_failed', + 'Could not verify the keystore password and integrity. Demo password: demo-password.', + ); + return { + format: 'jks', + limitation: null, + entries: [ + { + alias: 'upload', + kind: 'private_key', + keyStatus: request.keyAlias + ? request.keyPassword === '' || + request.keyPassword === 'demo-password' + ? 'verified' + : 'failed' + : 'not_checked', + certificates: [ + { + subject: 'CN=Demo signing identity, O=Example Studio', + issuer: 'CN=Demo signing identity, O=Example Studio', + serial: '1234', + validFrom: 'Oct 5 00:00:00 2026 GMT', + validUntil: 'Oct 5 00:00:00 2056 GMT', + sha1: Array(20).fill('AA').join(':'), + sha256: Array(32).fill('BB').join(':'), + }, + ], + }, + ], + }; + }, + }; +} diff --git a/src/features/keystore/infrastructure/demoKeystoreService.ts b/src/features/keystore/infrastructure/demoKeystoreService.ts new file mode 100644 index 00000000..8340d31b --- /dev/null +++ b/src/features/keystore/infrastructure/demoKeystoreService.ts @@ -0,0 +1,42 @@ +import { + expirationDate, + extension, + KeystoreError, + type KeystoreService, +} from '../domain/keystore'; + +/** Explicit development-only simulation: no private key or file is created. */ +export function createDemoKeystoreService(fail = false): KeystoreService { + return { + async choosePath(format) { + return `/demo/upload.${extension(format)}`; + }, + async generate(request) { + if (fail) + throw new KeystoreError( + 'write_failed', + 'Simulated failure: the destination folder is not writable.', + ); + return { + path: request.path, + format: request.format, + alias: request.alias, + expiresAt: expirationDate( + request.validityYears, + new Date('2026-10-03T12:00:00Z'), + ), + sha1: Array(20).fill('AA').join(':'), + sha256: Array(32).fill('BB').join(':'), + }; + }, + async copy(text) { + await navigator.clipboard.writeText(text); + }, + async reveal() { + throw new KeystoreError( + 'demo', + 'Demo mode does not create a file to reveal.', + ); + }, + }; +} diff --git a/src/features/keystore/infrastructure/tauriKeystoreExplorerService.test.ts b/src/features/keystore/infrastructure/tauriKeystoreExplorerService.test.ts new file mode 100644 index 00000000..0c0b22f2 --- /dev/null +++ b/src/features/keystore/infrastructure/tauriKeystoreExplorerService.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTauriKeystoreExplorerService } from './tauriKeystoreExplorerService'; +import { createDemoKeystoreExplorerService } from './demoKeystoreExplorerService'; + +const input = { + path: '/upload.jks', + password: 'demo-password', + keyAlias: null, + keyPassword: '', +}; +describe('Keystore explorer IPC', () => { + it('passes credentials to the native command and validates certificate results', async () => { + const report = await createDemoKeystoreExplorerService().inspect(input); + const call = vi.fn().mockResolvedValue(report); + expect( + await createTauriKeystoreExplorerService(call).inspect(input), + ).toEqual(report); + expect(call).toHaveBeenCalledWith('explore_keystore', { request: input }); + report.entries[0]!.certificates[0]!.sha256 = 'invalid'; + await expect( + createTauriKeystoreExplorerService(call).inspect(input), + ).rejects.toMatchObject({ code: 'invalid_response' }); + }); + it('rejects invalid statuses and translates structured native failures', async () => { + const report = await createDemoKeystoreExplorerService().inspect(input); + const call = vi.fn().mockResolvedValue({ + ...report, + entries: [{ ...report.entries[0], keyStatus: 'healthy' }], + }); + await expect( + createTauriKeystoreExplorerService(call).inspect(input), + ).rejects.toMatchObject({ code: 'invalid_response' }); + call.mockRejectedValue({ + code: 'unlock_failed', + message: 'Password or integrity check failed.', + }); + await expect( + createTauriKeystoreExplorerService(call).inspect(input), + ).rejects.toMatchObject({ + code: 'unlock_failed', + message: 'Password or integrity check failed.', + }); + }); + it('handles cancelled file selection without inventing a path', async () => { + const service = createTauriKeystoreExplorerService( + vi.fn().mockResolvedValue(null), + ); + expect(await service.choose()).toBeNull(); + }); +}); diff --git a/src/features/keystore/infrastructure/tauriKeystoreExplorerService.ts b/src/features/keystore/infrastructure/tauriKeystoreExplorerService.ts new file mode 100644 index 00000000..0337e65a --- /dev/null +++ b/src/features/keystore/infrastructure/tauriKeystoreExplorerService.ts @@ -0,0 +1,102 @@ +import { invoke } from '@tauri-apps/api/core'; +import { + ExplorerError, + type CertificateInfo, + type ExploredEntry, + type KeystoreExplorerService, + type KeystoreReport, +} from '../domain/explorer'; + +type Invoke = ( + command: string, + args?: Record, +) => Promise; +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} +function certificate(value: unknown): value is CertificateInfo { + return ( + record(value) && + [ + 'subject', + 'issuer', + 'serial', + 'validFrom', + 'validUntil', + 'sha1', + 'sha256', + ].every((k) => typeof value[k] === 'string') && + typeof value.sha1 === 'string' && + /^(?:[A-F0-9]{2}:){19}[A-F0-9]{2}$/.test(value.sha1) && + typeof value.sha256 === 'string' && + /^(?:[A-F0-9]{2}:){31}[A-F0-9]{2}$/.test(value.sha256) + ); +} +function entry(value: unknown): value is ExploredEntry { + return ( + record(value) && + (value.alias === null || typeof value.alias === 'string') && + ['private_key', 'trusted_certificate', 'certificate'].includes( + String(value.kind), + ) && + ['not_checked', 'not_applicable', 'verified', 'failed'].includes( + String(value.keyStatus), + ) && + (value.kind === 'private_key' + ? value.keyStatus !== 'not_applicable' + : value.keyStatus === 'not_applicable') && + Array.isArray(value.certificates) && + value.certificates.every(certificate) + ); +} +function report(value: unknown): value is KeystoreReport { + return ( + record(value) && + (value.format === 'jks' || value.format === 'pkcs12') && + (value.limitation === null || typeof value.limitation === 'string') && + Array.isArray(value.entries) && + value.entries.every(entry) + ); +} +function invalid(): never { + throw new ExplorerError( + 'invalid_response', + 'The keystore explorer returned an invalid response.', + ); +} +export function createTauriKeystoreExplorerService( + call: Invoke = invoke, +): KeystoreExplorerService { + async function request(command: string, args?: Record) { + try { + return await call(command, args); + } catch (error) { + if ( + record(error) && + typeof error.code === 'string' && + typeof error.message === 'string' + ) { + throw new ExplorerError(error.code, error.message); + } + throw new ExplorerError( + 'unavailable', + 'The keystore explorer is unavailable. Please retry.', + ); + } + } + return { + async choose() { + const value = await request('choose_signing_keystore'); + if (value === null || (typeof value === 'string' && value.length > 0)) + return value; + return invalid(); + }, + async inspect(input) { + const value = await request('explore_keystore', { request: input }); + return report(value) ? value : invalid(); + }, + async copy(text) { + await request('copy_keystore_text', { text }); + }, + }; +} diff --git a/src/features/keystore/infrastructure/tauriKeystoreService.test.ts b/src/features/keystore/infrastructure/tauriKeystoreService.test.ts new file mode 100644 index 00000000..1f5fff00 --- /dev/null +++ b/src/features/keystore/infrastructure/tauriKeystoreService.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTauriKeystoreService } from './tauriKeystoreService'; +import { createDemoKeystoreService } from './demoKeystoreService'; +import { + expirationDate, + type GenerateRequest, + validateRequest, +} from '../domain/keystore'; + +const input: GenerateRequest = { + path: '/tmp/upload.jks', + format: 'JKS', + alias: 'upload', + password: 'store-password', + keyPassword: 'store-password', + validityYears: 30, + commonName: 'Signing', + organization: '', + organizationalUnit: '', + locality: '', + state: '', + country: '', +}; +describe('Keystore service and validation', () => { + it('validates the native result and forwards the creation contract', async () => { + const result = await createDemoKeystoreService().generate(input); + const call = vi.fn().mockResolvedValue(result); + const service = createTauriKeystoreService(call); + expect(await service.generate(input)).toEqual(result); + expect(call).toHaveBeenCalledWith('generate_keystore', { request: input }); + for (const invalid of [ + null, + {}, + { ...result, sha256: 'invalid' }, + { ...result, path: '/wrong.jks' }, + { ...result, format: 'PKCS12' }, + ]) { + call.mockResolvedValueOnce(invalid); + await expect(service.generate(input)).rejects.toMatchObject({ + code: 'invalid_response', + }); + } + }); + it('handles cancellation and malformed paths, and translates structured errors', async () => { + const call = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(42) + .mockRejectedValueOnce({ + code: 'invalid_input', + message: 'Invalid alias.', + field: 'alias', + }) + .mockRejectedValueOnce('unknown'); + const service = createTauriKeystoreService(call); + expect(await service.choosePath('JKS')).toBeNull(); + await expect(service.choosePath('JKS')).rejects.toMatchObject({ + code: 'invalid_response', + }); + await expect(service.generate(input)).rejects.toMatchObject({ + code: 'invalid_input', + field: 'alias', + message: 'Invalid alias.', + }); + await expect(service.copy('secret')).rejects.toMatchObject({ + code: 'unavailable', + }); + }); + it('validates compatibility constraints without trimming passwords', () => { + expect(validateRequest(input)).toEqual({}); + expect(validateRequest({ ...input, keyPassword: '' })).toEqual({}); + expect( + validateRequest({ ...input, password: '', keyPassword: '' }).password, + ).toBeDefined(); + expect( + validateRequest({ ...input, keyPassword: ' ' }).keyPassword, + ).toBeDefined(); + expect( + validateRequest({ + ...input, + password: ' leading and trailing ', + keyPassword: ' leading and trailing ', + }), + ).toEqual({}); + expect( + validateRequest({ + ...input, + password: 'éèàpassword', + commonName: '', + alias: 'UPPER', + validityYears: 1.5, + country: 'FRA', + }), + ).toMatchObject({ + password: expect.any(String), + commonName: expect.any(String), + alias: expect.any(String), + validityYears: expect.any(String), + country: expect.any(String), + }); + expect( + validateRequest({ ...input, path: 'relative.jks' }).path, + ).toBeDefined(); + expect(expirationDate(1, new Date('2024-02-29T12:00:00Z'))).toBe( + '2025-02-28', + ); + }); + it('keeps demo failures explicit', async () => { + await expect( + createDemoKeystoreService(true).generate(input), + ).rejects.toMatchObject({ code: 'write_failed' }); + }); +}); diff --git a/src/features/keystore/infrastructure/tauriKeystoreService.ts b/src/features/keystore/infrastructure/tauriKeystoreService.ts new file mode 100644 index 00000000..613bf7a0 --- /dev/null +++ b/src/features/keystore/infrastructure/tauriKeystoreService.ts @@ -0,0 +1,74 @@ +import { invoke } from '@tauri-apps/api/core'; +import { + KeystoreError, + type GeneratedKeystore, + type KeystoreService, +} from '../domain/keystore'; + +type Invoke = ( + command: string, + args?: Record, +) => Promise; +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; +function invalid(): never { + throw new KeystoreError( + 'invalid_response', + 'The keystore service returned an invalid response.', + ); +} + +export function createTauriKeystoreService( + call: Invoke = invoke, +): KeystoreService { + async function request(command: string, args: Record) { + try { + return await call(command, args); + } catch (error) { + if ( + isRecord(error) && + typeof error.code === 'string' && + typeof error.message === 'string' + ) + throw new KeystoreError( + error.code, + error.message, + typeof error.field === 'string' ? error.field : undefined, + ); + throw new KeystoreError( + 'unavailable', + 'The desktop operation is unavailable. Please retry.', + ); + } + } + return { + async choosePath(format) { + const value = await request('choose_keystore_path', { format }); + if (value !== null && (typeof value !== 'string' || !value)) invalid(); + return value as string | null; + }, + async generate(input) { + const value = await request('generate_keystore', { request: input }); + if ( + !isRecord(value) || + !['path', 'alias', 'expiresAt', 'sha1', 'sha256'].every( + (key) => typeof value[key] === 'string' && value[key] !== '', + ) || + value.format !== input.format || + value.path !== input.path || + value.alias !== input.alias || + !/^\d{4}-\d{2}-\d{2}$/.test(value.expiresAt as string) || + !/^([A-F0-9]{2}:){19}[A-F0-9]{2}$/.test(value.sha1 as string) || + !/^([A-F0-9]{2}:){31}[A-F0-9]{2}$/.test(value.sha256 as string) + ) + invalid(); + return value as unknown as GeneratedKeystore; + }, + async copy(text) { + await request('copy_keystore_text', { text }); + }, + async reveal(path) { + await request('reveal_keystore', { path }); + }, + }; +} diff --git a/src/features/keystore/presentation/KeystoreExplorerWorkspace.test.ts b/src/features/keystore/presentation/KeystoreExplorerWorkspace.test.ts new file mode 100644 index 00000000..2cb49d49 --- /dev/null +++ b/src/features/keystore/presentation/KeystoreExplorerWorkspace.test.ts @@ -0,0 +1,85 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import KeystoreExplorerWorkspace from './KeystoreExplorerWorkspace.vue'; +import { createDemoKeystoreExplorerService } from '../infrastructure/demoKeystoreExplorerService'; +import type { KeystoreReport } from '../domain/explorer'; + +describe('Keystore explorer', () => { + it('opens a store, checks key credentials independently and clears stale results on edits', async () => { + const wrapper = mount(KeystoreExplorerWorkspace, { + props: { service: createDemoKeystoreExplorerService(), demo: true }, + }); + await wrapper.get('#explorer-path').setValue('/demo/upload.jks'); + await wrapper.get('#explorer-password').setValue('demo-password'); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(wrapper.text()).toContain('store password and integrity verified'); + expect(wrapper.text()).toContain('Key password not checked'); + expect(wrapper.text()).toContain('CN=Demo signing identity'); + await wrapper.get('#explorer-alias').setValue('upload'); + await wrapper.get('#explorer-key-password').setValue('wrong'); + await wrapper.findAll('form')[1]!.trigger('submit'); + await flushPromises(); + expect(wrapper.text()).toContain('Key verification failed'); + await wrapper.get('#explorer-key-password').setValue('demo-password'); + expect(wrapper.text()).not.toContain('Key verification failed'); + await wrapper.findAll('form')[1]!.trigger('submit'); + await flushPromises(); + expect(wrapper.text()).toContain('Key unlocked β€” certificate matches'); + await wrapper.get('#explorer-password').setValue('wrong'); + expect(wrapper.find('article').exists()).toBe(false); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toContain('Could not verify'); + expect(wrapper.text()).not.toContain( + 'store password and integrity verified', + ); + }); + + it('preserves a report when the picker is cancelled and discards late results after input changes', async () => { + const service = createDemoKeystoreExplorerService(); + const wrapper = mount(KeystoreExplorerWorkspace, { props: { service } }); + await wrapper.get('#explorer-path').setValue('/demo/upload.jks'); + await wrapper.get('#explorer-password').setValue('demo-password'); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + service.choose = vi.fn().mockResolvedValue(null); + await wrapper + .findAll('button') + .find((b) => b.text() === 'Choose keystore')! + .trigger('click'); + await flushPromises(); + expect(wrapper.find('article').exists()).toBe(true); + let resolve!: (report: KeystoreReport) => void; + const pending = new Promise((r) => { + resolve = r; + }); + const result = await service.inspect({ + path: '', + password: 'demo-password', + keyAlias: null, + keyPassword: '', + }); + service.inspect = () => pending; + await wrapper.get('form').trigger('submit'); + await wrapper.get('#explorer-path').setValue('/different.jks'); + resolve(result); + await flushPromises(); + expect(wrapper.find('article').exists()).toBe(false); + }); + + it('shows empty stores and PKCS12 inventory limitations explicitly', async () => { + const service = createDemoKeystoreExplorerService(); + service.inspect = async () => ({ + format: 'pkcs12', + entries: [], + limitation: 'Multi-key inventory is not supported.', + }); + const wrapper = mount(KeystoreExplorerWorkspace, { props: { service } }); + await wrapper.get('#explorer-path').setValue('/empty.p12'); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(wrapper.text()).toContain('No entries found'); + expect(wrapper.text()).toContain('Multi-key inventory is not supported'); + }); +}); diff --git a/src/features/keystore/presentation/KeystoreExplorerWorkspace.vue b/src/features/keystore/presentation/KeystoreExplorerWorkspace.vue new file mode 100644 index 00000000..853b94bd --- /dev/null +++ b/src/features/keystore/presentation/KeystoreExplorerWorkspace.vue @@ -0,0 +1,285 @@ + + + diff --git a/src/features/keystore/presentation/KeystoreWorkspace.test.ts b/src/features/keystore/presentation/KeystoreWorkspace.test.ts new file mode 100644 index 00000000..c6aa4ccd --- /dev/null +++ b/src/features/keystore/presentation/KeystoreWorkspace.test.ts @@ -0,0 +1,237 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import KeystoreWorkspace from './KeystoreWorkspace.vue'; +import { createDemoKeystoreService } from '../infrastructure/demoKeystoreService'; +import { KeystoreError, type GeneratedKeystore } from '../domain/keystore'; + +function setup() { + const service = createDemoKeystoreService(); + const generate = vi.spyOn(service, 'generate'); + const copy = vi.spyOn(service, 'copy').mockResolvedValue(); + const reveal = vi.spyOn(service, 'reveal').mockResolvedValue(); + const wrapper = mount(KeystoreWorkspace, { props: { service } }); + return { wrapper, service, generate, copy, reveal }; +} +async function fill(wrapper: ReturnType['wrapper']) { + await wrapper.get('#keystore-path').setValue('/tmp/upload.jks'); + await wrapper.get('#store-password').setValue('store-password'); + await wrapper.get('#store-confirmation').setValue('store-password'); + await wrapper.get('#identity-commonName').setValue('Example Signing'); +} + +describe('Generate keystore workspace', () => { + it('generates with the shared password, displays the result and supports copying and revealing', async () => { + const { wrapper, generate, copy, reveal } = setup(); + await fill(wrapper); + await wrapper.get('#identity-country').setValue('fr'); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ + password: 'store-password', + keyPassword: 'store-password', + country: 'FR', + validityYears: 30, + alias: 'upload', + }), + ); + expect(wrapper.text()).toContain('Keystore generated successfully'); + await wrapper.get('[aria-label="Copy SHA-256"]').trigger('click'); + await flushPromises(); + expect(copy).toHaveBeenCalledWith(Array(32).fill('BB').join(':')); + await wrapper + .findAll('button') + .find((b) => b.text() === 'Show in folder')! + .trigger('click'); + expect(reveal).toHaveBeenCalledWith('/tmp/upload.jks'); + await wrapper.get('#key-alias').setValue('changed'); + expect(wrapper.text()).not.toContain('Keystore generated successfully'); + wrapper.unmount(); + }); + + it('shows, hides, copies and securely generates passwords with matching confirmations', async () => { + const { wrapper, copy } = setup(); + await wrapper + .get('[aria-label="Generate keystore password"]') + .trigger('click'); + const password = wrapper.get('#store-password'); + expect(password.element.value).toMatch(/^[A-Za-z0-9_-]{24}$/); + expect( + wrapper.get('#store-confirmation').element.value, + ).toBe(password.element.value); + expect(password.attributes('type')).toBe('password'); + await wrapper.get('[aria-label="Show keystore password"]').trigger('click'); + expect(password.attributes('type')).toBe('text'); + await wrapper.get('[aria-label="Hide keystore password"]').trigger('click'); + expect(password.attributes('type')).toBe('password'); + await wrapper.get('[aria-label="Copy keystore password"]').trigger('click'); + await flushPromises(); + expect(copy).toHaveBeenCalledWith(password.element.value); + expect(wrapper.text()).toContain('Copied'); + copy.mockRejectedValueOnce(new Error('clipboard failed')); + await wrapper.get('[aria-label="Copy confirm password"]').trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('Could not copy. Please retry.'); + await wrapper.get('input[type="checkbox"]').setValue(false); + await wrapper.get('[aria-label="Generate key password"]').trigger('click'); + expect( + wrapper.get('#key-confirmation').element.value, + ).toBe(wrapper.get('#key-password').element.value); + expect( + wrapper.find('[aria-label="Generate confirm key password"]').exists(), + ).toBe(false); + wrapper.unmount(); + }); + + it('validates confirmations and a separate JKS key password before submitting', async () => { + const { wrapper, generate } = setup(); + await fill(wrapper); + await wrapper.get('#store-confirmation').setValue('different'); + await wrapper.get('input[type="checkbox"]').setValue(false); + await wrapper.get('#key-password').setValue('short'); + await wrapper.get('form').trigger('submit'); + expect(generate).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Passwords do not match.'); + expect(wrapper.get('#key-password').attributes('aria-invalid')).toBe( + 'true', + ); + await wrapper.get('#store-confirmation').setValue('store-password'); + await wrapper.get('#key-password').setValue('key-password'); + await wrapper.get('#key-confirmation').setValue('key-password'); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ + password: 'store-password', + keyPassword: 'key-password', + }), + ); + wrapper.unmount(); + }); + + it('switches PKCS12 to a shared password and updates the file extension', async () => { + const { wrapper, generate } = setup(); + await fill(wrapper); + await wrapper.get('input[type="checkbox"]').setValue(false); + await wrapper.get('#keystore-format').trigger('click'); + await wrapper + .get('[role="option"][id="keystore-format-option-1"]') + .trigger('click'); + expect(wrapper.get('#keystore-path').element.value).toBe( + '/tmp/upload.p12', + ); + expect(wrapper.find('#key-password').exists()).toBe(false); + expect( + wrapper.get('input[type="checkbox"]').element.disabled, + ).toBe(true); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ + format: 'PKCS12', + keyPassword: 'store-password', + }), + ); + wrapper.unmount(); + }); + + it('reuses the store password when the optional key password is cleared', async () => { + const { wrapper, generate } = setup(); + await fill(wrapper); + await wrapper.get('input[type="checkbox"]').setValue(false); + await wrapper.get('#key-password').setValue('separate-password'); + await wrapper.get('#key-confirmation').setValue('separate-password'); + await wrapper.get('#key-password').setValue(''); + expect(wrapper.get('#key-password-description').text()).toContain( + 'Leave empty to use the keystore password', + ); + expect( + wrapper.get('#key-confirmation').element.value, + ).toBe(''); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ + password: 'store-password', + keyPassword: 'store-password', + }), + ); + wrapper.unmount(); + }); + + it('preserves the path when browsing is cancelled and handles native errors and extensionless paths', async () => { + const { wrapper, service } = setup(); + await fill(wrapper); + const choose = vi + .spyOn(service, 'choosePath') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('/tmp/new-store') + .mockRejectedValueOnce( + new KeystoreError('native_operation_failed', 'Dialog unavailable.'), + ); + const browse = wrapper + .findAll('button') + .find((b) => b.text() === 'Browse…')!; + await browse.trigger('click'); + await flushPromises(); + expect(wrapper.get('#keystore-path').element.value).toBe( + '/tmp/upload.jks', + ); + await browse.trigger('click'); + await flushPromises(); + expect(wrapper.get('#keystore-path').element.value).toBe( + '/tmp/new-store.jks', + ); + await browse.trigger('click'); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toBe('Dialog unavailable.'); + expect(choose).toHaveBeenCalledWith('JKS'); + wrapper.unmount(); + }); + + it('blocks duplicate submissions and permits retry after a failed generation', async () => { + const { wrapper, generate } = setup(); + await fill(wrapper); + let reject!: (error: Error) => void; + generate.mockImplementationOnce( + () => + new Promise((_, fail) => { + reject = fail; + }), + ); + await wrapper.get('form').trigger('submit'); + await wrapper.get('form').trigger('submit'); + expect(generate).toHaveBeenCalledTimes(1); + expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined(); + expect(wrapper.text()).toContain('Generating keystore…'); + reject(new KeystoreError('already_exists', 'A file already exists.')); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toBe('A file already exists.'); + expect(wrapper.get('#store-password').element.value).toBe( + 'store-password', + ); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(wrapper.text()).toContain('Keystore generated successfully'); + wrapper.unmount(); + }); + + it('makes browser-only and simulated operation explicit', async () => { + const unavailable = mount(KeystoreWorkspace); + expect(unavailable.get('[role="alert"]').text()).toContain( + 'requires the desktop application', + ); + expect(unavailable.get('fieldset').attributes('disabled')).toBeDefined(); + unavailable.unmount(); + const { wrapper } = setup(); + await wrapper.setProps({ demo: true }); + await fill(wrapper); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(wrapper.get('[role="note"]').text()).toContain( + 'No key or file is created', + ); + expect(wrapper.text()).toContain('Simulated keystore generated'); + wrapper.unmount(); + }); +}); diff --git a/src/features/keystore/presentation/KeystoreWorkspace.vue b/src/features/keystore/presentation/KeystoreWorkspace.vue new file mode 100644 index 00000000..618e519c --- /dev/null +++ b/src/features/keystore/presentation/KeystoreWorkspace.vue @@ -0,0 +1,488 @@ + + + diff --git a/src/features/settings/domain/settings.ts b/src/features/settings/domain/settings.ts new file mode 100644 index 00000000..2350e13e --- /dev/null +++ b/src/features/settings/domain/settings.ts @@ -0,0 +1,23 @@ +export interface AppSettings { + crashReportingEnabled: boolean; +} + +export type ProjectLink = 'repository' | 'issue' | 'changelog'; + +export interface SettingsService { + load(): Promise; + setCrashReporting(enabled: boolean): Promise; + openProjectLink(link: ProjectLink): Promise; + openLogsFolder(): Promise; + checkForUpdates(): Promise; +} + +export class SettingsError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'SettingsError'; + } +} diff --git a/src/features/settings/infrastructure/demoSettingsService.ts b/src/features/settings/infrastructure/demoSettingsService.ts new file mode 100644 index 00000000..e4695a82 --- /dev/null +++ b/src/features/settings/infrastructure/demoSettingsService.ts @@ -0,0 +1,29 @@ +import { SettingsError, type SettingsService } from '../domain/settings'; + +export function createDemoSettingsService(fail = false): SettingsService { + let crashReportingEnabled = false; + function check() { + if (fail) + throw new SettingsError('demo_failure', 'Simulated settings failure.'); + } + return { + async load() { + check(); + return { crashReportingEnabled }; + }, + async setCrashReporting(enabled) { + check(); + crashReportingEnabled = enabled; + return { crashReportingEnabled }; + }, + async openProjectLink() { + check(); + }, + async openLogsFolder() { + check(); + }, + async checkForUpdates() { + check(); + }, + }; +} diff --git a/src/features/settings/infrastructure/tauriSettingsService.test.ts b/src/features/settings/infrastructure/tauriSettingsService.test.ts new file mode 100644 index 00000000..63cf4265 --- /dev/null +++ b/src/features/settings/infrastructure/tauriSettingsService.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTauriSettingsService } from './tauriSettingsService'; + +describe('Settings IPC', () => { + it('sends preference changes and fixed destination identifiers', async () => { + const call = vi.fn().mockResolvedValue({ crashReportingEnabled: true }); + const service = createTauriSettingsService(call); + expect(await service.load()).toEqual({ crashReportingEnabled: true }); + await service.setCrashReporting(false); + await service.openProjectLink('issue'); + await service.openProjectLink('repository'); + await service.openLogsFolder(); + await service.checkForUpdates(); + expect(call.mock.calls).toEqual([ + ['load_settings', undefined], + ['set_crash_reporting', { enabled: false }], + ['open_project_link', { link: 'issue' }], + ['open_project_link', { link: 'repository' }], + ['open_logs_folder', undefined], + ['check_app_updates', undefined], + ]); + }); + it('propagates log folder failures for display and retry', async () => { + const service = createTauriSettingsService( + vi.fn().mockRejectedValue({ + code: 'open_failed', + message: 'Could not open logs.', + }), + ); + await expect(service.openLogsFolder()).rejects.toMatchObject({ + code: 'open_failed', + message: 'Could not open logs.', + }); + }); + it('preserves unavailable updater errors instead of claiming no updates exist', async () => { + const service = createTauriSettingsService(async () => { + throw { + code: 'updater_unavailable', + message: 'Install a macOS release.', + }; + }); + await expect(service.checkForUpdates()).rejects.toMatchObject({ + code: 'updater_unavailable', + message: 'Install a macOS release.', + }); + }); + it.each([null, {}, { crashReportingEnabled: 'true' }, false])( + 'rejects malformed preferences: %j', + async (response) => { + const service = createTauriSettingsService(async () => response); + await expect(service.load()).rejects.toMatchObject({ + code: 'invalid_response', + }); + await expect(service.setCrashReporting(true)).rejects.toMatchObject({ + code: 'invalid_response', + }); + }, + ); + it('preserves structured failures and normalizes unexpected ones', async () => { + const call = vi + .fn() + .mockRejectedValueOnce({ + code: 'storage_failed', + message: 'Cannot save.', + }) + .mockRejectedValueOnce('unknown'); + const service = createTauriSettingsService(call); + await expect(service.setCrashReporting(true)).rejects.toMatchObject({ + code: 'storage_failed', + message: 'Cannot save.', + }); + await expect(service.openProjectLink('issue')).rejects.toMatchObject({ + code: 'unavailable', + }); + }); +}); diff --git a/src/features/settings/infrastructure/tauriSettingsService.ts b/src/features/settings/infrastructure/tauriSettingsService.ts new file mode 100644 index 00000000..af276e5e --- /dev/null +++ b/src/features/settings/infrastructure/tauriSettingsService.ts @@ -0,0 +1,68 @@ +import { invoke } from '@tauri-apps/api/core'; +import { + SettingsError, + type AppSettings, + type SettingsService, +} from '../domain/settings'; + +type Invoke = ( + command: string, + args?: Record, +) => Promise; + +export function createTauriSettingsService( + call: Invoke = invoke, +): SettingsService { + async function request(command: string, args?: Record) { + try { + return await call(command, args); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + 'message' in error && + typeof error.code === 'string' && + typeof error.message === 'string' + ) { + throw new SettingsError(error.code, error.message); + } + throw new SettingsError( + 'unavailable', + 'The settings operation failed. Please retry.', + ); + } + } + async function settings( + command: string, + args?: Record, + ): Promise { + const result = await request(command, args); + if ( + typeof result !== 'object' || + result === null || + !('crashReportingEnabled' in result) || + typeof result.crashReportingEnabled !== 'boolean' + ) { + throw new SettingsError( + 'invalid_response', + 'The settings service returned an invalid response.', + ); + } + return { crashReportingEnabled: result.crashReportingEnabled }; + } + return { + load: () => settings('load_settings'), + setCrashReporting: (enabled) => + settings('set_crash_reporting', { enabled }), + async openProjectLink(link) { + await request('open_project_link', { link }); + }, + async openLogsFolder() { + await request('open_logs_folder'); + }, + async checkForUpdates() { + await request('check_app_updates'); + }, + }; +} diff --git a/src/features/settings/presentation/SettingsWorkspace.test.ts b/src/features/settings/presentation/SettingsWorkspace.test.ts new file mode 100644 index 00000000..268e9bc9 --- /dev/null +++ b/src/features/settings/presentation/SettingsWorkspace.test.ts @@ -0,0 +1,176 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import SettingsWorkspace from './SettingsWorkspace.vue'; +import { createDemoSettingsService } from '../infrastructure/demoSettingsService'; +import { SettingsError } from '../domain/settings'; + +describe('Application settings', () => { + it('saves both choices and restores the preference after navigation', async () => { + const service = createDemoSettingsService(); + let wrapper = mount(SettingsWorkspace, { props: { service } }); + await flushPromises(); + expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe( + 'false', + ); + await wrapper.get('[role="switch"]').trigger('click'); + await flushPromises(); + wrapper.unmount(); + wrapper = mount(SettingsWorkspace, { props: { service } }); + await flushPromises(); + expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe( + 'true', + ); + await wrapper.get('[role="switch"]').trigger('click'); + await flushPromises(); + expect((await service.load()).crashReportingEnabled).toBe(false); + wrapper.unmount(); + }); + it('keeps the saved choice on write failure and allows retry', async () => { + const service = createDemoSettingsService(); + const save = vi + .spyOn(service, 'setCrashReporting') + .mockRejectedValueOnce( + new SettingsError('storage_failed', 'Could not save settings.'), + ); + const wrapper = mount(SettingsWorkspace, { props: { service } }); + await flushPromises(); + await wrapper.get('[role="switch"]').trigger('click'); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toContain('Could not save'); + expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe( + 'false', + ); + await wrapper.get('[role="switch"]').trigger('click'); + await flushPromises(); + expect(save).toHaveBeenCalledTimes(2); + expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe( + 'true', + ); + wrapper.unmount(); + }); + it('disables changes while loading and saving', async () => { + const service = createDemoSettingsService(); + let finish!: (value: { crashReportingEnabled: boolean }) => void; + service.setCrashReporting = () => + new Promise((resolve) => { + finish = resolve; + }); + const wrapper = mount(SettingsWorkspace, { props: { service } }); + expect(wrapper.get('[role="switch"]').attributes('disabled')).toBeDefined(); + await flushPromises(); + await wrapper.get('[role="switch"]').trigger('click'); + expect(wrapper.get('[role="switch"]').attributes('disabled')).toBeDefined(); + finish({ crashReportingEnabled: true }); + await flushPromises(); + expect( + wrapper.get('[role="switch"]').attributes('disabled'), + ).toBeUndefined(); + wrapper.unmount(); + }); + it('recovers from a load failure and handles GitHub actions and failures', async () => { + const service = createDemoSettingsService(); + vi.spyOn(service, 'load').mockRejectedValueOnce( + new SettingsError('storage_failed', 'Could not load settings.'), + ); + const open = vi + .spyOn(service, 'openProjectLink') + .mockRejectedValueOnce( + new SettingsError('open_failed', 'Could not open GitHub.'), + ); + const wrapper = mount(SettingsWorkspace, { props: { service } }); + await flushPromises(); + expect(wrapper.get('[role="switch"]').attributes('disabled')).toBeDefined(); + await wrapper.get('[role="alert"] button').trigger('click'); + await flushPromises(); + const button = (label: string) => + wrapper.findAll('button').find((entry) => entry.text() === label)!; + await button('Create GitHub issue').trigger('click'); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toContain( + 'Could not open GitHub', + ); + await button('View on GitHub').trigger('click'); + await flushPromises(); + await button('View changelog').trigger('click'); + await flushPromises(); + expect(open.mock.calls).toEqual([['issue'], ['repository'], ['changelog']]); + expect(button('Open logs folder').attributes('disabled')).toBeUndefined(); + expect(button('Check for updates').attributes('disabled')).toBeUndefined(); + wrapper.unmount(); + }); + it('opens the logs folder, blocks duplicate requests and allows retry after failure', async () => { + const service = createDemoSettingsService(); + let reject!: (error: unknown) => void; + const open = vi.spyOn(service, 'openLogsFolder').mockImplementationOnce( + () => + new Promise((_, fail) => { + reject = fail; + }), + ); + const wrapper = mount(SettingsWorkspace, { + props: { service, demo: true }, + }); + await flushPromises(); + const button = wrapper + .findAll('button') + .find((entry) => entry.text() === 'Open logs folder')!; + await button.trigger('click'); + expect(button.attributes('disabled')).toBeDefined(); + await button.trigger('click'); + expect(open).toHaveBeenCalledTimes(1); + reject(new SettingsError('open_failed', 'Could not open logs.')); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toContain( + 'Could not open logs.', + ); + await button.trigger('click'); + await flushPromises(); + expect(wrapper.get('[role="status"]').text()).toContain('simulated'); + expect(open).toHaveBeenCalledTimes(2); + wrapper.unmount(); + }); + it('requires the native service to open logs', () => { + const wrapper = mount(SettingsWorkspace); + const button = wrapper + .findAll('button') + .find((entry) => entry.text() === 'Open logs folder')!; + expect(button.attributes('disabled')).toBeDefined(); + wrapper.unmount(); + }); + it('blocks duplicate update checks, reports unavailable builds and permits retry', async () => { + const service = createDemoSettingsService(); + let reject!: (error: unknown) => void; + const check = vi.spyOn(service, 'checkForUpdates').mockImplementationOnce( + () => + new Promise((_, fail) => { + reject = fail; + }), + ); + const wrapper = mount(SettingsWorkspace, { + props: { service, demo: true }, + }); + await flushPromises(); + const button = wrapper + .findAll('button') + .find((entry) => entry.text() === 'Check for updates')!; + await button.trigger('click'); + expect(button.attributes('disabled')).toBeDefined(); + await button.trigger('click'); + expect(check).toHaveBeenCalledTimes(1); + reject( + new SettingsError( + 'updater_unavailable', + 'Updates require an installed macOS release.', + ), + ); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toContain( + 'installed macOS release', + ); + await button.trigger('click'); + await flushPromises(); + expect(wrapper.get('[role="status"]').text()).toContain('simulated'); + expect(check).toHaveBeenCalledTimes(2); + wrapper.unmount(); + }); +}); diff --git a/src/features/settings/presentation/SettingsWorkspace.vue b/src/features/settings/presentation/SettingsWorkspace.vue new file mode 100644 index 00000000..789958cf --- /dev/null +++ b/src/features/settings/presentation/SettingsWorkspace.vue @@ -0,0 +1,297 @@ + + + diff --git a/src/features/signing/domain/signing.ts b/src/features/signing/domain/signing.ts new file mode 100644 index 00000000..afef1bb6 --- /dev/null +++ b/src/features/signing/domain/signing.ts @@ -0,0 +1,31 @@ +export interface SignRequest { + apkPath: string; + keystorePath: string; + alias: string; + password: string; + keyPassword: string; +} + +export interface SigningService { + chooseApk(): Promise; + chooseKeystore(): Promise; + /** Signs first, then opens the native save dialog. Null means cancellation. */ + signAndSave(request: SignRequest): Promise; + copy(text: string): Promise; + reveal(path: string): Promise; +} + +export class SigningError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'SigningError'; + } +} + +export function signedFilename(path: string): string { + const filename = path.split(/[\\/]/).pop() ?? ''; + return `${filename.replace(/\.apk$/i, '')}-signed.apk`; +} diff --git a/src/features/signing/infrastructure/demoSigningService.ts b/src/features/signing/infrastructure/demoSigningService.ts new file mode 100644 index 00000000..759daefd --- /dev/null +++ b/src/features/signing/infrastructure/demoSigningService.ts @@ -0,0 +1,26 @@ +import { + SigningError, + signedFilename, + type SigningService, +} from '../domain/signing'; + +export function createDemoSigningService(fail = false): SigningService { + return { + async chooseApk() { + return '/demo/example.apk'; + }, + async chooseKeystore() { + return '/demo/upload.jks'; + }, + async signAndSave(request) { + if (fail) + throw new SigningError( + 'invalid_keystore', + 'Simulated failure: could not unlock the signing key. Check the alias and passwords.', + ); + return `/demo/${signedFilename(request.apkPath)}`; + }, + async copy() {}, + async reveal() {}, + }; +} diff --git a/src/features/signing/infrastructure/tauriSigningService.test.ts b/src/features/signing/infrastructure/tauriSigningService.test.ts new file mode 100644 index 00000000..52ee67e0 --- /dev/null +++ b/src/features/signing/infrastructure/tauriSigningService.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTauriSigningService } from './tauriSigningService'; +import { createDemoSigningService } from './demoSigningService'; +import { SigningError, signedFilename } from '../domain/signing'; + +const input = { + apkPath: '/apps/my.app.apk', + keystorePath: '/keys/upload.jks', + alias: 'upload', + password: 'store password', + keyPassword: 'key password', +}; + +describe('APK signing service', () => { + it('passes credentials to the native workflow and accepts save cancellation', async () => { + const call = vi + .fn() + .mockResolvedValueOnce('/output/my.app-signed.apk') + .mockResolvedValueOnce(null); + const service = createTauriSigningService(call); + expect(await service.signAndSave(input)).toBe('/output/my.app-signed.apk'); + expect(call).toHaveBeenCalledWith('sign_apk', { request: input }); + expect(await service.signAndSave(input)).toBeNull(); + }); + it('validates path responses for signing and both pickers', async () => { + for (const value of [undefined, '', 7, {}, { path: '/output.apk' }]) { + const service = createTauriSigningService( + vi.fn().mockResolvedValue(value), + ); + await expect(service.signAndSave(input)).rejects.toMatchObject({ + code: 'invalid_response', + }); + await expect(service.chooseApk()).rejects.toMatchObject({ + code: 'invalid_response', + }); + await expect(service.chooseKeystore()).rejects.toMatchObject({ + code: 'invalid_response', + }); + } + }); + it('preserves structured errors and hides unexpected native failures', async () => { + const error = { + code: 'invalid_keystore', + message: 'Check the alias and passwords.', + }; + await expect( + createTauriSigningService(vi.fn().mockRejectedValue(error)).signAndSave( + input, + ), + ).rejects.toMatchObject(error); + await expect( + createTauriSigningService( + vi.fn().mockRejectedValue('internal details'), + ).signAndSave(input), + ).rejects.toMatchObject({ code: 'unavailable' }); + }); + it('uses scoped commands for file selection and result actions', async () => { + const call = vi.fn().mockResolvedValue(null); + const service = createTauriSigningService(call); + expect(await service.chooseApk()).toBeNull(); + expect(await service.chooseKeystore()).toBeNull(); + await service.copy('value'); + await service.reveal('/output.apk'); + expect(call.mock.calls).toEqual([ + ['choose_apk_path', undefined], + ['choose_signing_keystore', undefined], + ['copy_keystore_text', { text: 'value' }], + ['reveal_keystore', { path: '/output.apk' }], + ]); + }); + it('keeps demo success and failure explicit and handles platform filenames', async () => { + expect(signedFilename('C:\\apps\\my.app.APK')).toBe('my.app-signed.apk'); + expect(signedFilename('/apps/example.apk')).toBe('example-signed.apk'); + expect(await createDemoSigningService().signAndSave(input)).toBe( + '/demo/my.app-signed.apk', + ); + await expect( + createDemoSigningService(true).signAndSave(input), + ).rejects.toBeInstanceOf(SigningError); + }); +}); diff --git a/src/features/signing/infrastructure/tauriSigningService.ts b/src/features/signing/infrastructure/tauriSigningService.ts new file mode 100644 index 00000000..e0357752 --- /dev/null +++ b/src/features/signing/infrastructure/tauriSigningService.ts @@ -0,0 +1,55 @@ +import { invoke } from '@tauri-apps/api/core'; +import { SigningError, type SigningService } from '../domain/signing'; + +type Invoke = ( + command: string, + args?: Record, +) => Promise; + +export function createTauriSigningService( + call: Invoke = invoke, +): SigningService { + async function request(command: string, args?: Record) { + try { + return await call(command, args); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + 'message' in error && + typeof error.code === 'string' && + typeof error.message === 'string' + ) { + throw new SigningError(error.code, error.message); + } + throw new SigningError( + 'unavailable', + 'The signing operation is unavailable. Please retry.', + ); + } + } + async function path( + command: string, + args?: Record, + ): Promise { + const value = await request(command, args); + if (value === null || (typeof value === 'string' && value.length > 0)) + return value; + throw new SigningError( + 'invalid_response', + 'The signing service returned an invalid response.', + ); + } + return { + chooseApk: () => path('choose_apk_path'), + chooseKeystore: () => path('choose_signing_keystore'), + signAndSave: (input) => path('sign_apk', { request: input }), + async copy(text) { + await request('copy_keystore_text', { text }); + }, + async reveal(value) { + await request('reveal_keystore', { path: value }); + }, + }; +} diff --git a/src/features/signing/presentation/SigningWorkspace.test.ts b/src/features/signing/presentation/SigningWorkspace.test.ts new file mode 100644 index 00000000..586b6762 --- /dev/null +++ b/src/features/signing/presentation/SigningWorkspace.test.ts @@ -0,0 +1,131 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import SigningWorkspace from './SigningWorkspace.vue'; +import { createDemoSigningService } from '../infrastructure/demoSigningService'; +import { SigningError } from '../domain/signing'; + +async function fill(wrapper: ReturnType) { + await wrapper.get('#signing-apk').setValue('/apps/example.apk'); + await wrapper.get('#signing-keystore').setValue('/keys/upload.jks'); + await wrapper.get('#signing-alias').setValue('upload'); + await wrapper.get('#signing-password').setValue('store-password'); +} + +describe('APK signing workspace', () => { + it('validates required inputs without invoking signing', async () => { + const service = { ...createDemoSigningService(), signAndSave: vi.fn() }; + const wrapper = mount(SigningWorkspace, { props: { service } }); + await wrapper.get('form').trigger('submit'); + expect(service.signAndSave).not.toHaveBeenCalled(); + expect(wrapper.get('[role="alert"]').text()).toContain('key alias'); + wrapper.unmount(); + }); + it('accepts typed paths, passes credentials and prevents duplicate signing', async () => { + let finish!: (value: string | null) => void; + const service = { + ...createDemoSigningService(), + signAndSave: vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + reveal: vi.fn(), + }; + const wrapper = mount(SigningWorkspace, { props: { service } }); + await fill(wrapper); + await wrapper.get('#signing-key-password').setValue('separate-password'); + expect(wrapper.text()).toContain('example-signed.apk'); + await wrapper.get('form').trigger('submit'); + await wrapper.get('form').trigger('submit'); + expect(service.signAndSave).toHaveBeenCalledTimes(1); + expect(service.signAndSave).toHaveBeenCalledWith({ + apkPath: '/apps/example.apk', + keystorePath: '/keys/upload.jks', + alias: 'upload', + password: 'store-password', + keyPassword: 'separate-password', + }); + expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined(); + expect(wrapper.text()).not.toContain('Signed APK saved'); + finish('/output/example-signed.apk'); + await flushPromises(); + expect(wrapper.get('[role="status"]').text()).toContain( + '/output/example-signed.apk', + ); + await wrapper.get('[role="status"] button').trigger('click'); + expect(service.reveal).toHaveBeenCalledWith('/output/example-signed.apk'); + wrapper.unmount(); + }); + it('reports cancelled saving separately and allows retry after a signing error', async () => { + const signAndSave = vi + .fn() + .mockResolvedValueOnce(null) + .mockRejectedValueOnce( + new SigningError('invalid_keystore', 'Wrong keystore password.'), + ) + .mockResolvedValueOnce('/output/example-signed.apk'); + const wrapper = mount(SigningWorkspace, { + props: { service: { ...createDemoSigningService(), signAndSave } }, + }); + await fill(wrapper); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(wrapper.get('[role="status"]').text()).toContain('Save cancelled'); + expect(wrapper.text()).not.toContain('Signed APK saved'); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toBe( + 'Wrong keystore password.', + ); + expect(wrapper.text()).not.toContain('Save cancelled'); + await wrapper.get('#signing-password').setValue('corrected'); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); + expect(wrapper.text()).toContain('Signed APK saved'); + wrapper.unmount(); + }); + it('keeps paths when pickers are cancelled and exposes selection failures', async () => { + const chooseApk = vi + .fn() + .mockResolvedValueOnce('/chosen/app.apk') + .mockResolvedValueOnce(null) + .mockRejectedValueOnce( + new SigningError('native_operation_failed', 'Could not open dialog.'), + ); + const wrapper = mount(SigningWorkspace, { + props: { service: { ...createDemoSigningService(), chooseApk } }, + }); + const button = wrapper + .findAll('button') + .find((b) => b.text() === 'Choose APK')!; + await button.trigger('click'); + await flushPromises(); + expect(wrapper.get('#signing-apk').element.value).toBe( + '/chosen/app.apk', + ); + await button.trigger('click'); + await flushPromises(); + expect(wrapper.get('#signing-apk').element.value).toBe( + '/chosen/app.apk', + ); + await button.trigger('click'); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toBe('Could not open dialog.'); + wrapper.unmount(); + }); + it('requires the native runtime and clearly labels explicit simulations', () => { + const unavailable = mount(SigningWorkspace); + expect(unavailable.get('[role="alert"]').text()).toContain( + 'desktop application', + ); + expect(unavailable.get('fieldset').attributes('disabled')).toBeDefined(); + unavailable.unmount(); + const demo = mount(SigningWorkspace, { + props: { service: createDemoSigningService(), demo: true }, + }); + expect(demo.get('[role="note"]').text()).toContain('No file is created'); + demo.unmount(); + }); +}); diff --git a/src/features/signing/presentation/SigningWorkspace.vue b/src/features/signing/presentation/SigningWorkspace.vue new file mode 100644 index 00000000..777ea8e2 --- /dev/null +++ b/src/features/signing/presentation/SigningWorkspace.vue @@ -0,0 +1,232 @@ + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..0db08a6d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,85 @@ +import { createApp } from 'vue'; +import { createTauriKeystoreExplorerService } from './features/keystore/infrastructure/tauriKeystoreExplorerService'; +import { createDemoKeystoreExplorerService } from './features/keystore/infrastructure/demoKeystoreExplorerService'; +import { initializeLocalLogging } from './shared/infrastructure/localLogging'; +import { initializeCrashReporting } from './shared/infrastructure/crashReporting'; +import { createTauriSettingsService } from './features/settings/infrastructure/tauriSettingsService'; +import { createDemoSettingsService } from './features/settings/infrastructure/demoSettingsService'; +import { createTauriSigningService } from './features/signing/infrastructure/tauriSigningService'; +import { createDemoSigningService } from './features/signing/infrastructure/demoSigningService'; +import { createTauriApkService } from './features/apk/infrastructure/tauriApkService'; +import { createDemoApkService } from './features/apk/infrastructure/demoApkService'; +import { createTauriKeystoreService } from './features/keystore/infrastructure/tauriKeystoreService'; +import { createDemoKeystoreService } from './features/keystore/infrastructure/demoKeystoreService'; +import App from './App.vue'; +import './styles.css'; +import { isTauri } from '@tauri-apps/api/core'; +import { createTauriDeviceService } from './features/devices/infrastructure/tauriDeviceService'; +import { createDemoDeviceService } from './features/devices/infrastructure/demoDeviceService'; +import { createTauriAdbService } from './features/adb/infrastructure/tauriAdbService'; +import { createDemoAdbService } from './features/adb/infrastructure/demoAdbService'; +import { + DeviceDiscoveryError, + type DeviceService, +} from './features/devices/domain/devices'; + +const scenario = new URLSearchParams(window.location.search).get('demo'); +const demo = + import.meta.env.DEV && + (scenario === 'devices' || scenario === 'empty' || scenario === 'error'); +const unavailable: DeviceService = { + async list() { + throw new DeviceDiscoveryError( + 'native_required', + 'USB discovery requires the desktop application. For a browser demo, open /?demo=devices with the development server.', + ); + }, +}; +const deviceService = demo + ? createDemoDeviceService(scenario) + : isTauri() + ? createTauriDeviceService() + : unavailable; + +const adbService = demo + ? createDemoAdbService() + : isTauri() + ? createTauriAdbService() + : undefined; +const keystoreService = demo + ? createDemoKeystoreService(scenario === 'error') + : isTauri() + ? createTauriKeystoreService() + : undefined; +const apkService = demo + ? createDemoApkService(scenario === 'error') + : isTauri() + ? createTauriApkService() + : undefined; +const app = createApp(App, { + keystoreExplorerService: demo + ? createDemoKeystoreExplorerService(scenario === 'error') + : isTauri() + ? createTauriKeystoreExplorerService() + : undefined, + settingsService: demo + ? createDemoSettingsService(scenario === 'error') + : isTauri() + ? createTauriSettingsService() + : undefined, + signingService: demo + ? createDemoSigningService(scenario === 'error') + : isTauri() + ? createTauriSigningService() + : undefined, + deviceService, + adbService, + keystoreService, + apkService, + demo, +}); +if (isTauri() && !demo) { + initializeCrashReporting(app); + initializeLocalLogging(app); +} +app.mount('#app'); diff --git a/src/shared/infrastructure/crashReporting.test.ts b/src/shared/infrastructure/crashReporting.test.ts new file mode 100644 index 00000000..30c3e9b7 --- /dev/null +++ b/src/shared/infrastructure/crashReporting.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createApp } from 'vue'; +import * as Sentry from '@sentry/vue'; +import { + createNativeErrorForwarder, + initializeCrashReporting, +} from './crashReporting'; + +describe('native crash reporting bridge', () => { + it('forwards error details and consumes the browser event', async () => { + const send = vi.fn().mockResolvedValue(undefined); + const event = { + type: undefined, + exception: { values: [{ type: 'Error', value: 'Failure' }] }, + }; + expect(await createNativeErrorForwarder(send)(event)).toBeNull(); + expect(JSON.parse(send.mock.calls[0]![0] as string)).toEqual(event); + }); + + it('does not create an unhandled rejection when IPC is unavailable', async () => { + const send = vi.fn().mockRejectedValue(new Error('Unavailable')); + expect( + await createNativeErrorForwarder(send)({ type: undefined }), + ).toBeNull(); + }); + + it('captures Vue failures without attaching component props', async () => { + const send = vi.fn().mockResolvedValue(undefined); + const app = createApp({}); + app.config.errorHandler = vi.fn(); + initializeCrashReporting(app, send); + try { + app.config.errorHandler?.(new Error('Vue failure'), null, 'render'); + await vi.waitFor(() => expect(send).toHaveBeenCalled()); + const event = JSON.parse(send.mock.calls[0]![0] as string); + expect(event.exception.values[0].value).toBe('Vue failure'); + expect(event.contexts?.vue?.propsData).toBeUndefined(); + } finally { + await Sentry.close(); + } + }); +}); diff --git a/src/shared/infrastructure/crashReporting.ts b/src/shared/infrastructure/crashReporting.ts new file mode 100644 index 00000000..2a878647 --- /dev/null +++ b/src/shared/infrastructure/crashReporting.ts @@ -0,0 +1,46 @@ +import type { App } from 'vue'; +import * as Sentry from '@sentry/vue'; +import { invoke } from '@tauri-apps/api/core'; + +type SendError = (event: string) => Promise; + +/** The native client owns the DSN, release, transport and persisted consent. */ +export function initializeCrashReporting( + app: App, + send: SendError = (event) => invoke('capture_frontend_error', { event }), +) { + Sentry.init({ + app, + // Required by the browser SDK; no request is made to this placeholder. + dsn: 'https://public@localhost/1', + sendClientReports: false, + transport: () => ({ + send: async () => ({}), + flush: async () => true, + }), + dataCollection: { + userInfo: false, + cookies: false, + httpHeaders: false, + httpBodies: [], + urlQueryParams: false, + stackFrameVariables: false, + }, + defaultIntegrations: false, + integrations: [Sentry.globalHandlersIntegration(), Sentry.vueIntegration()], + attachProps: false, + beforeSend: createNativeErrorForwarder(send), + }); +} + +/** Always consume the event locally, including when the native bridge fails. */ +export function createNativeErrorForwarder(send: SendError) { + return async (event: Sentry.ErrorEvent): Promise => { + try { + await send(JSON.stringify(event)); + } catch { + // Reporting must not produce another unhandled error. + } + return null; + }; +} diff --git a/src/shared/infrastructure/localLogging.test.ts b/src/shared/infrastructure/localLogging.test.ts new file mode 100644 index 00000000..2678284b --- /dev/null +++ b/src/shared/infrastructure/localLogging.test.ts @@ -0,0 +1,51 @@ +import { createApp } from 'vue'; +import { describe, expect, it, vi } from 'vitest'; +import { createLocalLogger, initializeLocalLogging } from './localLogging'; + +const createTestApp = () => createApp({}); + +describe('Local diagnostics', () => { + it('sends only the event identifier across IPC', async () => { + const call = vi.fn().mockResolvedValue(undefined); + await createLocalLogger(call).error('vue_error'); + expect(call).toHaveBeenCalledWith('log_frontend_event', { + event: 'vue_error', + }); + }); + + it('preserves Vue reporting, excludes payloads and removes listeners', async () => { + const app = createTestApp(); + const previous = vi.fn(); + app.config.errorHandler = previous; + const logger = { error: vi.fn().mockResolvedValue(undefined) }; + const events = new EventTarget(); + const dispose = initializeLocalLogging(app, logger, events); + const error = new Error('password=secret'); + app.config.errorHandler!(error, null, 'render'); + events.dispatchEvent(new Event('error')); + events.dispatchEvent(new Event('unhandledrejection')); + expect(previous).toHaveBeenCalledWith(error, null, 'render'); + expect(logger.error.mock.calls).toEqual([ + ['vue_error'], + ['unhandled_error'], + ['unhandled_rejection'], + ]); + dispose(); + events.dispatchEvent(new Event('error')); + expect(logger.error).toHaveBeenCalledTimes(3); + expect(app.config.errorHandler).toBe(previous); + }); + + it('contains logging failures without causing unhandled rejections', async () => { + const app = createTestApp(); + const logger = { + error: vi.fn().mockRejectedValue(new Error('IPC unavailable')), + }; + const events = new EventTarget(); + const dispose = initializeLocalLogging(app, logger, events); + events.dispatchEvent(new Event('error')); + await Promise.resolve(); + expect(logger.error).toHaveBeenCalledTimes(1); + dispose(); + }); +}); diff --git a/src/shared/infrastructure/localLogging.ts b/src/shared/infrastructure/localLogging.ts new file mode 100644 index 00000000..b24caaa1 --- /dev/null +++ b/src/shared/infrastructure/localLogging.ts @@ -0,0 +1,55 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { App } from 'vue'; + +export type FrontendLogEvent = + 'vue_error' | 'unhandled_error' | 'unhandled_rejection'; + +export interface LocalLogger { + error(event: FrontendLogEvent): Promise; +} + +export function createLocalLogger(call: typeof invoke = invoke): LocalLogger { + return { + async error(event) { + // Never serialize Error objects, component state or rejection payloads. + await call('log_frontend_event', { event }); + }, + }; +} + +/** Installs local diagnostics while preserving existing Vue error integrations. */ +export function initializeLocalLogging( + app: App, + logger: LocalLogger = createLocalLogger(), + events: EventTarget = window, +): () => void { + const report = (event: FrontendLogEvent) => { + // A failed logging IPC must not generate another unhandled rejection. + void logger.error(event).catch(() => {}); + }; + const previous = app.config.errorHandler; + const handler: NonNullable = ( + error, + instance, + info, + ) => { + report('vue_error'); + if (previous) previous(error, instance, info); + else console.error(error); + }; + const onError = () => report('unhandled_error'); + const onRejection = () => report('unhandled_rejection'); + app.config.errorHandler = handler; + events.addEventListener('error', onError); + events.addEventListener('unhandledrejection', onRejection); + const dispose = () => { + events.removeEventListener('error', onError); + events.removeEventListener('unhandledrejection', onRejection); + if (app.config.errorHandler === handler) { + if (previous) app.config.errorHandler = previous; + else delete app.config.errorHandler; + } + }; + app.onUnmount(dispose); + return dispose; +} diff --git a/src/shared/presentation/widgets/AppIcon.vue b/src/shared/presentation/widgets/AppIcon.vue new file mode 100644 index 00000000..cd93331d --- /dev/null +++ b/src/shared/presentation/widgets/AppIcon.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/shared/presentation/widgets/ComingSoonWorkspace.vue b/src/shared/presentation/widgets/ComingSoonWorkspace.vue new file mode 100644 index 00000000..2f248647 --- /dev/null +++ b/src/shared/presentation/widgets/ComingSoonWorkspace.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/shared/presentation/widgets/PasswordField.vue b/src/shared/presentation/widgets/PasswordField.vue new file mode 100644 index 00000000..6c464c4b --- /dev/null +++ b/src/shared/presentation/widgets/PasswordField.vue @@ -0,0 +1,118 @@ + + + diff --git a/src/shared/presentation/widgets/SelectField.test.ts b/src/shared/presentation/widgets/SelectField.test.ts new file mode 100644 index 00000000..129f1497 --- /dev/null +++ b/src/shared/presentation/widgets/SelectField.test.ts @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import SelectField from './SelectField.vue'; + +const props = { + id: 'format', + label: 'Format', + modelValue: 'JKS', + options: [ + { value: 'JKS', label: 'JKS', description: 'Java KeyStore' }, + { value: 'PKCS12', label: 'PKCS12', description: 'Standard format' }, + ], +}; + +describe('Custom select', () => { + it('selects an option using the pointer and exposes its expanded and selected states', async () => { + const wrapper = mount(SelectField, { props }); + const trigger = wrapper.get('[role="combobox"]'); + expect(trigger.attributes('aria-expanded')).toBe('false'); + await trigger.trigger('click'); + expect(trigger.attributes('aria-expanded')).toBe('true'); + expect(wrapper.get('[aria-selected="true"]').text()).toContain('JKS'); + await wrapper.get('#format-option-1').trigger('click'); + expect(wrapper.emitted('update:modelValue')).toEqual([['PKCS12']]); + expect(wrapper.find('[role="listbox"]').exists()).toBe(false); + wrapper.unmount(); + }); + + it('supports keyboard navigation, typeahead, selection and cancellation', async () => { + const wrapper = mount(SelectField, { props }); + const trigger = wrapper.get('[role="combobox"]'); + await trigger.trigger('keydown', { key: 'ArrowDown' }); + await trigger.trigger('keydown', { key: 'End' }); + expect(trigger.attributes('aria-activedescendant')).toBe('format-option-1'); + await trigger.trigger('keydown', { key: 'Home' }); + expect(trigger.attributes('aria-activedescendant')).toBe('format-option-0'); + await trigger.trigger('keydown', { key: 'p' }); + expect(trigger.attributes('aria-activedescendant')).toBe('format-option-1'); + await trigger.trigger('keydown', { key: 'Escape' }); + expect(wrapper.emitted('update:modelValue')).toBeUndefined(); + await trigger.trigger('keydown', { key: ' ' }); + await trigger.trigger('keydown', { key: 'ArrowDown' }); + await trigger.trigger('keydown', { key: 'Enter' }); + expect(wrapper.emitted('update:modelValue')).toEqual([['PKCS12']]); + await trigger.trigger('click'); + await trigger.trigger('keydown', { key: 'Tab' }); + expect(trigger.attributes('aria-expanded')).toBe('false'); + wrapper.unmount(); + }); + + it('closes outside the control and cannot open or select when disabled', async () => { + const wrapper = mount(SelectField, { props, attachTo: document.body }); + const trigger = wrapper.get('[role="combobox"]'); + await trigger.trigger('click'); + document.body.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true }), + ); + await wrapper.vm.$nextTick(); + expect(trigger.attributes('aria-expanded')).toBe('false'); + await trigger.trigger('click'); + await wrapper.setProps({ disabled: true }); + expect(trigger.attributes('aria-expanded')).toBe('false'); + await trigger.trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('[role="listbox"]').exists()).toBe(false); + expect(wrapper.emitted('update:modelValue')).toBeUndefined(); + wrapper.unmount(); + }); +}); diff --git a/src/shared/presentation/widgets/SelectField.vue b/src/shared/presentation/widgets/SelectField.vue new file mode 100644 index 00000000..47756824 --- /dev/null +++ b/src/shared/presentation/widgets/SelectField.vue @@ -0,0 +1,196 @@ + + + diff --git a/src/shared/presentation/widgets/WorkspaceNavigation.vue b/src/shared/presentation/widgets/WorkspaceNavigation.vue new file mode 100644 index 00000000..1e6dba36 --- /dev/null +++ b/src/shared/presentation/widgets/WorkspaceNavigation.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/shared/presentation/workspaceNavigation.ts b/src/shared/presentation/workspaceNavigation.ts new file mode 100644 index 00000000..dd1dcd79 --- /dev/null +++ b/src/shared/presentation/workspaceNavigation.ts @@ -0,0 +1,62 @@ +export const workspacePages = [ + { + id: 'overview', + label: 'Device overview', + group: 'Device', + icon: 'overview', + description: '', + }, + { + id: 'files', + label: 'File explorer', + group: 'Device', + icon: 'folder', + description: 'Browse files and folders on your Android device.', + }, + { + id: 'logcat', + label: 'Logcat', + group: 'Device', + icon: 'terminal', + description: 'Read and filter Android device logs.', + }, + { + id: 'apk-analysis', + label: 'APK analysis', + group: 'APK & Keystore', + icon: 'package', + description: 'Inspect APK metadata, permissions and signatures.', + }, + { + id: 'generate-keystore', + label: 'Generate keystore', + group: 'APK & Keystore', + icon: 'build', + description: + 'Create a keystore and signing key for your Android applications.', + }, + { + id: 'apk-signing', + label: 'APK signing', + group: 'APK & Keystore', + icon: 'shield', + description: 'Sign APK packages with your signing key.', + }, + { + id: 'keystore-explorer', + label: 'Keystore explorer', + group: 'APK & Keystore', + icon: 'folder', + description: + 'Explore keystore aliases, certificates and signing credentials.', + }, + { + id: 'settings', + label: 'Settings', + group: 'Application', + icon: 'settings', + description: 'Application preferences, support and updates.', + }, +] as const; + +export type WorkspacePage = (typeof workspacePages)[number]; diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 00000000..89933f24 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,69 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: + system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --color-primary: #7cffb2; + --color-primary-hover: #a3ffc9; + --color-primary-muted: #173a29; + --color-on-primary: #10271b; + --color-canvas: #0d1110; + --color-sidebar: #111715; + --color-surface: #171e1b; + --color-surface-raised: #202a25; + --color-stroke: #2b3831; + --color-foreground: #edf5f0; + --color-muted: #a0b2a7; + --color-warning: #f2cb82; + --color-danger: #ffa4a4; +} + +:root { + color-scheme: dark; +} + +@layer components { + .field-icon-button { + @apply flex size-8 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-primary/10 hover:text-primary focus-visible:outline-offset-0 motion-reduce:transition-none disabled:cursor-default disabled:opacity-40; + } + .keystore-input { + @apply w-full rounded-lg border border-stroke bg-canvas px-3 py-2.5 text-sm; + } + .keystore-button { + @apply rounded-lg border border-stroke bg-surface-raised px-3 py-2 text-xs text-foreground hover:border-primary/50 disabled:cursor-default disabled:opacity-40; + } + .keystore-card { + @apply rounded-xl border border-stroke bg-surface p-5; + } +} + +body { + margin: 0; + min-width: 320px; + background: var(--color-canvas); + color: var(--color-foreground); + -webkit-font-smoothing: antialiased; +} + +::selection { + background: var(--color-primary); + color: var(--color-on-primary); +} + +button:not(:disabled), +select:not(:disabled) { + cursor: pointer; +} + +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 4px; +} + +.workspace-background { + background: radial-gradient( + ellipse at 85% 0%, + rgb(124 255 178 / 5%), + transparent 55% + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 2b944449..00000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:android_tools/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..32553803 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.vue", + "vite.config.ts", + "playwright.config.ts", + "e2e/**/*.ts" + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..511e9d13 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,20 @@ +import tailwindcss from '@tailwindcss/vite'; +import vue from '@vitejs/plugin-vue'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + clearScreen: false, + server: { + host: '127.0.0.1', + port: 1420, + strictPort: true, + watch: { ignored: ['**/src-tauri/**'] }, + }, + test: { + environment: 'happy-dom', + include: ['src/**/*.test.ts'], + clearMocks: true, + restoreMocks: true, + }, +}); diff --git a/web/favicon.png b/web/favicon.png deleted file mode 100644 index 8aaa46ac..00000000 Binary files a/web/favicon.png and /dev/null differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png deleted file mode 100644 index b749bfef..00000000 Binary files a/web/icons/Icon-192.png and /dev/null differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48d..00000000 Binary files a/web/icons/Icon-512.png and /dev/null differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d76..00000000 Binary files a/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c5669..00000000 Binary files a/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/web/index.html b/web/index.html deleted file mode 100644 index a08492a6..00000000 --- a/web/index.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - android_tools - - - - - - diff --git a/web/manifest.json b/web/manifest.json deleted file mode 100644 index bc7c17b6..00000000 --- a/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "android_tools", - "short_name": "android_tools", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/windows/.gitignore b/windows/.gitignore deleted file mode 100644 index d492d0d9..00000000 --- a/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt deleted file mode 100644 index eccad543..00000000 --- a/windows/CMakeLists.txt +++ /dev/null @@ -1,121 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(android_tools LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "android_tools") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) - -# Install Android Tools binaries (adb.exe and aapt.exe) -if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/adb.exe") - install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/adb.exe" - DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) -endif() - -if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/aapt.exe") - install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/aapt.exe" - DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) -endif() diff --git a/windows/adb.exe b/windows/adb.exe deleted file mode 100644 index 371f7db2..00000000 Binary files a/windows/adb.exe and /dev/null differ diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt deleted file mode 100644 index 903f4899..00000000 --- a/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,109 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# 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") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 5cde352d..00000000 --- a/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,41 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - AutoUpdaterWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("AutoUpdaterWindowsPluginCApi")); - BitsdojoWindowPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); - DesktopDropPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("DesktopDropPlugin")); - IrondashEngineContextPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); - PdfxPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("PdfxPlugin")); - SentryFlutterPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SentryFlutterPlugin")); - Sqlite3FlutterLibsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); - SuperNativeExtensionsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); - WebviewWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WebviewWindowsPlugin")); -} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85..00000000 --- a/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake deleted file mode 100644 index aed5cdc9..00000000 --- a/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,34 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - auto_updater_windows - bitsdojo_window_windows - desktop_drop - irondash_engine_context - pdfx - sentry_flutter - sqlite3_flutter_libs - super_native_extensions - url_launcher_windows - webview_windows -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt deleted file mode 100644 index 394917c0..00000000 --- a/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,40 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc deleted file mode 100644 index ed2ab9bc..00000000 --- a/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "android_tools" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "android_tools" "\0" - VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" - VALUE "OriginalFilename", "android_tools.exe" "\0" - VALUE "ProductName", "android_tools" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp deleted file mode 100644 index 955ee303..00000000 --- a/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,71 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652f..00000000 --- a/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp deleted file mode 100644 index 38bac9ce..00000000 --- a/windows/runner/main.cpp +++ /dev/null @@ -1,45 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" -#include -auto bdw = bitsdojo_window_configure(BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP); - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"android_tools", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/windows/runner/resource.h b/windows/runner/resource.h deleted file mode 100644 index 66a65d1e..00000000 --- a/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico deleted file mode 100644 index c04e20ca..00000000 Binary files a/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest deleted file mode 100644 index 153653e8..00000000 --- a/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,14 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp deleted file mode 100644 index 3a0b4651..00000000 --- a/windows/runner/utils.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - unsigned int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr) - -1; // remove the trailing null character - int input_length = (int)wcslen(utf16_string); - std::string utf8_string; - if (target_length == 0 || target_length > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/windows/runner/utils.h b/windows/runner/utils.h deleted file mode 100644 index 3879d547..00000000 --- a/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp deleted file mode 100644 index 60608d0f..00000000 --- a/windows/runner/win32_window.cpp +++ /dev/null @@ -1,288 +0,0 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h deleted file mode 100644 index e901dde6..00000000 --- a/windows/runner/win32_window.h +++ /dev/null @@ -1,102 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_