diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 9f2974e..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,189 +0,0 @@ -name: Build ATK - -on: - push: - tags: - - 'v*' - workflow_dispatch: - -env: - GO_VERSION: "1.23" - NODE_VERSION: "20" - PNPM_VERSION: "10" - -jobs: - - build-linux: - name: Build Linux (deb + AppImage) - runs-on: ubuntu-22.04 - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev fuse libfuse2 - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - run_install: false - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - - name: Install nfpm - run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest - - name: Install frontend dependencies - run: pnpm install - working-directory: frontend - - name: Set webkit pkg-config path - run: | - echo "PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig" >> $GITHUB_ENV - echo "CGO_CFLAGS=$(pkg-config --cflags webkit2gtk-4.1)" >> $GITHUB_ENV - echo "CGO_LDFLAGS=$(pkg-config --libs webkit2gtk-4.1)" >> $GITHUB_ENV - - name: Download Go modules - run: go mod download - - name: Build binary - run: wails build -tags webkit2_41 - - name: Generate icon and desktop file - run: | - mkdir -p build - cp assets/appicon.png build/appicon.png - cp assets/atk-deb.desktop build/atk.desktop - - name: Build .deb - run: | - nfpm pkg --packager deb --target build/ - mv build/atk_*.deb "build/ATK-${{ github.ref_name }}-amd64.deb" || true - - name: Build AppImage - run: | - wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage - chmod +x linuxdeploy-x86_64.AppImage - mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps - cp build/bin/ATK AppDir/usr/bin/ATK - cp assets/atk-appimage.desktop AppDir/usr/share/applications/ATK.desktop - cp build/appicon.png AppDir/usr/share/icons/hicolor/256x256/apps/atk.png - ARCH=x86_64 ./linuxdeploy-x86_64.AppImage --appdir AppDir --output appimage - find . -maxdepth 1 -name "*.AppImage" -not -name "linuxdeploy*" -exec mv {} "build/ATK-${{ github.ref_name }}-x86_64.AppImage" \; - - name: Upload Linux artifacts - uses: actions/upload-artifact@v4 - with: - name: "atk-linux-${{ github.ref_name }}" - path: | - build/bin/ATK - build/*.deb - build/*.AppImage - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') - with: - name: "ATK ${{ github.ref_name }}" - body: "ATK Android Toolkit ${{ github.ref_name }}" - files: | - build/ATK-${{ github.ref_name }}-amd64.deb - build/ATK-${{ github.ref_name }}-x86_64.AppImage - build/bin/ATK - draft: false - prerelease: false - - build-windows: - name: Build Windows (exe) - runs-on: windows-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - run_install: false - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - - name: Install frontend dependencies - run: pnpm install - working-directory: frontend - - name: Download Go modules - run: go mod download - - name: Build Windows binary - run: wails build - - name: Rename output - shell: cmd - run: copy "build\bin\ATK.exe" "build\ATK-${{ github.ref_name }}-windows-amd64.exe" - - name: Upload Windows artifact - uses: actions/upload-artifact@v4 - with: - name: "atk-windows-${{ github.ref_name }}" - path: build/ATK-${{ github.ref_name }}-windows-amd64.exe - - name: Upload to Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') - with: - files: build/ATK-${{ github.ref_name }}-windows-amd64.exe - - build-macos: - name: Build macOS (unsigned DMG) - runs-on: macos-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - run_install: false - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - - name: Install frontend dependencies - run: pnpm install - working-directory: frontend - - name: Download Go modules - run: go mod download - - name: Build macOS binary - run: wails build - - name: Create DMG - run: | - mkdir -p dmg_staging - if [ -d "build/bin/ATK.app" ]; then - cp -r build/bin/ATK.app dmg_staging/ - else - cp build/bin/ATK dmg_staging/ - fi - hdiutil create -volname "ATK" -srcfolder dmg_staging -ov -format UDZO "build/ATK-${{ github.ref_name }}-macos-unsigned.dmg" - - name: Upload macOS artifact - uses: actions/upload-artifact@v4 - with: - name: "atk-macos-${{ github.ref_name }}" - path: build/ATK-${{ github.ref_name }}-macos-unsigned.dmg - - name: Upload to Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') - with: - files: build/ATK-${{ github.ref_name }}-macos-unsigned.dmg diff --git a/ATK_SCREENSHOTS/APK_Audit.png b/ATK_SCREENSHOTS/APK_Audit.png new file mode 100644 index 0000000..244174c Binary files /dev/null and b/ATK_SCREENSHOTS/APK_Audit.png differ diff --git a/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO1.mp4 b/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO1.mp4 new file mode 100644 index 0000000..e088bb5 Binary files /dev/null and b/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO1.mp4 differ diff --git a/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO2.mp4 b/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO2.mp4 new file mode 100644 index 0000000..316376b Binary files /dev/null and b/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO2.mp4 differ diff --git a/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO3.mp4 b/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO3.mp4 new file mode 100644 index 0000000..ec6a5e7 Binary files /dev/null and b/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO3.mp4 differ diff --git a/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO4.mp4 b/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO4.mp4 new file mode 100644 index 0000000..c5476f6 Binary files /dev/null and b/ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO4.mp4 differ diff --git a/ATK_SCREENSHOTS/ATK_screen_mirror_pref.png b/ATK_SCREENSHOTS/ATK_screen_mirror_pref.png new file mode 100644 index 0000000..afb0e21 Binary files /dev/null and b/ATK_SCREENSHOTS/ATK_screen_mirror_pref.png differ diff --git a/ATK_SCREENSHOTS/App_Inspector.png b/ATK_SCREENSHOTS/App_Inspector.png new file mode 100644 index 0000000..588583e Binary files /dev/null and b/ATK_SCREENSHOTS/App_Inspector.png differ diff --git a/ATK_SCREENSHOTS/Backup.png b/ATK_SCREENSHOTS/Backup.png new file mode 100644 index 0000000..91b71dc Binary files /dev/null and b/ATK_SCREENSHOTS/Backup.png differ diff --git a/ATK_SCREENSHOTS/Certificates.png b/ATK_SCREENSHOTS/Certificates.png new file mode 100644 index 0000000..2e782b4 Binary files /dev/null and b/ATK_SCREENSHOTS/Certificates.png differ diff --git a/ATK_SCREENSHOTS/Dashboard.png b/ATK_SCREENSHOTS/Dashboard.png new file mode 100644 index 0000000..3c98283 Binary files /dev/null and b/ATK_SCREENSHOTS/Dashboard.png differ diff --git a/ATK_SCREENSHOTS/Debloater.png b/ATK_SCREENSHOTS/Debloater.png new file mode 100644 index 0000000..49662a7 Binary files /dev/null and b/ATK_SCREENSHOTS/Debloater.png differ diff --git a/ATK_SCREENSHOTS/Files.png b/ATK_SCREENSHOTS/Files.png new file mode 100644 index 0000000..d52b641 Binary files /dev/null and b/ATK_SCREENSHOTS/Files.png differ diff --git a/ATK_SCREENSHOTS/Flasher.png b/ATK_SCREENSHOTS/Flasher.png new file mode 100644 index 0000000..66ee1c6 Binary files /dev/null and b/ATK_SCREENSHOTS/Flasher.png differ diff --git a/ATK_SCREENSHOTS/Logcat.png b/ATK_SCREENSHOTS/Logcat.png new file mode 100644 index 0000000..319aea8 Binary files /dev/null and b/ATK_SCREENSHOTS/Logcat.png differ diff --git a/ATK_SCREENSHOTS/Login_Window_Password.png b/ATK_SCREENSHOTS/Login_Window_Password.png new file mode 100644 index 0000000..737c6a9 Binary files /dev/null and b/ATK_SCREENSHOTS/Login_Window_Password.png differ diff --git a/ATK_SCREENSHOTS/Packages.png b/ATK_SCREENSHOTS/Packages.png new file mode 100644 index 0000000..9311b6b Binary files /dev/null and b/ATK_SCREENSHOTS/Packages.png differ diff --git a/ATK_SCREENSHOTS/Prop_Editor.png b/ATK_SCREENSHOTS/Prop_Editor.png new file mode 100644 index 0000000..b86c909 Binary files /dev/null and b/ATK_SCREENSHOTS/Prop_Editor.png differ diff --git a/ATK_SCREENSHOTS/Settings.png b/ATK_SCREENSHOTS/Settings.png new file mode 100644 index 0000000..783c02d Binary files /dev/null and b/ATK_SCREENSHOTS/Settings.png differ diff --git a/ATK_SCREENSHOTS/Settings2.png b/ATK_SCREENSHOTS/Settings2.png new file mode 100644 index 0000000..fd9d96e Binary files /dev/null and b/ATK_SCREENSHOTS/Settings2.png differ diff --git a/ATK_SCREENSHOTS/Settings3.png b/ATK_SCREENSHOTS/Settings3.png new file mode 100644 index 0000000..c1e109e Binary files /dev/null and b/ATK_SCREENSHOTS/Settings3.png differ diff --git a/ATK_SCREENSHOTS/Shell.png b/ATK_SCREENSHOTS/Shell.png new file mode 100644 index 0000000..c98588c Binary files /dev/null and b/ATK_SCREENSHOTS/Shell.png differ diff --git a/ATK_SCREENSHOTS/Utilities.png b/ATK_SCREENSHOTS/Utilities.png new file mode 100644 index 0000000..16ea68e Binary files /dev/null and b/ATK_SCREENSHOTS/Utilities.png differ diff --git a/README.md b/README.md index 6bb8725..1a4a0dc 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,301 @@ -``` - █████╗ ████████╗██╗ ██╗ -██╔══██╗╚══██╔══╝██║ ██╔╝ -███████║ ██║ █████╔╝ -██╔══██║ ██║ ██╔═██╗ -██║ ██║ ██║ ██║ ██╗ -╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ -ANDROID TOOLKIT — v1.0.5 -``` +

+ ATK +

-> All-in-one ADB command centre for Android power users, security researchers, and bug hunters. -> Built with Go + React via Wails. Runs natively on Linux, Windows, and macOS. -> Uses your system ADB — no bundled binaries, no mystery executables. +

ATK · Android Tool kit

+ +

+ An all-in-one Android command centre with a real-time system-map debugging engine. +

+ +

+ License GPLv3 + Linux + Go + React via Wails + Ask DeepWiki +

--- -``` -[ DOWNLOADS ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` +## What is ATK? -| Platform | Format | Install | -|-----------------------|-------------|--------------------------------------------------| -| Linux — Debian/Ubuntu | `.deb` | `sudo dpkg -i ATK-*.deb` | -| Linux — any distro | `.AppImage` | `chmod +x ATK-*.AppImage && ./ATK-*.AppImage` | -| Windows | `.exe` | Run directly | -| macOS 11.0+ | `.dmg` | Unsigned — see note below | +ATK (Android Toolkit) is an all-in-one ADB and fastboot command centre for power +users, security researchers, and bug hunters. It runs on Linux, built with Go +and React via Wails. You get the tools an OEM service centre has, plus a +real-time debugging engine built around a live system map. + +Mirror and control your phone in a detachable window. Browse files on the device +and your computer with a built-in image viewer. Root and flash Pixels. Audit APKs +for trackers and secrets. Debloat over 5,000 packages. Run hundreds of one-click +ADB commands. And watch the device's behaviour in real time as a live system map. +One themeable UI covers all of it. + +> 🗺️ The Live System Map turns logcat into a live, interactive view of the whole +> system's behaviour. No other Android tool does this. [Jump to it ↓](#-live-system-map) + +> [!NOTE] +> ATK uses your system `adb`, `fastboot`, and `scrcpy` from PATH. Nothing is +> bundled. Settings shows the path and SHA-256 of each binary so you can verify +> them yourself. + +--- + +## 🙏 Built on the community + +ATK builds on these open-source projects. Go star them: + +- **[scrcpy](https://github.com/Genymobile/scrcpy)** (Genymobile): screen mirroring and control behind the Screen Mirror module. +- **[apkauditor](https://apkauditor.com)** (Sandeep Wawdane): inspiration for the APK Audit feature. Clean-room reimplementation, no code reused. +- **[Canta](https://github.com/samolego/Canta) / [Shizuku](https://github.com/RikkaApps/Shizuku)**: reference for removing and disabling apps without root. +- **[Magisk](https://github.com/topjohnwu/Magisk)** (topjohnwu): boot-image patching and root. +- **[Universal Android Debloater](https://github.com/0x192/universal-android-debloater)** (0x192): the original UAD project and the foundation of ATK's debloater. GPL-3.0. +- **[Universal Android Debloater Next Generation](https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation)**: the maintained UAD fork ATK's package database comes from. +- **[PixelFlasher](https://github.com/badabing2005/PixelFlasher)** (badabing2005): Pixel flash-sequence reference. +- **[Wails](https://wails.io)**: Go and Web application framework. +- **[Lucide](https://lucide.dev)**: icon set. +- **[adb-gui-kit](https://github.com/Drenzzz/adb-gui-kit)** (Drenzzz): early base ADB GUI groundwork this project started from. + +### 🗺️ See the Live System Map in action + +Real-time demos of the map engine showing live device telemetry: You may have seen this viral on X, it is getting around ! Yes it came from here, This is the original implementation. + +**▶️ Demo 1** + +https://github.com/user-attachments/assets/88ade32b-fc65-4165-a5a5-9419ca75eb7a + +**▶️ Demo 2** + +https://github.com/user-attachments/assets/dfb97bdf-0cdb-48d8-a11c-d80222887f1d + +**▶️ Demo 3** + +https://github.com/user-attachments/assets/090df134-2d79-4f7a-96ef-0a58e42f0ad5 + +**▶️ Demo 4** + +https://github.com/user-attachments/assets/47a3590a-11f8-416f-b972-0e89d933419c + +

Live System Map

+ + + + + + + + + + +
Live System Map view 1Live System Map view 2
Live System Map view 3Live System Map view 4
+ +
+📸 More screenshots + + + + + + + + + + + +
Dashboard
Dashboard
File Explorer
Files
Package Manager
Packages
Debloater
Debloater
APK Audit
APK Audit
App Inspector
App Inspector
Certificate Manager
Certificates
Device Backup
Backup
Prop Editor
Prop Editor
Shell Terminal
Shell
Utilities
Utilities
Flasher
Flasher
Screen Mirror prefs
Screen Mirror prefs
Settings: Appearance
Settings
Settings: Features
Settings 2
Settings: Advanced
Settings 3
Password lock
Login
Logcat (map mode)
Logcat
+ +
+ + +--- + +## Download **[→ Latest Release](https://github.com/jegly/ATK/releases/latest)** +| Distro | Format | Install | +|-----------------|--------|--------------------------| +| Debian / Ubuntu | `.deb` | `sudo dpkg -i ATK-*.deb` | + +> [!NOTE] +> Linux only. ATK is built and tested on Debian and Ubuntu. Other distros build +> from source (below). + +The releases page publishes checksums. Verify before installing. + **Linux requirements** ```bash sudo apt install adb fastboot libwebkit2gtk-4.1-0 -``` - -**macOS — Gatekeeper bypass** -```bash -xattr -rd com.apple.quarantine /Applications/ATK.app -# or: System Preferences → Security & Privacy → Open Anyway +# scrcpy is only needed for the Screen Mirror module: +sudo apt install scrcpy ``` --- -``` -[ MODULES ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` +## Modules -``` -┌─────────────────────┬────────────────────────────────────────────────────┐ -│ MODULE │ DESCRIPTION │ -├─────────────────────┼────────────────────────────────────────────────────┤ -│ Dashboard │ Device info, wireless ADB, reboot controls │ -│ File Explorer │ Push, pull, rename, delete, batch export │ -│ Package Manager │ Install, uninstall, enable, disable, pull APK │ -│ Debloater │ 2157 packages — Samsung, Xiaomi, Google, 10+ OEMs │ -│ Live Logcat │ Real-time streaming, level filter, tag filter │ -│ App Inspector │ Permissions, components, certs, pinning check │ -│ Certificate Manager │ Install/remove user CAs for HTTPS interception │ -│ Device Backup │ adb backup with app selection and restore │ -│ Prop Editor │ Read/write all 300+ system properties │ -│ Shell Terminal │ adb shell and host commands, command history │ -│ Utilities │ 487 commands across 15 categories │ -│ Flasher │ Fastboot partition flash, getvar, sideload │ -│ Pixel Factory Flash │ Full factory image flash from flash-all.sh │ -└─────────────────────┴────────────────────────────────────────────────────┘ -``` +| Module | What it does | +|---|---| +| 🖥️ **Screen Mirror** | Live mirror and full control via scrcpy; detachable and recordable | +| 📊 **Dashboard** | Device info, wireless ADB, reboot controls | +| 📁 **File Explorer** | Browse the device and your computer, push and pull, image viewer | +| 📦 **Package Manager** | Install, uninstall, enable, disable, pull APK, plus **privileged removal of protected system apps without root** | +| 🔎 **APK Audit** | Static APK security audit: perms, trackers, certs, rule findings | +| 🧹 **Debloater** | 5,362 packages across Samsung, Xiaomi, Google, and 11 more OEMs | +| 📡 **Live Logcat + System Map** | Real-time log streaming, plus a live, interactive map of system behaviour across subsystems | +| 🕵️ **App Inspector** | Permissions, components, certs, SSL-pinning check | +| 🔐 **Certificate Manager** | Install and remove user CAs for HTTPS interception | +| 💾 **Device Backup** | `adb backup` with app selection and restore | +| 🎚️ **Prop Editor** | Read and write all 300+ system properties | +| 💻 **Shell Terminal** | adb shell and host, command library, export session | +| 🧰 **Utilities** | 631 one-click commands across 50+ categories | +| ⚡ **Flasher** | Fastboot, live-boot, Magisk root, firmware download | + +> [!TIP] +> Hide any module you don't use from **Settings → Sidebar Features**. Theme +> (Dark, Catppuccin Frappé, Latte) and sidebar position (left, top, bottom) are +> configurable too. --- -``` -[ SECURITY ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` +## ✨ What's new -``` -NO BUNDLED BINARIES - ATK has no bin/ directory. It resolves adb and fastboot from your system - PATH — installed via apt, Homebrew, or Android SDK. The Settings view - displays the full path and SHA-256 of whichever binary is in use so you - can verify it against Google's published platform-tools checksums. - -NO SHELL STRING BUILDING - Every command uses exec.Command(binary, arg1, arg2, ...) with discrete - arguments passed directly to execve. There is no shell involved and - therefore no shell injection surface. - -INPUT VALIDATION - Package names, partition names, IP addresses, and remote paths are all - validated before use. Fastboot flash only accepts a known partition - allowlist — no arbitrary partition names accepted. -``` +- 📡 **Live System Map**: turn logcat into a live, interactive map of system behaviour across subsystems *(see below)*. +- 🧹 **Debloater database grew from 2,157 to 5,362 packages**, with a **privileged uninstall of protected system apps without root** and a one-click **restore**. +- 🧰 **Utilities expanded to 631 one-click commands** across 50+ categories. +- 🎨 **Themes**: Dark, Catppuccin **Frappé**, and **Latte**. Dismissible safety banners. +- 🔎 **APK Audit** exports to **JSON, CSV, SARIF**, with an in-app APK explorer. +- 📦 **Smarter package ops**: combined *Disable + Uninstall*, a *disabled* badge, and verify-then-escalate so removals stick. +- 🔌 **Offline-capable UI**: fonts are self-hosted, with no runtime CDN fetches. --- -``` -[ DEBLOATER ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` +## 🖥️ Screen Mirror -Package database sourced from Universal Android Debloater (UAD-ng). 2,157 -packages across 14 manufacturers and categories, each with safety ratings: +See your phone on your computer and drive it with mouse and keyboard. ATK is the +control panel. The mirror opens in its own window you can move, resize, and snap +anywhere. It runs on scrcpy from your system install. -``` - SAFE — generally safe to remove - CAUTION — disable rather than uninstall; may affect device behaviour - KEEP — do not remove; will break core system functionality -``` +- 🕹️ **Full control**: tap, swipe, type, long-press, complete input from your desktop +- 🪟 **Detachable**: separate window, and you can keep it alive after ATK closes +- 📷 **Capture**: one-click screenshot (PNG) and full-session screen recording +- 🎛️ **Tunable**: max resolution, bitrate, FPS, stay-awake, turn-screen-off, show-touches, always-on-top, fullscreen, borderless +- ⌨️ **Shortcut cheat-sheet**: Home, Back, recents, copy and paste, rotate, and more, built in -Coverage: Samsung · Xiaomi · OnePlus/Oppo · Huawei · Sony · Motorola · LG - Nokia/HMD · Asus · Realme · Google · Carriers · AOSP · Misc +> [!NOTE] +> Single-instance by design. Start always yields one window, and ATK clears the +> mirror on exit unless you asked it to stay. --- -``` -[ PIXEL FACTORY FLASH ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` +## 📁 File Explorer -ATK reads `flash-all.sh` directly from inside the factory image zip and -executes the correct sequence — no hardcoded partition order. Options: +A file manager for the device and your computer. -``` - --wipe Wipe userdata (-w flag on fastboot update) - --disable-verity For Magisk / root setups - --disable-verif Paired with disable-verity - --force Bypass anti-rollback (use with caution) - --slot all Flash both A and B slots -``` - -Download factory images from: https://developers.google.com/android/images +- 🔀 **Two sources**: toggle between the phone (adb) and your local filesystem +- ⏱️ **Push and pull**: transfers with a live progress bar, ETA, and cancel +- 🎯 **Push by browsing**: pick files on your PC, browse the phone to the destination folder, then *Push here*, with no paths to type +- 🖱️ **Right-click**: Open, Pull to folder, Rename, Move, Copy path, Delete +- 🖼️ **Image viewer**: full-screen, with ← and → to flip through a folder (device or local) +- 🧭 **Navigation**: Back, Forward, Up history, and an editable path bar --- -``` -[ BUILD FROM SOURCE ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` +## 📡 Live System Map -**Prerequisites — Ubuntu/Debian** +The Live System Map turns the raw logcat firehose into a live, interactive view +of what your phone is doing. It is a real-time engine that unifies system-level +telemetry from many subsystems into one live relational model, shown as an +interactive, multi-mode visualization. No other Android tool does this. + +Processes, services, tags, and components become **nodes**. The relationships +mined from the stream become **edges**: launches, crashes, ANRs, kills, signals, +graphics and audio events, and temporal co-occurrence. Every event becomes a +packet that **flows** from source to destination. You get one coherent, live +picture of how `system_server`, SurfaceFlinger, the media and telephony stacks, +and your apps interact right now. + +- 🌐 **Multiple render modes**: a crisp 2D graph, a neon flow view, and a 3D hierarchical tree +- 🧩 **Many layouts**: force-directed, hub boxes, radial-by-importance, and geometric arrangements +- 🌊 **Trackable flows**: follow individual events travelling between subsystems, source to destination +- 🚨 **Surfacing**: crashes, ANRs, and errors auto-alert and ping their node, and you can add keyword watch-rules +- 🎯 **Focus tools**: isolate one node's traffic, build a watchlist, filter by severity or kind, scrub a timeline, diff against a baseline +- 🎥 **Capture and export**: record the packet stream and pull it out for offline analysis +- ⌨️ **Built for flow**: pause and resume, fullscreen, freeze, search-to-step, colour-coding, savable presets + +Read it as a node-link graph to understand structure, or as flowing packets to +watch behaviour. Pick the mode that fits your question. + +--- + +## ⚡ Flasher + +All flash tooling in one place across three tabs, with a live device-info bar up +top (connection mode · slot · bootloader · lock state · root). + +| Tab | What it does | +|---|---| +| **Manual** | Reboot menu (system, bootloader, fastbootd, recovery), bootloader unlock and lock, flash any safe-listed partition, getvar, ADB sideload, **live-boot** an image, or flash boot/init_boot to a chosen slot | +| **Pixel Factory** | Drag in (or browse to) a Google factory `.zip`. ATK reads `flash-all.sh` and runs the right sequence. Options: wipe or keep data, disable-verity and verification, both slots | +| **Download** | Fetch official Pixel **factory or OTA** images by device, straight from Google, with a progress bar and automatic **SHA-256 verification** | + +> [!IMPORTANT] +> **Rooting (optional).** Enable it in `Settings → Advanced`. ATK can download and +> install Magisk for you, extract boot/init_boot from a factory zip, push it for +> the Magisk app to patch, pull the patched image back, then **live-boot** it +> (temporary root) or **flash** it (permanent). It needs an unlocked bootloader. +> Flashing can wipe or brick a device, so proceed carefully. + +Browse factory images: https://developers.google.com/android/images + +--- + +## 🔎 APK Audit + +A static security audit of any APK, whether a browsed file or an app pulled off +the device. The engine is hybrid: it uses Android SDK tools when present and a +pure-Go fallback otherwise, so it works with zero extra dependencies. + +- **Score and grade** with a severity breakdown +- **Dangerous permissions** highlighted +- **Tracker and ad-SDK** detection +- **Rule findings** tagged with CWE and MASVS +- **Decoded manifest** with exported components +- **Signing certificate**: identity, scheme (v1/v2/v3), SHA-256 and SHA-1 +- **Explorer**: browse the APK, view text, images, and hex +- **Export** to JSON, CSV, and SARIF + +--- + +## 🧹 Debloater + +The package database comes from Universal Android Debloater (UAD-ng): **5,362 +packages** across 14 manufacturers, each with a safety rating. Beyond enable and +disable, ATK can run a **privileged uninstall of protected system apps without +root**, and **restore** them later, using a Canta and Shizuku-style technique +over ADB. + +| Rating | Meaning | +|---|---| +| 🟢 **Safe** | Generally safe to remove | +| 🟡 **Caution** | Disable rather than uninstall; may affect device behaviour | +| 🔴 **Keep** | Do not remove; will break core system functionality | + +Coverage: Samsung · Xiaomi · OnePlus/Oppo · Huawei · Sony · Motorola · LG · Nokia/HMD · Asus · Realme · Google · Carriers · AOSP · Misc + +--- + +## 🔒 Security + +> [!NOTE] +> - **No bundled binaries.** ATK resolves `adb`, `fastboot`, and `scrcpy` from your PATH. Settings shows each binary's path and SHA-256 to verify against Google's published checksums. +> - **No host shell string-building.** Host commands use `exec.Command(binary, args…)` (direct `execve`, no shell). Paths sent to the *device* shell are quoted, so filenames with spaces or special characters stay safe. +> - **Input validation.** ATK validates package names, partitions, IPs, and remote paths. Fastboot flash uses a partition allowlist. Destructive flash and bootloader actions confirm first. + +--- + +## Build from Source + +**Prerequisites on Ubuntu/Debian** ```bash sudo apt install -y build-essential pkg-config libgtk-3-dev \ libwebkit2gtk-4.1-dev libayatana-appindicator3-dev adb fastboot @@ -150,7 +314,7 @@ sudo npm install -g pnpm go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` -**Build** +**Build and run** ```bash git clone https://github.com/jegly/ATK cd ATK @@ -160,10 +324,8 @@ wails build -tags webkit2_41 ./build/bin/ATK ``` -**Dev mode (hot reload)** -```bash -wails dev -tags webkit2_41 -``` +**Dev mode (hot reload):** `wails dev -tags webkit2_41` + **Package as .deb** ```bash @@ -173,44 +335,21 @@ nfpm pkg --packager deb --target build/ sudo dpkg -i build/atk_*.deb ``` ---- - -``` -[ ARCH LINUX ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` - -A `PKGBUILD` is included in `aur/`. See `aur/README.md` for publishing to -the AUR. Until then, Arch users can use the `.AppImage` from the releases -page — no installation required. +### Other distros +Build from source as above (`wails build -tags webkit2_41`) and run +`./build/bin/ATK` directly. You need `adb`, `fastboot`, GTK 3, and WebKit2GTK 4.1 +present. --- -``` -[ LICENCE ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` +## License -ATK is released under the GNU General Public License v3.0. +ATK is released under the **GNU General Public License v3.0**. The debloater +database derives from the **Universal Android Debloater** project (GPL-3.0), +originally created by [0x192](https://github.com/0x192/universal-android-debloater) +and continued by the Universal-Debloater-Alliance's +[Next Generation](https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation) +fork. See `LICENSE` for full terms and third-party attributions. -The debloater package database is from Universal Android Debloater Next -Generation (GPL-3.0) by the Universal-Debloater-Alliance. +

github.com/jegly/ATK

-See LICENSE for full terms and third-party attributions. - ---- - -``` -[ ACKNOWLEDGEMENTS ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` - -``` - Universal Android Debloater Alliance — debloater package database - Wails — Go + Web application framework - PixelFlasher (badabing2005) — Pixel flash sequence reference - Lucide — icon set -``` - ---- - -``` - github.com/jegly/ATK -``` diff --git a/RELEASE_NOTES_v1.1.0.md b/RELEASE_NOTES_v1.1.0.md new file mode 100644 index 0000000..33d02e7 --- /dev/null +++ b/RELEASE_NOTES_v1.1.0.md @@ -0,0 +1,45 @@ +# ATK v1.1.0 — the Live System Map release + +The all-in-one, OEM-style Android toolkit for power users, security researchers, +and bug hunters — now with a **first-of-its-kind real-time debugging engine**. + +## ⭐ Headline — Live System Map +Turn the raw logcat firehose into a **live, interactive map of what your phone is +actually doing**. A real-time engine unifies system-level telemetry from many +subsystems into a single live relational model — processes, services, tags and +components become nodes; launches, crashes, ANRs, kills, signals and +graphics/audio events become edges; every event flows source → destination. + +- 🌐 Multiple render modes — crisp 2D graph, neon flow view, 3D hierarchical tree +- 🧩 Layouts — force-directed, hub boxes, radial-by-importance, geometric +- 🌊 Trackable flows — follow individual events between subsystems +- 🚨 Auto-surfacing of crashes / ANRs / errors + your own keyword watch-rules +- 🎯 Focus tools — isolate a node, watchlist, severity/kind filters, timeline, baseline diff +- 🎥 Capture & export the packet stream for offline analysis + +## ✨ What's new +- 📡 **Live System Map** — the flagship real-time visualization (above). +- 🧹 **Debloater database 2,157 → 5,362 packages** (Samsung, Xiaomi, Google + 11 more OEMs). +- 🔓 **Privileged uninstall of protected system apps — without root**, plus one-click **restore**. +- 🧰 **Utilities expanded to 631 one-click commands** across 50+ categories. +- 🔎 **APK Audit** — static security audit (perms, trackers, certs, CWE/MASVS rule findings) with an in-app APK explorer and **JSON · CSV · SARIF** export. +- 🎨 **Themes** — Dark, Catppuccin **Frappé** & **Latte**; configurable sidebar; dismissible safety banners. +- 📦 **Smarter package ops** — combined *Disable + Uninstall*, a *disabled* badge, and verify-then-escalate so removals actually stick. +- 🔌 **Fully offline-capable UI** — self-hosted fonts, no runtime CDN fetches. +- 🖼️ New app icon. + +## 📦 Install (Linux) +**Debian / Ubuntu:** +```bash +sudo dpkg -i atk_1.1.0_amd64.deb +``` +Requirements: `adb`, `fastboot`, `libgtk-3-0`, `libwebkit2gtk-4.1-0` +(`scrcpy` only needed for the Screen Mirror module). + +Other distros: build from source — see the README. + +> ⚠️ Linux only. No bundled binaries — ATK uses *your* system `adb`/`fastboot`/`scrcpy`. + +## 🙏 Credits +Built on the open-source community — scrcpy, Magisk, Universal Android Debloater, +Wails, Lucide, and more. Full attributions and licenses are in the README. GPL-3.0. diff --git a/android-helper/Main.java b/android-helper/Main.java new file mode 100644 index 0000000..3af26af --- /dev/null +++ b/android-helper/Main.java @@ -0,0 +1,79 @@ +// ATK privileged uninstall helper. +// +// Run on-device via `app_process` as the shell user (uid 2000) - the same +// identity non-root Shizuku uses. It calls IPackageInstaller.uninstall() +// directly with the DELETE_SYSTEM_APP flag, which the `pm` CLI never sets, +// so it can remove protected system apps for a user without root. +// +// Written entirely with reflection + the public IntentSender(IBinder) +// constructor so it compiles against the standard android.jar (no hidden +// API stubs needed). The hidden classes resolve at runtime on-device. +// +// Usage: app_process / Main [userId] +// +// Prints "ATK_OK " / "ATK_ERR " for the caller to parse. + +import android.content.IntentSender; +import android.os.Binder; +import android.os.IBinder; +import android.os.Parcel; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; + +public final class Main { + // android.content.pm.PackageManager.DELETE_SYSTEM_APP + static final int DELETE_SYSTEM_APP = 0x00000004; + + public static void main(String[] args) { + if (args.length < 1) { + System.out.println("ATK_ERR usage: [userId]"); + return; + } + String pkg = args[0]; + int userId = args.length > 1 ? Integer.parseInt(args[1]) : 0; + + try { + // IPackageManager pm = IPackageManager.Stub.asInterface(ServiceManager.getService("package")) + Class sm = Class.forName("android.os.ServiceManager"); + IBinder pmBinder = (IBinder) sm.getMethod("getService", String.class).invoke(null, "package"); + Class ipmStub = Class.forName("android.content.pm.IPackageManager$Stub"); + Object pm = ipmStub.getMethod("asInterface", IBinder.class).invoke(null, pmBinder); + Class ipm = Class.forName("android.content.pm.IPackageManager"); + + // IPackageInstaller installer = pm.getPackageInstaller() + Object installer = ipm.getMethod("getPackageInstaller").invoke(pm); + Class ipi = Class.forName("android.content.pm.IPackageInstaller"); + + // VersionedPackage vp = new VersionedPackage(pkg, VERSION_CODE_HIGHEST=-1) + Class vpc = Class.forName("android.content.pm.VersionedPackage"); + Object vp = vpc.getConstructor(String.class, long.class).newInstance(pkg, (long) -1); + + // A local IntentSender whose Binder swallows the async result callback. + // We don't parse the result here - the caller verifies via `pm list packages`. + IBinder localSender = new Binder() { + @Override + protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) { + if (reply != null) { + reply.writeNoException(); + } + return true; + } + }; + Constructor isc = IntentSender.class.getConstructor(IBinder.class); + IntentSender sender = isc.newInstance(localSender); + + // installer.uninstall(VersionedPackage, String callerPkg, int flags, IntentSender, int userId) + Method uninstall = ipi.getMethod("uninstall", + vpc, String.class, int.class, IntentSender.class, int.class); + uninstall.invoke(installer, vp, "com.android.shell", DELETE_SYSTEM_APP, sender, userId); + + // Give system_server a moment to process the async removal. + Thread.sleep(1500); + System.out.println("ATK_OK " + pkg); + } catch (Throwable t) { + Throwable c = t.getCause() != null ? t.getCause() : t; + System.out.println("ATK_ERR " + c.getClass().getSimpleName() + ": " + c.getMessage()); + } + } +} diff --git a/android-helper/atk-helper.dex b/android-helper/atk-helper.dex new file mode 100644 index 0000000..ba29911 Binary files /dev/null and b/android-helper/atk-helper.dex differ diff --git a/android-helper/build.sh b/android-helper/build.sh new file mode 100644 index 0000000..40072c9 --- /dev/null +++ b/android-helper/build.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Rebuild the ATK privileged-uninstall helper dex (android-helper/atk-helper.dex). +# +# This is the on-device helper ATK pushes and runs via `app_process` (as the +# shell user, uid 2000) to remove protected system apps that `pm uninstall` +# refuses - the same technique Canta uses via Shizuku, but driven over adb with +# no root and no Shizuku app. It calls IPackageInstaller.uninstall() directly +# with the DELETE_SYSTEM_APP flag. +# +# Requires: a JDK (javac) and Android SDK build-tools (d8) + a platform android.jar. +set -euo pipefail +cd "$(dirname "$0")" + +JAVAC="${JAVAC:-$(command -v javac)}" +ANDROID_JAR="${ANDROID_JAR:-$HOME/Android/Sdk/platforms/android-37.0/android.jar}" +D8="${D8:-$HOME/Android/Sdk/build-tools/37.0.0/d8}" + +rm -rf classes && mkdir -p classes +"$JAVAC" --release 17 -cp "$ANDROID_JAR" -d classes Main.java +"$D8" --min-api 26 --output . classes/*.class +mv classes.dex atk-helper.dex +rm -rf classes +echo "Built atk-helper.dex ($(stat -c%s atk-helper.dex) bytes)" diff --git a/app.go b/app.go index 292f73a..338b84a 100644 --- a/app.go +++ b/app.go @@ -3,6 +3,9 @@ package main import ( "context" "sync" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) // DeviceMode represents whether a device is in ADB or Fastboot mode @@ -77,6 +80,10 @@ type App struct { // cancellation for long-running ops currentCancel context.CancelFunc opMutex sync.Mutex + + // app-lock "require password for destructive actions" session window + dangerMu sync.Mutex + dangerUntil time.Time } // NewApp creates a new App instance @@ -90,4 +97,18 @@ func NewApp() *App { // Startup is called when the app starts func (a *App) Startup(ctx context.Context) { a.ctx = ctx + // Frameless windows can open off-centre on some WMs; centre on launch. + runtime.WindowCenter(ctx) +} + +// Shutdown is called when the app is closing — tidy up spawned child processes +// (e.g. a scrcpy mirror) so they don't outlive the app as orphan windows. +// Exception: a mirror started in "detached" mode is left running on purpose. +func (a *App) Shutdown(ctx context.Context) { + scrcpyMu.Lock() + detached := scrcpyDetached + scrcpyMu.Unlock() + if !detached { + a.StopScrcpy() + } } diff --git a/assets/appicon.png b/assets/appicon.png index 76005ae..79bce4d 100644 Binary files a/assets/appicon.png and b/assets/appicon.png differ diff --git a/assets/appicon.svg b/assets/appicon.svg new file mode 100644 index 0000000..6c5b650 --- /dev/null +++ b/assets/appicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend_apkaudit.go b/backend_apkaudit.go new file mode 100644 index 0000000..0a0799d --- /dev/null +++ b/backend_apkaudit.go @@ -0,0 +1,951 @@ +package main + +// APK Auditor — static analysis of an APK (local file or installed package). +// +// Clean-room implementation. The feature concept (a tabbed APK static auditor: +// overview/findings/manifest/components/cert/explorer) is inspired by +// apkauditor.com by Sandeep Wawdane, but none of its code is used here — this +// engine is written from scratch in Go and shells out to the Android SDK +// build-tools (aapt2, apksigner) plus the JBR's keytool for the heavy parsing. + +import ( + "archive/zip" + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/ncruces/zenity" +) + +// --------------------------------------------------------------------------- +// Result types (JSON-tagged for the Wails frontend) +// --------------------------------------------------------------------------- + +type APKAudit struct { + // Source + Source string `json:"source"` // "file" | "device" + Path string `json:"path"` // display path (remote path for device source) + LocalPath string `json:"localPath"` // on-disk APK to read entries from (Explorer/export) + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + SHA256 string `json:"sha256"` + + // Metadata + PackageName string `json:"packageName"` + AppLabel string `json:"appLabel"` + VersionName string `json:"versionName"` + VersionCode string `json:"versionCode"` + MinSDK string `json:"minSdk"` + TargetSDK string `json:"targetSdk"` + CompileSDK string `json:"compileSdk"` + + // Manifest-level flags + Debuggable bool `json:"debuggable"` + AllowBackup bool `json:"allowBackup"` + UsesCleartext bool `json:"usesCleartext"` + HasNSC bool `json:"hasNetworkSecurityConfig"` + + Permissions []Permission `json:"permissions"` + Components []Component `json:"components"` + Cert APKCertInfo `json:"cert"` + Findings []Finding `json:"findings"` + Trackers []Tracker `json:"trackers"` + Files []APKFileEntry `json:"files"` + ManifestXML string `json:"manifestXml"` + + // Scoring + Score int `json:"score"` // 0-100 + Grade string `json:"grade"` // A-F + Counts map[string]int `json:"counts"` // severity -> count + + noManifestMF bool // transient: no META-INF/MANIFEST.MF in the archive +} + +type Permission struct { + Name string `json:"name"` + Dangerous bool `json:"dangerous"` +} + +type Component struct { + Type string `json:"type"` // activity|service|receiver|provider + Name string `json:"name"` + Exported bool `json:"exported"` + ExportedImplicit bool `json:"exportedImplicit"` + Permission string `json:"permission"` + IntentFilters []string `json:"intentFilters"` + + explicitExported bool // set when android:exported was present (not serialized) +} + +type APKCertInfo struct { + Verified bool `json:"verified"` + Subject string `json:"subject"` + Issuer string `json:"issuer"` + SigAlgo string `json:"sigAlgo"` + Serial string `json:"serial"` + SHA256 string `json:"sha256"` + SHA1 string `json:"sha1"` + ValidFrom string `json:"validFrom"` + ValidTo string `json:"validTo"` + V1 bool `json:"v1"` + V2 bool `json:"v2"` + V3 bool `json:"v3"` + IsDebug bool `json:"isDebug"` + Expired bool `json:"expired"` + WeakAlgo bool `json:"weakAlgo"` + Error string `json:"error"` +} + +type Finding struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` // critical|high|medium|low|info + Category string `json:"category"` + Description string `json:"description"` + CWE string `json:"cwe"` + Masvs string `json:"masvs"` + Confidence int `json:"confidence"` + Matches []FindingMatch `json:"matches"` +} + +type FindingMatch struct { + File string `json:"file"` + Value string `json:"value"` +} + +type Tracker struct { + Name string `json:"name"` + Category string `json:"category"` + Matches int `json:"matches"` +} + +type APKFileEntry struct { + Path string `json:"path"` + Size int64 `json:"size"` + Compressed int64 `json:"compressed"` +} + +// --------------------------------------------------------------------------- +// Tunables +// --------------------------------------------------------------------------- + +const ( + auditCommandTimeout = 90 * time.Second + maxDexBytes = 64 << 20 // skip a single dex larger than 64 MB + maxCandidates = 250000 // cap extracted strings scanned + maxMatchesPerRule = 25 // cap reported instances per finding + minStringLen = 6 +) + +// --------------------------------------------------------------------------- +// Public API (auto-bound to the frontend via the single App bind) +// --------------------------------------------------------------------------- + +// SelectAPKForAudit opens a native file picker filtered to APKs. +func (a *App) SelectAPKForAudit() (string, error) { + path, err := zenity.SelectFile( + zenity.Title("Select APK to audit"), + zenity.FileFilters{ + {Name: "APK files", Patterns: []string{"*.apk"}, CaseFold: true}, + {Name: "All files", Patterns: []string{"*"}}, + }, + ) + if err == zenity.ErrCanceled { + return "", nil + } + return path, err +} + +// AuditInstalledApp pulls the base APK of an installed package off the device +// into a temp file, audits it, then removes the temp copy. +func (a *App) AuditInstalledApp(packageName string) (APKAudit, error) { + if err := validatePackageName(packageName); err != nil { + return APKAudit{}, err + } + + out, err := a.runAdbShell("pm", "path", packageName) + if err != nil { + return APKAudit{}, fmt.Errorf("could not locate package on device: %w", err) + } + + var remote string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + p := strings.TrimPrefix(line, "package:") + if strings.HasSuffix(p, "base.apk") { + remote = p + break + } + if remote == "" && strings.HasSuffix(p, ".apk") { + remote = p // fall back to the first apk if no base.apk + } + } + if remote == "" { + return APKAudit{}, fmt.Errorf("no APK path found for %s", packageName) + } + + // Remove temps from earlier device audits, then keep this one on disk so + // the Explorer/export can read entries from it after the audit returns. + cleanStaleAuditTemps() + tmp := filepath.Join(os.TempDir(), "atk-audit-"+sanitizeFileToken(packageName)+".apk") + if _, err := a.runCommandTimeout(auditCommandTimeout, "adb", "pull", remote, tmp); err != nil { + return APKAudit{}, fmt.Errorf("failed to pull APK: %w", err) + } + + audit, err := a.auditFile(tmp) + if err != nil { + os.Remove(tmp) + return audit, err + } + audit.Source = "device" + audit.Path = remote + audit.LocalPath = tmp + audit.FileName = packageName + " (base.apk)" + return audit, nil +} + +// AuditAPK audits a local APK file path. +func (a *App) AuditAPK(path string) (APKAudit, error) { + if strings.TrimSpace(path) == "" { + return APKAudit{}, fmt.Errorf("no APK path provided") + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return APKAudit{}, fmt.Errorf("file not found: %s", path) + } + audit, err := a.auditFile(path) + if err != nil { + return audit, err + } + audit.Source = "file" + return audit, nil +} + +// --------------------------------------------------------------------------- +// Core pipeline +// --------------------------------------------------------------------------- + +func (a *App) auditFile(path string) (APKAudit, error) { + audit := APKAudit{ + Path: path, + LocalPath: path, + FileName: filepath.Base(path), + Counts: map[string]int{}, + } + + if info, err := os.Stat(path); err == nil { + audit.FileSize = info.Size() + } + if sum, err := fileSHA256(path); err == nil { + audit.SHA256 = sum + } + + ctx, cancel := context.WithTimeout(context.Background(), auditCommandTimeout) + defer cancel() + + // 1. Manifest + metadata: aapt2 when available, else pure-Go fallback. + a.parseManifestHybrid(ctx, path, &audit) + + // 2. Signing certificate: apksigner+keytool when available, else pure-Go. + a.parseCertHybrid(ctx, path, &audit) + + // 3. ZIP walk: file tree + dex string extraction for code/secret/tracker rules. + a.scanArchive(path, &audit) + + // 4. Manifest-derived findings. + a.deriveManifestFindings(&audit) + + // 5. Score. + a.scoreAudit(&audit) + + return audit, nil +} + +// --------------------------------------------------------------------------- +// aapt2: badging +// --------------------------------------------------------------------------- + +func (a *App) parseBadging(ctx context.Context, path string, audit *APKAudit) { + out, err := a.runBuildTool(ctx, "aapt2", "dump", "badging", path) + if err != nil || out == "" { + return + } + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "package:"): + audit.PackageName = badgingField(line, "name") + audit.VersionCode = badgingField(line, "versionCode") + audit.VersionName = badgingField(line, "versionName") + audit.CompileSDK = badgingField(line, "compileSdkVersion") + case strings.HasPrefix(line, "sdkVersion:"): + audit.MinSDK = strings.Trim(strings.TrimPrefix(line, "sdkVersion:"), "'") + case strings.HasPrefix(line, "targetSdkVersion:"): + audit.TargetSDK = strings.Trim(strings.TrimPrefix(line, "targetSdkVersion:"), "'") + case strings.HasPrefix(line, "application-label:"): + if audit.AppLabel == "" { + audit.AppLabel = strings.Trim(strings.TrimPrefix(line, "application-label:"), "'") + } + case strings.HasPrefix(line, "uses-permission:"): + name := badgingField(line, "name") + if name != "" { + audit.Permissions = append(audit.Permissions, Permission{ + Name: name, + Dangerous: dangerousPermissions[name], + }) + } + } + } +} + +// badgingField extracts key='value' from an aapt2 badging line. +func badgingField(line, key string) string { + marker := key + "='" + i := strings.Index(line, marker) + if i < 0 { + return "" + } + rest := line[i+len(marker):] + j := strings.Index(rest, "'") + if j < 0 { + return rest + } + return rest[:j] +} + +// --------------------------------------------------------------------------- +// aapt2: xmltree manifest parse (components, exported flags, intent filters, +// application flags) + a readable reconstruction for the Manifest tab. +// --------------------------------------------------------------------------- + +func (a *App) parseManifestTree(ctx context.Context, path string, audit *APKAudit) { + out, err := a.runBuildTool(ctx, "aapt2", "dump", "xmltree", path, "--file", "AndroidManifest.xml") + if err != nil || out == "" { + return + } + audit.ManifestXML = out + + // Frames store the component index (not a pointer) so appends to + // audit.Components can't leave us holding a stale pointer. + type frame struct { + indent int + name string + compIdx int // -1 when the element is not a component + } + var stack []frame + + curComp := func() int { + for i := len(stack) - 1; i >= 0; i-- { + if stack[i].compIdx >= 0 { + return stack[i].compIdx + } + } + return -1 + } + top := func() string { + if len(stack) == 0 { + return "" + } + return stack[len(stack)-1].name + } + + for _, raw := range strings.Split(out, "\n") { + indent := countIndent(raw) + line := strings.TrimSpace(raw) + + switch { + case strings.HasPrefix(line, "E:"): + for len(stack) > 0 && stack[len(stack)-1].indent >= indent { + stack = stack[:len(stack)-1] + } + elem := elementName(line) + switch elem { + case "activity", "activity-alias", "service", "receiver", "provider": + typ := elem + if typ == "activity-alias" { + typ = "activity" + } + audit.Components = append(audit.Components, Component{Type: typ}) + stack = append(stack, frame{indent: indent, name: elem, compIdx: len(audit.Components) - 1}) + case "intent-filter": + if ci := curComp(); ci >= 0 { + audit.Components[ci].IntentFilters = append(audit.Components[ci].IntentFilters, "") + } + stack = append(stack, frame{indent: indent, name: elem, compIdx: -1}) + default: + stack = append(stack, frame{indent: indent, name: elem, compIdx: -1}) + } + + case strings.HasPrefix(line, "A:"): + attr, val := manifestAttr(line) + switch top() { + case "uses-sdk": + if attr == "minSdkVersion" && audit.MinSDK == "" { + audit.MinSDK = val + } + if attr == "targetSdkVersion" && audit.TargetSDK == "" { + audit.TargetSDK = val + } + case "application": + switch attr { + case "debuggable": + audit.Debuggable = isTrue(val) + case "allowBackup": + audit.AllowBackup = isTrue(val) + case "usesCleartextTraffic": + audit.UsesCleartext = isTrue(val) + case "networkSecurityConfig": + audit.HasNSC = true + } + case "activity", "activity-alias", "service", "receiver", "provider": + if ci := curComp(); ci >= 0 { + switch attr { + case "name": + audit.Components[ci].Name = val + case "exported": + audit.Components[ci].Exported = isTrue(val) + audit.Components[ci].explicitExported = true + case "permission": + audit.Components[ci].Permission = val + } + } + case "action", "category": + if attr == "name" { + if ci := curComp(); ci >= 0 && len(audit.Components[ci].IntentFilters) > 0 { + idx := len(audit.Components[ci].IntentFilters) - 1 + sep := "" + if audit.Components[ci].IntentFilters[idx] != "" { + sep = ", " + } + audit.Components[ci].IntentFilters[idx] += sep + shortName(val) + } + } + } + } + } + + // Defaults the tree walk can't see: allowBackup defaults on when absent; + // cleartext defaults on for targetSdk < 28. + if !strings.Contains(out, "allowBackup") { + audit.AllowBackup = true + } + if !strings.Contains(out, "usesCleartextTraffic") { + if t := atoiSafe(audit.TargetSDK); t > 0 && t < 28 { + audit.UsesCleartext = true + } + } + + // Implicit export: an intent-filter present with no explicit android:exported + // means the component is reachable by other apps (pre-Android 12 behaviour). + for i := range audit.Components { + c := &audit.Components[i] + if !c.Exported && !c.explicitExported && len(c.IntentFilters) > 0 { + c.ExportedImplicit = true + } + } +} + +// --------------------------------------------------------------------------- +// Signing certificate +// --------------------------------------------------------------------------- + +// parseManifestHybrid uses aapt2 when present (reference parse), otherwise the +// pure-Go apkparser fallback. Both populate the same audit fields. +func (a *App) parseManifestHybrid(ctx context.Context, path string, audit *APKAudit) { + if a.hasBuildTool("aapt2") { + a.parseBadging(ctx, path, audit) + a.parseManifestTree(ctx, path, audit) + if audit.PackageName != "" { + return // aapt2 succeeded + } + } + parseManifestGo(path, audit) +} + +// parseCertHybrid resolves the signing certificate. The pure-Go x509 path +// (apkverifier) always owns cert *identity* — subject/issuer/serial/validity/ +// algorithm/fingerprints — because it is accurate, consistent across machines, +// and needs no JDK. When apksigner is available it additionally refines the +// authoritative per-scheme booleans (v1/v2/v3 reported independently, which +// apksigner does better than a single "highest scheme" number). +func (a *App) parseCertHybrid(ctx context.Context, path string, audit *APKAudit) { + parseCertGo(path, audit) + if a.hasBuildTool("apksigner") && findJBR() != "" { + a.refineSchemesApksigner(ctx, path, audit) + } + finalizeCert(audit) +} + +// refineSchemesApksigner overlays apksigner's authoritative verification result +// (verified + independent v1/v2/v3 flags) onto the Go-parsed cert. It ignores +// the Play "Source Stamp" signer, which is not the app's signing certificate. +func (a *App) refineSchemesApksigner(ctx context.Context, path string, audit *APKAudit) { + out, _ := a.runBuildToolJava(ctx, "apksigner", "verify", "--verbose", path) + if out == "" { + return + } + var v1, v2, v3, verifies, sawScheme bool + for _, line := range strings.Split(out, "\n") { + l := strings.TrimSpace(line) + if strings.Contains(l, "Source Stamp") { + continue + } + switch { + case l == "Verifies": + verifies = true + case strings.HasPrefix(l, "Verified using v1 scheme"): + v1 = strings.HasSuffix(l, "true") + sawScheme = true + case strings.HasPrefix(l, "Verified using v2 scheme"): + v2 = strings.HasSuffix(l, "true") + sawScheme = true + case strings.Contains(l, "v3 scheme"), strings.Contains(l, "v3.1 scheme"), strings.Contains(l, "v3.2 scheme"): + if strings.HasPrefix(l, "Verified using") && strings.HasSuffix(l, "true") { + v3 = true + } + sawScheme = true + } + } + if sawScheme { + audit.Cert.Verified = verifies + audit.Cert.V1, audit.Cert.V2, audit.Cert.V3 = v1, v2, v3 + } +} + +// --------------------------------------------------------------------------- +// ZIP / DEX scanning +// --------------------------------------------------------------------------- + +func (a *App) scanArchive(path string, audit *APKAudit) { + zr, err := zip.OpenReader(path) + if err != nil { + audit.addFinding(Finding{ + ID: "zip-open", Title: "APK archive could not be opened", Severity: "high", + Category: "code", Description: "The APK ZIP structure could not be read: " + err.Error(), + }) + return + } + defer zr.Close() + + hasManifestMF := false + candidates := make([]candidate, 0, 4096) + seen := make(map[string]struct{}, 4096) + trackerHits := map[string]int{} + + for _, f := range zr.File { + audit.Files = append(audit.Files, APKFileEntry{ + Path: f.Name, + Size: int64(f.UncompressedSize64), + Compressed: int64(f.CompressedSize64), + }) + if f.Name == "META-INF/MANIFEST.MF" { + hasManifestMF = true + } + + if strings.HasPrefix(f.Name, "classes") && strings.HasSuffix(f.Name, ".dex") { + if f.UncompressedSize64 > maxDexBytes { + continue + } + data := readZipEntry(f) + if data == nil { + continue + } + extractStrings(data, f.Name, &candidates, seen) + matchTrackers(data, trackerHits) + } + } + + sort.Slice(audit.Files, func(i, j int) bool { return audit.Files[i].Path < audit.Files[j].Path }) + + // Tracker findings. + for name, n := range trackerHits { + audit.Trackers = append(audit.Trackers, Tracker{ + Name: name, Category: trackerCategory[name], Matches: n, + }) + } + sort.Slice(audit.Trackers, func(i, j int) bool { return audit.Trackers[i].Name < audit.Trackers[j].Name }) + + // Code-pattern + secret rules over extracted strings. + a.applyStringRules(candidates, audit) + + // A missing JAR manifest only matters when the APK also fails to verify — + // v2/v3-only signed APKs legitimately have no META-INF/MANIFEST.MF. + audit.noManifestMF = !hasManifestMF +} + +// candidate is one extracted printable string and where it came from. +type candidate struct { + val string + file string +} + +// extractStrings pulls printable ASCII runs of length >= minStringLen out of a +// dex blob, de-duplicating globally, capped at maxCandidates. +func extractStrings(data []byte, file string, out *[]candidate, seen map[string]struct{}) { + var b strings.Builder + flush := func() { + if b.Len() >= minStringLen { + s := b.String() + if _, ok := seen[s]; !ok && len(*out) < maxCandidates { + seen[s] = struct{}{} + *out = append(*out, candidate{val: s, file: file}) + } + } + b.Reset() + } + for _, c := range data { + if c >= 0x20 && c < 0x7f { + b.WriteByte(c) + } else { + flush() + } + if len(*out) >= maxCandidates { + return + } + } + flush() +} + +func (a *App) applyStringRules(cands []candidate, audit *APKAudit) { + // Code/network/crypto/webview/storage rules: substring presence. + for _, rule := range codeRules { + var matches []FindingMatch + for _, c := range cands { + hit := false + for _, needle := range rule.needles { + if strings.Contains(c.val, needle) { + hit = true + break + } + } + if hit { + if len(matches) < maxMatchesPerRule { + matches = append(matches, FindingMatch{File: c.file, Value: truncate(c.val, 200)}) + } + } + } + if len(matches) > 0 { + audit.addFinding(Finding{ + ID: rule.id, Title: rule.title, Severity: rule.severity, Category: rule.category, + Description: rule.description, CWE: rule.cwe, Masvs: rule.masvs, + Confidence: rule.confidence, Matches: matches, + }) + } + } + + // Secret rules: regex + Shannon-entropy gate to suppress noise. + for _, rule := range secretRules { + var matches []FindingMatch + for _, c := range cands { + for _, m := range rule.re.FindAllString(c.val, -1) { + if rule.entropyMin > 0 && shannonEntropy(m) < rule.entropyMin { + continue + } + if len(matches) < maxMatchesPerRule { + matches = append(matches, FindingMatch{File: c.file, Value: redactSecret(m)}) + } + } + } + if len(matches) > 0 { + audit.addFinding(Finding{ + ID: rule.id, Title: rule.title, Severity: rule.severity, Category: "secret", + Description: rule.description, CWE: "CWE-798", Masvs: "MASVS-STORAGE-1", + Confidence: rule.confidence, Matches: matches, + }) + } + } +} + +func matchTrackers(data []byte, hits map[string]int) { + s := string(data) + for name, sigs := range trackerSignatures { + for _, sig := range sigs { + if c := strings.Count(s, sig); c > 0 { + hits[name] += c + break + } + } + } +} + +// --------------------------------------------------------------------------- +// Manifest-derived findings +// --------------------------------------------------------------------------- + +func (a *App) deriveManifestFindings(audit *APKAudit) { + if audit.Debuggable { + audit.addFinding(Finding{ + ID: "manifest-debuggable", Title: "Application is debuggable", Severity: "high", + Category: "manifest", Confidence: 100, CWE: "CWE-489", Masvs: "MASVS-RESILIENCE-2", + Description: "android:debuggable=\"true\" lets anyone attach a debugger and inspect/modify the running app.", + }) + } + if audit.AllowBackup { + audit.addFinding(Finding{ + ID: "manifest-allowbackup", Title: "Backups allowed (allowBackup)", Severity: "medium", + Category: "manifest", Confidence: 90, CWE: "CWE-530", Masvs: "MASVS-STORAGE-2", + Description: "android:allowBackup is enabled (or defaulted on). App data can be extracted over adb with `adb backup`.", + }) + } + if audit.UsesCleartext { + audit.addFinding(Finding{ + ID: "manifest-cleartext", Title: "Cleartext HTTP traffic permitted", Severity: "medium", + Category: "network", Confidence: 85, CWE: "CWE-319", Masvs: "MASVS-NETWORK-1", + Description: "Cleartext (unencrypted HTTP) traffic is allowed, exposing data to network interception.", + }) + } + if !audit.HasNSC { + audit.addFinding(Finding{ + ID: "manifest-no-nsc", Title: "No Network Security Config", Severity: "low", + Category: "network", Confidence: 60, CWE: "CWE-295", Masvs: "MASVS-NETWORK-2", + Description: "No networkSecurityConfig is declared, so the app relies on platform defaults (no pinning, no per-domain cleartext rules).", + }) + } + + var exported []FindingMatch + for _, c := range audit.Components { + if (c.Exported || c.ExportedImplicit) && c.Permission == "" { + label := c.Type + ": " + shortName(c.Name) + if c.ExportedImplicit { + label += " (implicit)" + } + exported = append(exported, FindingMatch{Value: label}) + } + } + if len(exported) > 0 { + if len(exported) > maxMatchesPerRule { + exported = exported[:maxMatchesPerRule] + } + audit.addFinding(Finding{ + ID: "exported-components", Title: "Exported components without permission", + Severity: "medium", Category: "manifest", Confidence: 80, CWE: "CWE-926", + Masvs: "MASVS-PLATFORM-1", + Description: "These components are reachable by other apps and declare no protecting permission.", + Matches: exported, + }) + } + + // Signing-derived findings. + toolMissing := strings.Contains(audit.Cert.Error, "not found") + if !audit.Cert.Verified && !toolMissing { + desc := "The APK signature does not verify" + if audit.Cert.Error != "" { + desc += " (" + audit.Cert.Error + ")" + } + desc += ". It is unsigned or was repacked without re-signing, so it cannot be installed on a stock device and its integrity is unverifiable." + sev := "high" + if audit.noManifestMF { + sev = "critical" + } + audit.addFinding(Finding{ + ID: "unsigned", Title: "APK is unsigned or fails verification", Severity: sev, + Category: "signing", Confidence: 95, CWE: "CWE-347", Masvs: "MASVS-CODE-1", + Description: desc, + }) + } + if audit.Cert.IsDebug { + audit.addFinding(Finding{ + ID: "cert-debug", Title: "Signed with a debug certificate", Severity: "high", + Category: "signing", Confidence: 95, CWE: "CWE-321", Masvs: "MASVS-CODE-1", + Description: "The APK is signed with the well-known Android debug key; anyone can forge a matching signature.", + }) + } + if audit.Cert.Expired { + audit.addFinding(Finding{ + ID: "cert-expired", Title: "Signing certificate is expired", Severity: "low", + Category: "signing", Confidence: 90, CWE: "CWE-298", + Description: "The signing certificate validity period has ended.", + }) + } + if audit.Cert.WeakAlgo { + audit.addFinding(Finding{ + ID: "cert-weak-algo", Title: "Weak signature algorithm", Severity: "medium", + Category: "signing", Confidence: 95, CWE: "CWE-327", Masvs: "MASVS-CRYPTO-1", + Description: "The certificate uses a weak signature algorithm (" + audit.Cert.SigAlgo + ").", + }) + } + if audit.Cert.Verified && audit.Cert.V1 && !audit.Cert.V2 && !audit.Cert.V3 { + audit.addFinding(Finding{ + ID: "cert-v1-only", Title: "v1-only signing (Janus exploit)", Severity: "medium", + Category: "signing", Confidence: 90, CWE: "CWE-347", Masvs: "MASVS-CODE-1", + Description: "Signed only with the v1 JAR scheme. On Android < 7.0 such APKs are vulnerable to the Janus exploit (CVE-2017-13156).", + }) + } +} + +// --------------------------------------------------------------------------- +// Scoring +// --------------------------------------------------------------------------- + +func (a *App) scoreAudit(audit *APKAudit) { + weights := map[string]int{"critical": 25, "high": 15, "medium": 8, "low": 3, "info": 0} + score := 100 + for _, f := range audit.Findings { + audit.Counts[f.Severity]++ + score -= weights[f.Severity] + } + if score < 0 { + score = 0 + } + audit.Score = score + switch { + case score >= 90: + audit.Grade = "A" + case score >= 75: + audit.Grade = "B" + case score >= 60: + audit.Grade = "C" + case score >= 40: + audit.Grade = "D" + default: + audit.Grade = "F" + } + + // stable severity-then-title ordering + order := map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + sort.SliceStable(audit.Findings, func(i, j int) bool { + if order[audit.Findings[i].Severity] != order[audit.Findings[j].Severity] { + return order[audit.Findings[i].Severity] < order[audit.Findings[j].Severity] + } + return audit.Findings[i].Title < audit.Findings[j].Title + }) +} + +func (audit *APKAudit) addFinding(f Finding) { + if f.Confidence == 0 { + f.Confidence = 80 + } + audit.Findings = append(audit.Findings, f) +} + +// --------------------------------------------------------------------------- +// Build-tool / java command runners +// --------------------------------------------------------------------------- + +// runBuildTool runs an SDK build-tool that does not need a JVM (aapt2). +func (a *App) runBuildTool(ctx context.Context, name string, args ...string) (string, error) { + bin, err := a.resolveBuildTool(name) + if err != nil { + return "", err + } + return runExternal(ctx, bin, nil, args...) +} + +// runBuildToolJava runs an SDK build-tool that needs a JVM (apksigner). +func (a *App) runBuildToolJava(ctx context.Context, name string, args ...string) (string, error) { + bin, err := a.resolveBuildTool(name) + if err != nil { + return "", err + } + return runExternal(ctx, bin, a.javaEnv(), args...) +} + +// javaEnv returns an environment with the JBR's java on PATH + JAVA_HOME set, +// so apksigner/keytool work even when no system JDK is installed. +func (a *App) javaEnv() []string { + jbr := findJBR() + if jbr == "" { + return nil + } + env := os.Environ() + env = append(env, "JAVA_HOME="+jbr) + env = append(env, "PATH="+filepath.Join(jbr, "bin")+string(os.PathListSeparator)+os.Getenv("PATH")) + return env +} + +// resolveBuildTool finds an SDK build-tool, preferring PATH then the newest +// build-tools directory under known SDK roots. +func (a *App) resolveBuildTool(name string) (string, error) { + a.cacheMutex.RLock() + if c, ok := a.binaryCache["bt:"+name]; ok { + a.cacheMutex.RUnlock() + return c, nil + } + a.cacheMutex.RUnlock() + + var candidates []string + if p := lookPath(name); p != "" { + candidates = append(candidates, p) + } + for _, bt := range buildToolsDirs() { + candidates = append(candidates, filepath.Join(bt, name)) + } + for _, c := range candidates { + if info, err := os.Stat(c); err == nil && !info.IsDir() { + abs, _ := filepath.Abs(c) + a.cacheMutex.Lock() + a.binaryCache["bt:"+name] = abs + a.cacheMutex.Unlock() + return abs, nil + } + } + return "", fmt.Errorf("%s not found — install Android SDK build-tools (e.g. sdkmanager \"build-tools;37.0.0\")", name) +} + +// buildToolsDirs returns build-tools version dirs, newest first, across SDK roots. +func buildToolsDirs() []string { + var roots []string + for _, env := range []string{"ANDROID_HOME", "ANDROID_SDK_ROOT"} { + if v := os.Getenv(env); v != "" { + roots = append(roots, v) + } + } + if home, err := os.UserHomeDir(); err == nil { + roots = append(roots, + filepath.Join(home, "Android", "Sdk"), + filepath.Join(home, "Library", "Android", "sdk"), + ) + } + var dirs []string + for _, r := range roots { + bt := filepath.Join(r, "build-tools") + entries, err := os.ReadDir(bt) + if err != nil { + continue + } + var versions []string + for _, e := range entries { + if e.IsDir() { + versions = append(versions, e.Name()) + } + } + sort.Sort(sort.Reverse(sort.StringSlice(versions))) + for _, v := range versions { + dirs = append(dirs, filepath.Join(bt, v)) + } + } + return dirs +} + +// findJBR locates a JBR/JDK home (for apksigner/keytool). Prefers Android +// Studio's bundled JBR, matching the project's build recipe. +func findJBR() string { + if v := os.Getenv("JAVA_HOME"); v != "" { + if _, err := os.Stat(filepath.Join(v, "bin", "java")); err == nil { + return v + } + } + home, _ := os.UserHomeDir() + globs := []string{ + filepath.Join(home, "Documents", "android-studio*", "android-studio", "jbr"), + filepath.Join(home, "android-studio", "jbr"), + "/opt/android-studio/jbr", + "/usr/lib/jvm/*/", + } + for _, g := range globs { + matches, _ := filepath.Glob(g) + for _, m := range matches { + if _, err := os.Stat(filepath.Join(m, "bin", "java")); err == nil { + return strings.TrimRight(m, "/") + } + } + } + if p := lookPath("java"); p != "" { + // java is .../bin/java → JAVA_HOME is two levels up + return filepath.Dir(filepath.Dir(p)) + } + return "" +} diff --git a/backend_apkaudit_export.go b/backend_apkaudit_export.go new file mode 100644 index 0000000..9c0a736 --- /dev/null +++ b/backend_apkaudit_export.go @@ -0,0 +1,350 @@ +package main + +// Explorer entry viewer + findings export (JSON / CSV / SARIF) for the APK auditor. + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + entryTextCap = 1 << 20 // 1 MB of text shown + entryImageCap = 8 << 20 // 8 MB max image + entryHexCap = 16 << 10 // 16 KB hex preview +) + +type APKEntryContent struct { + Name string `json:"name"` + Size int64 `json:"size"` + Kind string `json:"kind"` // text | image | binary + Mime string `json:"mime"` + Text string `json:"text"` + Base64 string `json:"base64"` + Hex string `json:"hex"` + Truncated bool `json:"truncated"` +} + +// ReadAPKEntry opens a single entry inside an APK and returns a viewable form: +// text, base64-encoded image, or a hex preview for binaries. +func (a *App) ReadAPKEntry(apkPath, entry string) (APKEntryContent, error) { + if apkPath == "" || entry == "" { + return APKEntryContent{}, fmt.Errorf("missing apk path or entry name") + } + if _, err := os.Stat(apkPath); err != nil { + return APKEntryContent{}, fmt.Errorf("APK no longer available: %s", apkPath) + } + zr, err := zip.OpenReader(apkPath) + if err != nil { + return APKEntryContent{}, fmt.Errorf("open apk: %w", err) + } + defer zr.Close() + + var f *zip.File + for _, e := range zr.File { + if e.Name == entry { + f = e + break + } + } + if f == nil { + return APKEntryContent{}, fmt.Errorf("entry not found: %s", entry) + } + + res := APKEntryContent{Name: entry, Size: int64(f.UncompressedSize64), Mime: mimeForName(entry)} + + if isImageName(entry) { + data, _ := readEntryBytes(f, entryImageCap) + res.Kind = "image" + res.Base64 = base64.StdEncoding.EncodeToString(data) + res.Truncated = int64(len(data)) < res.Size + return res, nil + } + + data, truncated := readEntryBytes(f, entryTextCap) + if isTextBytes(data) { + res.Kind = "text" + res.Text = string(data) + res.Truncated = truncated + return res, nil + } + + // binary: hex preview of the first chunk + preview := data + if len(preview) > entryHexCap { + preview = preview[:entryHexCap] + truncated = true + } + res.Kind = "binary" + res.Hex = hexDump(preview) + res.Truncated = truncated || int64(len(data)) < res.Size + return res, nil +} + +// ExportAudit writes the audit to disk in the requested format via a save +// dialog and returns the chosen path ("" if the user cancelled). +func (a *App) ExportAudit(audit APKAudit, format string) (string, error) { + var content []byte + var ext string + switch strings.ToLower(format) { + case "json": + ext = "json" + b, err := json.MarshalIndent(audit, "", " ") + if err != nil { + return "", err + } + content = b + case "csv": + ext = "csv" + content = []byte(auditToCSV(audit)) + case "sarif": + ext = "sarif" + b, err := json.MarshalIndent(auditToSARIF(audit), "", " ") + if err != nil { + return "", err + } + content = b + default: + return "", fmt.Errorf("unknown export format: %s", format) + } + + base := audit.PackageName + if base == "" { + base = strings.TrimSuffix(audit.FileName, filepath.Ext(audit.FileName)) + } + if base == "" { + base = "apk-audit" + } + path, err := a.SelectSaveFile(base + "-audit." + ext) + if err != nil || path == "" { + return "", err + } + if err := os.WriteFile(path, content, 0o644); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return path, nil +} + +// --------------------------------------------------------------------------- +// Export builders +// --------------------------------------------------------------------------- + +func auditToCSV(audit APKAudit) string { + var buf bytes.Buffer + w := csv.NewWriter(&buf) + _ = w.Write([]string{"severity", "category", "title", "cwe", "masvs", "confidence", "file", "match"}) + for _, f := range audit.Findings { + conf := strconv.Itoa(f.Confidence) + if len(f.Matches) == 0 { + _ = w.Write([]string{f.Severity, f.Category, f.Title, f.CWE, f.Masvs, conf, "", ""}) + continue + } + for _, m := range f.Matches { + _ = w.Write([]string{f.Severity, f.Category, f.Title, f.CWE, f.Masvs, conf, m.File, m.Value}) + } + } + w.Flush() + return buf.String() +} + +// auditToSARIF emits a minimal SARIF 2.1.0 log suitable for GitHub code scanning. +func auditToSARIF(audit APKAudit) map[string]any { + levelFor := func(sev string) string { + switch sev { + case "critical", "high": + return "error" + case "medium": + return "warning" + default: + return "note" + } + } + + seenRule := map[string]bool{} + var rules []map[string]any + var results []map[string]any + + for _, f := range audit.Findings { + if !seenRule[f.ID] { + seenRule[f.ID] = true + rule := map[string]any{ + "id": f.ID, + "name": f.Title, + "shortDescription": map[string]any{"text": f.Title}, + "fullDescription": map[string]any{"text": f.Description}, + "defaultConfiguration": map[string]any{"level": levelFor(f.Severity)}, + "properties": map[string]any{ + "cwe": f.CWE, + "masvs": f.Masvs, + "severity": f.Severity, + }, + } + rules = append(rules, rule) + } + + locations := []map[string]any{} + for _, m := range f.Matches { + uri := m.File + if uri == "" { + uri = audit.FileName + } + locations = append(locations, map[string]any{ + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{"uri": uri}, + }, + "message": map[string]any{"text": m.Value}, + }) + } + if len(locations) == 0 { + locations = append(locations, map[string]any{ + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{"uri": audit.FileName}, + }, + }) + } + + results = append(results, map[string]any{ + "ruleId": f.ID, + "level": levelFor(f.Severity), + "message": map[string]any{"text": f.Title + " — " + f.Description}, + "locations": locations, + }) + } + + return map[string]any{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": []map[string]any{{ + "tool": map[string]any{ + "driver": map[string]any{ + "name": "ATK APK Auditor", + "informationUri": "https://github.com/jegly/ATK", + "rules": rules, + }, + }, + "properties": map[string]any{ + "package": audit.PackageName, + "version": audit.VersionName, + "score": audit.Score, + "grade": audit.Grade, + }, + "results": results, + }}, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func cleanStaleAuditTemps() { + matches, _ := filepath.Glob(filepath.Join(os.TempDir(), "atk-audit-*.apk")) + for _, m := range matches { + os.Remove(m) + } +} + +func readEntryBytes(f *zip.File, limit int) ([]byte, bool) { + rc, err := f.Open() + if err != nil { + return nil, false + } + defer rc.Close() + data, err := io.ReadAll(io.LimitReader(rc, int64(limit)+1)) + if err != nil { + return data, false + } + if len(data) > limit { + return data[:limit], true + } + return data, false +} + +func isTextBytes(data []byte) bool { + if len(data) == 0 { + return true + } + nonprint := 0 + for _, b := range data { + if b == 0 { + return false + } + if b < 0x09 || (b > 0x0d && b < 0x20) { + nonprint++ + } + } + return float64(nonprint)/float64(len(data)) < 0.05 +} + +func hexDump(data []byte) string { + var b strings.Builder + for i := 0; i < len(data); i += 16 { + end := i + 16 + if end > len(data) { + end = len(data) + } + row := data[i:end] + b.WriteString(fmt.Sprintf("%08x ", i)) + for j := 0; j < 16; j++ { + if j < len(row) { + b.WriteString(fmt.Sprintf("%02x ", row[j])) + } else { + b.WriteString(" ") + } + if j == 7 { + b.WriteByte(' ') + } + } + b.WriteString(" |") + for _, c := range row { + if c >= 0x20 && c < 0x7f { + b.WriteByte(c) + } else { + b.WriteByte('.') + } + } + b.WriteString("|\n") + } + return b.String() +} + +func isImageName(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico": + return true + } + return false +} + +func mimeForName(name string) string { + switch strings.ToLower(filepath.Ext(name)) { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".bmp": + return "image/bmp" + case ".ico": + return "image/x-icon" + case ".svg": + return "image/svg+xml" + case ".json": + return "application/json" + case ".xml": + return "text/xml" + default: + return "application/octet-stream" + } +} diff --git a/backend_apkaudit_purego.go b/backend_apkaudit_purego.go new file mode 100644 index 0000000..a619ece --- /dev/null +++ b/backend_apkaudit_purego.go @@ -0,0 +1,215 @@ +package main + +// Pure-Go fallback parsers for the APK auditor, used when the Android SDK +// build-tools (aapt2 / apksigner) or a JDK are not installed. This makes the +// audit fully self-contained for users who only have adb/fastboot. +// +// Reference path (aapt2 + apksigner) stays the default when those tools are +// present; these functions are only invoked as a fallback. + +import ( + "bytes" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "strings" + "time" + + "github.com/avast/apkparser" + "github.com/avast/apkverifier" +) + +// hasBuildTool reports whether a build-tool can be resolved on this machine. +func (a *App) hasBuildTool(name string) bool { + _, err := a.resolveBuildTool(name) + return err == nil +} + +// parseManifestGo decodes the binary AndroidManifest.xml with apkparser and +// fills the same audit fields the aapt2 path would (minus the resource-resolved +// app label, which needs the resource table). +func parseManifestGo(path string, audit *APKAudit) { + var buf bytes.Buffer + enc := xml.NewEncoder(&buf) + enc.Indent("", " ") + zipErr, _, _ := apkparser.ParseApk(path, enc) + _ = enc.Flush() + if zipErr != nil { + return + } + audit.ManifestXML = buf.String() + + dec := xml.NewDecoder(strings.NewReader(audit.ManifestXML)) + compIdx := -1 + allowBackupSeen, cleartextSeen := false, false + + for { + tok, err := dec.Token() + if err != nil { + break + } + switch t := tok.(type) { + case xml.StartElement: + attr := func(k string) string { + for _, a := range t.Attr { + if a.Name.Local == k { + return a.Value + } + } + return "" + } + has := func(k string) bool { + for _, a := range t.Attr { + if a.Name.Local == k { + return true + } + } + return false + } + + switch t.Name.Local { + case "manifest": + audit.PackageName = attr("package") + if v := attr("versionName"); v != "" { + audit.VersionName = v + } + if v := attr("versionCode"); v != "" { + audit.VersionCode = v + } + if v := attr("compileSdkVersion"); v != "" { + audit.CompileSDK = v + } + case "uses-sdk": + if v := attr("minSdkVersion"); v != "" { + audit.MinSDK = v + } + if v := attr("targetSdkVersion"); v != "" { + audit.TargetSDK = v + } + case "uses-permission", "uses-permission-sdk-23": + if n := attr("name"); n != "" { + audit.Permissions = append(audit.Permissions, Permission{Name: n, Dangerous: dangerousPermissions[n]}) + } + case "application": + if has("debuggable") { + audit.Debuggable = isTrue(attr("debuggable")) + } + if has("allowBackup") { + allowBackupSeen = true + audit.AllowBackup = isTrue(attr("allowBackup")) + } + if has("usesCleartextTraffic") { + cleartextSeen = true + audit.UsesCleartext = isTrue(attr("usesCleartextTraffic")) + } + if has("networkSecurityConfig") { + audit.HasNSC = true + } + case "activity", "activity-alias", "service", "receiver", "provider": + typ := t.Name.Local + if typ == "activity-alias" { + typ = "activity" + } + c := Component{Type: typ, Name: attr("name"), Permission: attr("permission")} + if has("exported") { + c.Exported = isTrue(attr("exported")) + c.explicitExported = true + } + audit.Components = append(audit.Components, c) + compIdx = len(audit.Components) - 1 + case "intent-filter": + if compIdx >= 0 { + audit.Components[compIdx].IntentFilters = append(audit.Components[compIdx].IntentFilters, "") + } + case "action", "category": + if n := attr("name"); n != "" && compIdx >= 0 && len(audit.Components[compIdx].IntentFilters) > 0 { + idx := len(audit.Components[compIdx].IntentFilters) - 1 + sep := "" + if audit.Components[compIdx].IntentFilters[idx] != "" { + sep = ", " + } + audit.Components[compIdx].IntentFilters[idx] += sep + shortName(n) + } + } + + case xml.EndElement: + switch t.Name.Local { + case "activity", "activity-alias", "service", "receiver", "provider": + compIdx = -1 + } + } + } + + // defaults the manifest may omit + if !allowBackupSeen { + audit.AllowBackup = true + } + if !cleartextSeen { + if v := atoiSafe(audit.TargetSDK); v > 0 && v < 28 { + audit.UsesCleartext = true + } + } + for i := range audit.Components { + c := &audit.Components[i] + if !c.Exported && !c.explicitExported && len(c.IntentFilters) > 0 { + c.ExportedImplicit = true + } + } +} + +// parseCertGo extracts and verifies the signing certificate with apkverifier. +func parseCertGo(path string, audit *APKAudit) { + res, err := apkverifier.Verify(path, nil) + if err != nil { + audit.Cert.Error = firstLine(err.Error()) + } + + if len(res.SignerCerts) > 0 && len(res.SignerCerts[0]) > 0 { + leaf := res.SignerCerts[0][0] + audit.Cert.Verified = err == nil + audit.Cert.Subject = leaf.Subject.String() + audit.Cert.Issuer = leaf.Issuer.String() + audit.Cert.Serial = leaf.SerialNumber.String() + audit.Cert.SigAlgo = leaf.SignatureAlgorithm.String() + audit.Cert.ValidFrom = leaf.NotBefore.Format("2006-01-02") + audit.Cert.ValidTo = leaf.NotAfter.Format("2006-01-02") + s256 := sha256.Sum256(leaf.Raw) + audit.Cert.SHA256 = hex.EncodeToString(s256[:]) + s1 := sha1.Sum(leaf.Raw) + audit.Cert.SHA1 = hex.EncodeToString(s1[:]) + } + + // Only claim a signing scheme when verification actually succeeded — this + // matches apksigner, which reports all-false for unsigned/broken APKs. + // (apkverifier otherwise defaults SchemeId to 1 even when nothing verifies.) + if err == nil { + switch { + case res.SigningSchemeId >= 3: // 3 or 3.1 + audit.Cert.V3 = true + case res.SigningSchemeId == 2: + audit.Cert.V2 = true + case res.SigningSchemeId == 1: + audit.Cert.V1 = true + } + } +} + +// finalizeCert derives debug/weak/expired flags from whichever path populated +// the cert fields, so both reference and fallback paths behave the same. +func finalizeCert(audit *APKAudit) { + subjIssuer := audit.Cert.Subject + " " + audit.Cert.Issuer + if strings.Contains(strings.ToLower(subjIssuer), "android debug") || + strings.Contains(subjIssuer, "CN=Android Debug") { + audit.Cert.IsDebug = true + } + algoUp := strings.ToUpper(audit.Cert.SigAlgo) + if strings.Contains(algoUp, "MD5") || strings.Contains(algoUp, "SHA1") || strings.Contains(algoUp, "SHA-1") { + audit.Cert.WeakAlgo = true + } + if audit.Cert.ValidTo != "" { + if t, ok := parseCertTime(audit.Cert.ValidTo); ok && t.Before(time.Now()) { + audit.Cert.Expired = true + } + } +} diff --git a/backend_apkaudit_rules.go b/backend_apkaudit_rules.go new file mode 100644 index 0000000..ea27fe2 --- /dev/null +++ b/backend_apkaudit_rules.go @@ -0,0 +1,430 @@ +package main + +// Rule data and small helpers for the APK auditor. The rule set is authored +// from scratch (CWE / OWASP-MASVS taxonomy is public). Expand freely. + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "math" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Rule tables +// --------------------------------------------------------------------------- + +type codeRule struct { + id, title, severity, category, description, cwe, masvs string + confidence int + needles []string +} + +// codeRules match by substring presence in DEX-extracted strings (method/class +// names and string constants surface here). +var codeRules = []codeRule{ + { + id: "crypto-weak-hash", title: "Weak hash algorithm (MD5/SHA-1)", severity: "medium", + category: "crypto", cwe: "CWE-327", masvs: "MASVS-CRYPTO-1", confidence: 55, + description: "References to MD5 or SHA-1, which are unsuitable for security-sensitive hashing.", + needles: []string{"MD5", "SHA-1", "SHA1"}, + }, + { + id: "crypto-ecb", title: "ECB cipher mode", severity: "high", + category: "crypto", cwe: "CWE-327", masvs: "MASVS-CRYPTO-1", confidence: 80, + description: "AES/DES in ECB mode leaks plaintext structure; use an authenticated mode (GCM).", + needles: []string{"AES/ECB", "DES/ECB", "/ECB/"}, + }, + { + id: "crypto-des-rc4", title: "Obsolete cipher (DES/RC4)", severity: "high", + category: "crypto", cwe: "CWE-327", masvs: "MASVS-CRYPTO-1", confidence: 70, + description: "DES/3DES/RC4 are broken or deprecated ciphers.", + needles: []string{"DES/", "DESede", "RC4", "ARCFOUR"}, + }, + { + id: "net-cleartext-url", title: "Hardcoded cleartext HTTP URL", severity: "low", + category: "network", cwe: "CWE-319", masvs: "MASVS-NETWORK-1", confidence: 50, + description: "Plain http:// endpoints found in code.", + needles: []string{"http://"}, + }, + { + id: "net-trustall", title: "Permissive TLS trust / hostname verifier", severity: "high", + category: "network", cwe: "CWE-295", masvs: "MASVS-NETWORK-2", confidence: 65, + description: "Custom TrustManager or hostname verifier bypass can disable certificate validation.", + needles: []string{"ALLOW_ALL_HOSTNAME_VERIFIER", "X509TrustManager", "checkServerTrusted", "setHostnameVerifier", "TrustAllCerts", "NullHostnameVerifier"}, + }, + { + id: "webview-js", title: "WebView JavaScript / bridge", severity: "medium", + category: "webview", cwe: "CWE-749", masvs: "MASVS-PLATFORM-2", confidence: 55, + description: "addJavascriptInterface / setJavaScriptEnabled exposes a JS↔native bridge; risky with untrusted content.", + needles: []string{"addJavascriptInterface", "setJavaScriptEnabled", "setAllowFileAccess", "setAllowUniversalAccessFromFileURLs"}, + }, + { + id: "storage-world", title: "World-readable/writable storage mode", severity: "high", + category: "storage", cwe: "CWE-276", masvs: "MASVS-STORAGE-2", confidence: 70, + description: "MODE_WORLD_READABLE/WRITABLE exposes private files to other apps.", + needles: []string{"MODE_WORLD_READABLE", "MODE_WORLD_WRITEABLE", "MODE_WORLD_WRITABLE"}, + }, + { + id: "storage-extsd", title: "External storage use", severity: "low", + category: "storage", cwe: "CWE-922", masvs: "MASVS-STORAGE-2", confidence: 40, + description: "Reads/writes to shared external storage, which other apps may access.", + needles: []string{"getExternalStorageDirectory", "getExternalStoragePublicDirectory"}, + }, + { + id: "code-runtime-exec", title: "Runtime command execution", severity: "medium", + category: "code", cwe: "CWE-78", masvs: "MASVS-CODE-4", confidence: 50, + description: "Runtime.exec / ProcessBuilder can run shell commands; dangerous with untrusted input.", + needles: []string{"Runtime;->exec", "Runtime.getRuntime", "ProcessBuilder"}, + }, + { + id: "code-dynamic-load", title: "Dynamic code loading", severity: "medium", + category: "code", cwe: "CWE-494", masvs: "MASVS-CODE-2", confidence: 55, + description: "DexClassLoader / loadClass can load code at runtime, complicating integrity guarantees.", + needles: []string{"DexClassLoader", "PathClassLoader", "loadDex", "System.load"}, + }, + { + id: "code-reflection", title: "Reflection", severity: "info", + category: "code", cwe: "CWE-470", masvs: "MASVS-CODE-2", confidence: 40, + description: "Heavy reflection usage; often benign but used to hide behaviour.", + needles: []string{"java.lang.reflect", "getDeclaredMethod", "setAccessible"}, + }, + { + id: "code-root-check", title: "Root / emulator detection strings", severity: "info", + category: "code", cwe: "", masvs: "MASVS-RESILIENCE-1", confidence: 45, + description: "References to su/Magisk/test-keys suggest root or emulator detection.", + needles: []string{"/system/bin/su", "/system/xbin/su", "Superuser", "magisk", "test-keys", "/sbin/su"}, + }, + { + id: "sql-raw", title: "Raw SQL query", severity: "low", + category: "storage", cwe: "CWE-89", masvs: "MASVS-CODE-4", confidence: 35, + description: "rawQuery/execSQL with concatenated input risks SQL injection.", + needles: []string{"rawQuery", "execSQL"}, + }, +} + +type secretRule struct { + id, title, severity, description string + confidence int + entropyMin float64 + re *regexp.Regexp +} + +var secretRules = []secretRule{ + {id: "secret-aws", title: "AWS access key ID", severity: "critical", confidence: 90, + description: "An AWS access key ID was found embedded in the code.", + re: regexp.MustCompile(`AKIA[0-9A-Z]{16}`)}, + {id: "secret-google-api", title: "Google API key", severity: "high", confidence: 80, + description: "A Google API key (AIza...) was found.", entropyMin: 3.0, + re: regexp.MustCompile(`AIza[0-9A-Za-z_\-]{35}`)}, + {id: "secret-stripe", title: "Stripe secret/live key", severity: "critical", confidence: 90, + description: "A Stripe live/secret key was found.", + re: regexp.MustCompile(`(?:sk|rk)_live_[0-9A-Za-z]{20,}`)}, + {id: "secret-github", title: "GitHub token", severity: "critical", confidence: 90, + description: "A GitHub personal access / app token was found.", + re: regexp.MustCompile(`gh[posru]_[0-9A-Za-z]{36,}`)}, + {id: "secret-slack", title: "Slack token", severity: "high", confidence: 85, + description: "A Slack token was found.", + re: regexp.MustCompile(`xox[baprs]-[0-9A-Za-z\-]{10,}`)}, + {id: "secret-twilio", title: "Twilio account SID", severity: "high", confidence: 80, + description: "A Twilio account SID was found.", + re: regexp.MustCompile(`AC[0-9a-fA-F]{32}`)}, + {id: "secret-jwt", title: "JSON Web Token", severity: "medium", confidence: 60, + description: "A JWT was found; may embed sensitive claims.", entropyMin: 3.5, + re: regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{4,}`)}, + {id: "secret-pem", title: "Private key (PEM)", severity: "critical", confidence: 95, + description: "A PEM private-key header was found embedded in the APK.", + re: regexp.MustCompile(`-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----`)}, + {id: "secret-firebase-db", title: "Firebase database URL", severity: "low", confidence: 60, + description: "A Firebase Realtime Database URL was found; check its rules are not public.", + re: regexp.MustCompile(`https://[a-z0-9\-]+\.firebaseio\.com`)}, +} + +// trackerSignatures maps an SDK name to DEX path fragments that identify it. +var trackerSignatures = map[string][]string{ + "Google Firebase": {"com/google/firebase"}, + "Google AdMob": {"com/google/android/gms/ads"}, + "Google Analytics": {"com/google/android/gms/analytics", "com/google/analytics"}, + "Google Crashlytics": {"com/google/firebase/crashlytics", "com/crashlytics"}, + "Facebook SDK": {"com/facebook/"}, + "Branch": {"io/branch/"}, + "AppsFlyer": {"com/appsflyer"}, + "Adjust": {"com/adjust/sdk"}, + "Mixpanel": {"com/mixpanel"}, + "Amplitude": {"com/amplitude"}, + "Segment": {"com/segment/analytics"}, + "Flurry": {"com/flurry"}, + "OneSignal": {"com/onesignal"}, + "Bugsnag": {"com/bugsnag"}, + "Sentry": {"io/sentry/"}, + "Unity Ads": {"com/unity3d/ads"}, + "AppLovin": {"com/applovin"}, + "ironSource": {"com/ironsource"}, + "Tapjoy": {"com/tapjoy"}, + "Chartboost": {"com/chartboost"}, + "Vungle": {"com/vungle"}, + "InMobi": {"com/inmobi"}, + "MoPub": {"com/mopub"}, + "Yandex Metrica": {"com/yandex/metrica"}, + "Kochava": {"com/kochava"}, + "Singular": {"com/singular/sdk"}, + "Braze": {"com/appboy", "com/braze"}, + "Localytics": {"com/localytics"}, + "ComScore": {"com/comscore"}, + "Tencent Bugly": {"com/tencent/bugly"}, + "Umeng": {"com/umeng"}, +} + +var trackerCategory = map[string]string{ + "Google Firebase": "Analytics", "Google AdMob": "Advertising", "Google Analytics": "Analytics", + "Google Crashlytics": "Crash reporting", "Facebook SDK": "Analytics", "Branch": "Attribution", + "AppsFlyer": "Attribution", "Adjust": "Attribution", "Mixpanel": "Analytics", + "Amplitude": "Analytics", "Segment": "Analytics", "Flurry": "Analytics", + "OneSignal": "Push/Analytics", "Bugsnag": "Crash reporting", "Sentry": "Crash reporting", + "Unity Ads": "Advertising", "AppLovin": "Advertising", "ironSource": "Advertising", + "Tapjoy": "Advertising", "Chartboost": "Advertising", "Vungle": "Advertising", + "InMobi": "Advertising", "MoPub": "Advertising", "Yandex Metrica": "Analytics", + "Kochava": "Attribution", "Singular": "Attribution", "Braze": "Marketing", + "Localytics": "Analytics", "ComScore": "Analytics", "Tencent Bugly": "Crash reporting", + "Umeng": "Analytics", +} + +// dangerousPermissions is the runtime-permission set (Android dangerous group). +var dangerousPermissions = map[string]bool{ + "android.permission.READ_CALENDAR": true, "android.permission.WRITE_CALENDAR": true, + "android.permission.CAMERA": true, + "android.permission.READ_CONTACTS": true, "android.permission.WRITE_CONTACTS": true, + "android.permission.GET_ACCOUNTS": true, + "android.permission.ACCESS_FINE_LOCATION": true, "android.permission.ACCESS_COARSE_LOCATION": true, + "android.permission.ACCESS_BACKGROUND_LOCATION": true, + "android.permission.RECORD_AUDIO": true, + "android.permission.READ_PHONE_STATE": true, "android.permission.READ_PHONE_NUMBERS": true, + "android.permission.CALL_PHONE": true, "android.permission.ANSWER_PHONE_CALLS": true, + "android.permission.READ_CALL_LOG": true, "android.permission.WRITE_CALL_LOG": true, + "android.permission.ADD_VOICEMAIL": true, "android.permission.USE_SIP": true, + "android.permission.BODY_SENSORS": true, + "android.permission.SEND_SMS": true, "android.permission.RECEIVE_SMS": true, + "android.permission.READ_SMS": true, "android.permission.RECEIVE_WAP_PUSH": true, + "android.permission.RECEIVE_MMS": true, + "android.permission.READ_EXTERNAL_STORAGE": true, "android.permission.WRITE_EXTERNAL_STORAGE": true, + "android.permission.READ_MEDIA_IMAGES": true, "android.permission.READ_MEDIA_VIDEO": true, + "android.permission.READ_MEDIA_AUDIO": true, + "android.permission.POST_NOTIFICATIONS": true, + "android.permission.BLUETOOTH_SCAN": true, "android.permission.BLUETOOTH_CONNECT": true, + "android.permission.BLUETOOTH_ADVERTISE": true, + "android.permission.ACTIVITY_RECOGNITION": true, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// runExternal executes a binary with optional custom env, returning trimmed stdout. +func runExternal(ctx context.Context, bin string, env []string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, bin, args...) + setCommandSysProcAttr(cmd) + if env != nil { + cmd.Env = env + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + out := strings.TrimSpace(stdout.String()) + if err != nil { + if out != "" { + return out, nil // some tools exit non-zero but print useful output (e.g. apksigner DOES NOT VERIFY) + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", &runError{msg} + } + return out, nil +} + +type runError struct{ msg string } + +func (e *runError) Error() string { return e.msg } + +func lookPath(name string) string { + if p, err := exec.LookPath(name); err == nil { + return p + } + return "" +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func readZipEntry(f *zip.File) []byte { + rc, err := f.Open() + if err != nil { + return nil + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + return nil + } + return data +} + +// shannonEntropy returns the per-character Shannon entropy (bits) of s. +func shannonEntropy(s string) float64 { + if s == "" { + return 0 + } + var freq [256]float64 + for i := 0; i < len(s); i++ { + freq[s[i]]++ + } + n := float64(len(s)) + var h float64 + for _, c := range freq { + if c == 0 { + continue + } + p := c / n + h -= p * math.Log2(p) + } + return h +} + +func redactSecret(s string) string { + if len(s) <= 10 { + return s + } + return s[:6] + "…" + s[len(s)-4:] +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func isTrue(v string) bool { + v = strings.TrimSpace(strings.ToLower(v)) + return v == "true" || v == "0xffffffff" || v == "-1" || v == "1" +} + +func atoiSafe(s string) int { + n, _ := strconv.Atoi(strings.TrimSpace(s)) + return n +} + +func sanitizeFileToken(s string) string { + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { + return r + } + return '_' + }, s) +} + +func shortName(fqcn string) string { + if i := strings.LastIndex(fqcn, "."); i >= 0 && i < len(fqcn)-1 { + // keep a leading dot (relative names) readable + if strings.HasPrefix(fqcn, ".") { + return fqcn + } + return fqcn[i+1:] + } + return fqcn +} + +// countIndent returns leading-space count of a line. +func countIndent(s string) int { + n := 0 + for _, c := range s { + if c == ' ' { + n++ + } else { + break + } + } + return n +} + +// elementName extracts the tag name from an "E: name (line=..)" xmltree line. +func elementName(line string) string { + line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "E:")) + if i := strings.Index(line, " "); i >= 0 { + line = line[:i] + } + return strings.TrimSpace(line) +} + +// manifestAttr parses an "A: ns:attr(0xhex)=value (Raw: ..)" xmltree line into +// a bare attribute name and a cleaned value. +func manifestAttr(line string) (string, string) { + line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "A:")) + eq := strings.Index(line, "=") + if eq < 0 { + return "", "" + } + name := strings.TrimSpace(line[:eq]) + val := strings.TrimSpace(line[eq+1:]) + + // strip "(0x...)" hex id from name and any namespace prefix + if p := strings.Index(name, "("); p >= 0 { + name = name[:p] + } + if c := strings.LastIndex(name, ":"); c >= 0 { + name = name[c+1:] + } + + // prefer the Raw: "..." form when present + if r := strings.Index(val, "(Raw: \""); r >= 0 { + rest := val[r+len("(Raw: \""):] + if e := strings.Index(rest, "\""); e >= 0 { + return name, rest[:e] + } + } + val = strings.Trim(val, "\"") + return name, val +} + +func parseCertTime(s string) (time.Time, bool) { + layouts := []string{ + "Mon Jan 02 15:04:05 MST 2006", + "Mon Jan 2 15:04:05 MST 2006", + "Jan 2, 2006", + "2006-01-02", + } + for _, l := range layouts { + if t, err := time.Parse(l, strings.TrimSpace(s)); err == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/backend_apkaudit_test.go b/backend_apkaudit_test.go new file mode 100644 index 0000000..e16d71d --- /dev/null +++ b/backend_apkaudit_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" +) + +// TestAuditMiniAPK is a manual smoke test against a real APK on disk. +// Run: go test -run TestAuditMiniAPK -v +func TestAuditMiniAPK(t *testing.T) { + apk := os.Getenv("AUDIT_APK") + if apk == "" { + apk = "/home/xyz/.local/share/apktool/framework/1.apk" + } + if _, err := os.Stat(apk); err != nil { + t.Skipf("test apk not present: %v", err) + } + + app := NewApp() + res, err := app.AuditAPK(apk) + if err != nil { + t.Fatalf("AuditAPK error: %v", err) + } + dumpAudit(t, res) + + // Explorer: read the (binary) manifest and a resource entry. + ent, err := app.ReadAPKEntry(res.LocalPath, "AndroidManifest.xml") + if err != nil { + t.Errorf("ReadAPKEntry manifest: %v", err) + } else { + fmt.Printf("\nentry AndroidManifest.xml: kind=%s size=%d truncated=%v hexlines=%d\n", + ent.Kind, ent.Size, ent.Truncated, len(splitLines(ent.Hex))) + } + + // Export builders (skip the GUI save dialog; just validate serialization). + csv := auditToCSV(res) + fmt.Printf("CSV rows=%d firstline=%q\n", len(splitLines(csv)), firstLine(csv)) + if b, err := json.Marshal(auditToSARIF(res)); err != nil { + t.Errorf("SARIF marshal: %v", err) + } else { + fmt.Printf("SARIF bytes=%d\n", len(b)) + } +} + +// TestParity compares the reference (aapt2/apksigner) parse against the pure-Go +// fallback on the same APK, so we can confirm the hybrid behaves the same. +// Run: go test -run TestParity -v (uses AUDIT_APK or the framework apk) +func TestParity(t *testing.T) { + apk := os.Getenv("AUDIT_APK") + if apk == "" { + apk = "/home/xyz/.local/share/apktool/framework/1.apk" + } + if _, err := os.Stat(apk); err != nil { + t.Skipf("test apk not present: %v", err) + } + app := NewApp() + + ref, err := app.AuditAPK(apk) // reference path (tools present on this box) + if err != nil { + t.Fatalf("reference audit: %v", err) + } + + var go_ APKAudit + go_.Counts = map[string]int{} + parseManifestGo(apk, &go_) + parseCertGo(apk, &go_) + finalizeCert(&go_) + + expCount := func(a APKAudit) (n int) { + for _, c := range a.Components { + if c.Exported || c.ExportedImplicit { + n++ + } + } + return + } + + fmt.Printf("\n%-16s | %-28s | %-28s\n", "field", "aapt2/apksigner (ref)", "pure-Go (fallback)") + row := func(label, a, b string) { + flag := "" + if a != b { + flag = " <-- DIFF" + } + fmt.Printf("%-16s | %-28s | %-28s%s\n", label, a, b, flag) + } + row("package", ref.PackageName, go_.PackageName) + row("versionName", ref.VersionName, go_.VersionName) + row("versionCode", ref.VersionCode, go_.VersionCode) + row("minSdk", ref.MinSDK, go_.MinSDK) + row("targetSdk", ref.TargetSDK, go_.TargetSDK) + row("permissions", itoa(len(ref.Permissions)), itoa(len(go_.Permissions))) + row("components", itoa(len(ref.Components)), itoa(len(go_.Components))) + row("exported", itoa(expCount(ref)), itoa(expCount(go_))) + row("debuggable", b2s(ref.Debuggable), b2s(go_.Debuggable)) + row("allowBackup", b2s(ref.AllowBackup), b2s(go_.AllowBackup)) + row("cert.verified", b2s(ref.Cert.Verified), b2s(go_.Cert.Verified)) + row("cert.v1/v2/v3", schemes(ref.Cert), schemes(go_.Cert)) + row("cert.sha256", trunc16(ref.Cert.SHA256), trunc16(go_.Cert.SHA256)) + + if ref.PackageName != go_.PackageName { + t.Errorf("package mismatch: %q vs %q", ref.PackageName, go_.PackageName) + } + if ref.Cert.SHA256 != "" && go_.Cert.SHA256 != "" && ref.Cert.SHA256 != go_.Cert.SHA256 { + t.Errorf("cert SHA-256 mismatch: %q vs %q", ref.Cert.SHA256, go_.Cert.SHA256) + } +} + +func itoa(n int) string { return fmt.Sprintf("%d", n) } +func b2s(b bool) string { return fmt.Sprintf("%v", b) } +func schemes(c APKCertInfo) string { return fmt.Sprintf("%v/%v/%v", c.V1, c.V2, c.V3) } +func trunc16(s string) string { + if len(s) > 16 { + return s[:16] + "…" + } + return s +} + +func splitLines(s string) []string { + if s == "" { + return nil + } + var n []string + for _, l := range strings.Split(s, "\n") { + if l != "" { + n = append(n, l) + } + } + return n +} + +// TestAuditInstalled audits a package off the connected device. +// Run: AUDIT_PKG=com.android.settings go test -run TestAuditInstalled -v +func TestAuditInstalled(t *testing.T) { + pkg := os.Getenv("AUDIT_PKG") + if pkg == "" { + t.Skip("set AUDIT_PKG to audit an installed package") + } + app := NewApp() + res, err := app.AuditInstalledApp(pkg) + if err != nil { + t.Fatalf("AuditInstalledApp error: %v", err) + } + dumpAudit(t, res) +} + +func dumpAudit(t *testing.T, res APKAudit) { + t.Helper() + + fmt.Printf("\n=== %s (%s v%s)\n", res.AppLabel, res.PackageName, res.VersionName) + fmt.Printf("score=%d grade=%s min=%s target=%s\n", res.Score, res.Grade, res.MinSDK, res.TargetSDK) + fmt.Printf("perms=%d components=%d files=%d trackers=%d findings=%d\n", + len(res.Permissions), len(res.Components), len(res.Files), len(res.Trackers), len(res.Findings)) + fmt.Printf("cert: verified=%v v1=%v v2=%v v3=%v debug=%v err=%q\n", + res.Cert.Verified, res.Cert.V1, res.Cert.V2, res.Cert.V3, res.Cert.IsDebug, res.Cert.Error) + fmt.Printf("flags: debuggable=%v allowBackup=%v cleartext=%v nsc=%v\n", + res.Debuggable, res.AllowBackup, res.UsesCleartext, res.HasNSC) + fmt.Println("counts:", res.Counts) + fmt.Println("--- findings ---") + for _, f := range res.Findings { + fmt.Printf("[%-8s] %-40s (%d matches) %s %s\n", f.Severity, f.Title, len(f.Matches), f.CWE, f.Masvs) + } + fmt.Println("--- trackers ---") + for _, tr := range res.Trackers { + fmt.Printf(" %-22s %-16s x%d\n", tr.Name, tr.Category, tr.Matches) + } + + if res.PackageName == "" { + t.Error("expected a package name from aapt2") + } + // quick JSON round-trip to ensure it serializes for the frontend + if _, err := json.Marshal(res); err != nil { + t.Errorf("json marshal failed: %v", err) + } +} diff --git a/backend_applock.go b/backend_applock.go new file mode 100644 index 0000000..e65ba44 --- /dev/null +++ b/backend_applock.go @@ -0,0 +1,216 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/crypto/scrypt" +) + +// App-lock: an optional password gate for ATK. +// +// Threat model (be honest about it — the Settings UI says the same): the launch +// gate and the "require password for destructive actions" window are enforced +// here in Go, so the ATK app itself cannot be driven into flashing/uninstalling +// without the password. They do NOT stop a fully-compromised computer from +// invoking `adb`/`fastboot` directly, outside ATK — nothing running as the same +// user can. This raises the bar against casual misuse and stops ATK being a +// turnkey attack surface; it is not a substitute for full-disk encryption or a +// locked bootloader. +// +// The password is never stored — only a per-install random salt + scrypt hash. + +// dangerWindow is how long a successful UnlockDanger keeps destructive actions +// unlocked. Kept short so an unattended session re-locks quickly. +const dangerWindow = 5 * time.Minute + +type appLockConfig struct { + Enabled bool `json:"enabled"` + Salt string `json:"salt"` // hex + Hash string `json:"hash"` // hex, scrypt(password, salt) + RequireForDanger bool `json:"requireForDanger"` +} + +func appLockPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "ATK", "applock.json"), nil +} + +func loadAppLock() appLockConfig { + var c appLockConfig + p, err := appLockPath() + if err != nil { + return c + } + data, err := os.ReadFile(p) + if err != nil { + return c + } + _ = json.Unmarshal(data, &c) + return c +} + +func saveAppLock(c appLockConfig) error { + p, err := appLockPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0o600) +} + +// scryptHash derives a 32-byte key. N=32768,r=8,p=1 is the interactive-login +// preset — a few tens of ms per attempt, which is the point. +func scryptHash(password string, salt []byte) (string, error) { + dk, err := scrypt.Key([]byte(password), salt, 1<<15, 8, 1, 32) + if err != nil { + return "", err + } + return hex.EncodeToString(dk), nil +} + +func (c appLockConfig) verify(password string) (bool, error) { + if !c.Enabled || c.Hash == "" { + return true, nil // no lock configured → everything passes + } + salt, err := hex.DecodeString(c.Salt) + if err != nil { + return false, fmt.Errorf("app-lock config is corrupt") + } + got, err := scryptHash(password, salt) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare([]byte(got), []byte(c.Hash)) == 1, nil +} + +// AppLockStatus reports whether the lock is enabled and whether destructive +// actions additionally require re-entering the password. Safe to call anytime. +func (a *App) AppLockStatus() map[string]bool { + c := loadAppLock() + return map[string]bool{ + "enabled": c.Enabled && c.Hash != "", + "requireForDanger": c.RequireForDanger, + } +} + +// VerifyAppPassword is used by the launch gate. Returns true on a correct +// password (or when no lock is set). +func (a *App) VerifyAppPassword(password string) (bool, error) { + return loadAppLock().verify(password) +} + +// SetAppPassword sets or changes the launch password and enables the lock. When +// a password already exists, `current` must match it. Pass "" for `current` on +// first setup. +func (a *App) SetAppPassword(current, next string) error { + if len(next) < 4 { + return fmt.Errorf("password must be at least 4 characters") + } + c := loadAppLock() + if c.Enabled && c.Hash != "" { + ok, err := c.verify(current) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("current password is incorrect") + } + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return err + } + hash, err := scryptHash(next, salt) + if err != nil { + return err + } + c.Enabled = true + c.Salt = hex.EncodeToString(salt) + c.Hash = hash + return saveAppLock(c) +} + +// DisableAppLock removes the lock entirely. The current password must match. +func (a *App) DisableAppLock(current string) error { + c := loadAppLock() + if !c.Enabled || c.Hash == "" { + return nil + } + ok, err := c.verify(current) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("password is incorrect") + } + return saveAppLock(appLockConfig{}) // wipe salt+hash +} + +// SetRequireForDanger toggles the per-action re-auth requirement. Requires the +// current password so a passer-by at an unlocked session can't switch it off. +func (a *App) SetRequireForDanger(current string, require bool) error { + c := loadAppLock() + if !c.Enabled || c.Hash == "" { + return fmt.Errorf("set an app password first") + } + ok, err := c.verify(current) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("password is incorrect") + } + c.RequireForDanger = require + return saveAppLock(c) +} + +// UnlockDanger opens the destructive-action window for dangerWindow on a correct +// password. Returns true if unlocked. Called by the frontend re-auth modal. +func (a *App) UnlockDanger(password string) (bool, error) { + c := loadAppLock() + ok, err := c.verify(password) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + a.dangerMu.Lock() + a.dangerUntil = time.Now().Add(dangerWindow) + a.dangerMu.Unlock() + return true, nil +} + +// requireDangerUnlocked is the backend gate every destructive method calls +// first. It is a no-op unless the lock is enabled AND RequireForDanger is set. +// When armed, it fails closed until UnlockDanger has been called recently. +func (a *App) requireDangerUnlocked() error { + c := loadAppLock() + if !c.Enabled || c.Hash == "" || !c.RequireForDanger { + return nil + } + a.dangerMu.Lock() + until := a.dangerUntil + a.dangerMu.Unlock() + if time.Now().Before(until) { + return nil + } + // Sentinel prefix the frontend recognises to pop the re-auth modal. + return fmt.Errorf("DANGER_LOCKED: app password required for this action") +} diff --git a/backend_bootinfo.go b/backend_bootinfo.go new file mode 100644 index 0000000..b09c2b4 --- /dev/null +++ b/backend_bootinfo.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "crypto/sha1" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "os" +) + +// Boot-image analysis + file hashing — both work entirely on local files, no +// device required. + +type BootInfo struct { + Valid bool `json:"valid"` + Type string `json:"type"` + HeaderVersion int `json:"headerVersion"` + AndroidVersion string `json:"androidVersion"` + SecurityPatch string `json:"securityPatch"` + PageSize int `json:"pageSize"` + KernelKB int `json:"kernelKB"` + RamdiskKB int `json:"ramdiskKB"` + SizeMB int `json:"sizeMB"` + SHA1 string `json:"sha1"` + SHA256 string `json:"sha256"` + Root string `json:"root"` +} + +// AnalyzeBootImage parses an Android boot / init_boot / vendor_boot image: +// header version, OS version + security patch, sizes, hashes, and a best-effort +// scan for root-solution markers (Magisk / KernelSU / APatch). +func (a *App) AnalyzeBootImage(path string) (BootInfo, error) { + var bi BootInfo + st, err := os.Stat(path) + if err != nil { + return bi, err + } + if st.Size() > 256<<20 { + return bi, fmt.Errorf("file too large to be a boot image (%d MB)", st.Size()>>20) + } + data, err := os.ReadFile(path) + if err != nil { + return bi, err + } + if len(data) < 64 { + return bi, fmt.Errorf("file too small to be a boot image") + } + + bi.SizeMB = len(data) / (1024 * 1024) + s1 := sha1.Sum(data) + bi.SHA1 = hex.EncodeToString(s1[:]) + s2 := sha256.Sum256(data) + bi.SHA256 = hex.EncodeToString(s2[:]) + + switch string(data[0:8]) { + case "ANDROID!": + bi.Type = "boot / init_boot" + bi.Valid = true + case "VNDRBOOT": + bi.Type = "vendor_boot" + bi.Valid = true + default: + bi.Type = "not a boot image" + } + + if bi.Valid && bi.Type != "vendor_boot" { + hv := binary.LittleEndian.Uint32(data[40:44]) + bi.HeaderVersion = int(hv) + + osverOff := 44 + if hv >= 3 { + osverOff = 16 + } + if osverOff+4 <= len(data) { + osver := binary.LittleEndian.Uint32(data[osverOff : osverOff+4]) + if osver != 0 { + ver := osver >> 11 + bi.AndroidVersion = fmt.Sprintf("%d.%d.%d", (ver>>14)&0x7f, (ver>>7)&0x7f, ver&0x7f) + patch := osver & 0x7ff + month := patch & 0xf + if month >= 1 && month <= 12 { + bi.SecurityPatch = fmt.Sprintf("%04d-%02d", 2000+((patch>>4)&0x7f), month) + } + } + } + + if hv >= 3 { + bi.KernelKB = int(binary.LittleEndian.Uint32(data[8:12])) / 1024 + bi.RamdiskKB = int(binary.LittleEndian.Uint32(data[12:16])) / 1024 + bi.PageSize = 4096 + } else { + bi.KernelKB = int(binary.LittleEndian.Uint32(data[8:12])) / 1024 + bi.RamdiskKB = int(binary.LittleEndian.Uint32(data[16:20])) / 1024 + bi.PageSize = int(binary.LittleEndian.Uint32(data[36:40])) + } + } + + switch { + case bytes.Contains(data, []byte("KernelSU")) || bytes.Contains(data, []byte("ksud")): + bi.Root = "KernelSU markers found" + case bytes.Contains(data, []byte("APatch")) || bytes.Contains(data, []byte("apatch")): + bi.Root = "APatch markers found" + case bytes.Contains(data, []byte("MAGISK")) || bytes.Contains(data, []byte("magisk")): + bi.Root = "Magisk markers found" + default: + bi.Root = "none (appears stock)" + } + return bi, nil +} + +type FileHashes struct { + SHA256 string `json:"sha256"` + SHA1 string `json:"sha1"` + SizeBytes int64 `json:"sizeBytes"` +} + +// HashFile streams a file and returns its SHA-256 / SHA-1 (works for any size). +func (a *App) HashFile(path string) (FileHashes, error) { + f, err := os.Open(path) + if err != nil { + return FileHashes{}, err + } + defer f.Close() + st, _ := f.Stat() + h1, h2 := sha1.New(), sha256.New() + if _, err := io.Copy(io.MultiWriter(h1, h2), f); err != nil { + return FileHashes{}, err + } + return FileHashes{ + SHA256: hex.EncodeToString(h2.Sum(nil)), + SHA1: hex.EncodeToString(h1.Sum(nil)), + SizeBytes: st.Size(), + }, nil +} diff --git a/backend_cert.go b/backend_cert.go index 404e449..21b8dda 100644 --- a/backend_cert.go +++ b/backend_cert.go @@ -1,9 +1,14 @@ package main import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" "fmt" + "os/exec" "strings" - "time" + "sync" ) type CertInfo struct { @@ -32,47 +37,74 @@ func (a *App) listCerts(path string, isUser bool) ([]CertInfo, error) { return nil, nil } - var certs []CertInfo + var names []string for _, fname := range strings.Fields(out) { fname = strings.TrimSpace(fname) - if fname == "" || strings.HasPrefix(fname, "ls:") { - continue + if fname != "" && !strings.HasPrefix(fname, "ls:") { + names = append(names, fname) } - - fullPath := path + "/" + fname - certOut, err := a.runAdbShellTimeout(10*time.Second, "openssl", "x509", - "-in", fullPath, "-noout", "-subject", "-issuer", "-enddate", "-fingerprint") - - info := CertInfo{ - Filename: fname, - IsUser: isUser, - IsSystem: !isUser, - } - - if err == nil { - for _, line := range strings.Split(certOut, "\n") { - line = strings.TrimSpace(line) - switch { - case strings.HasPrefix(line, "subject="): - info.Subject = strings.TrimPrefix(line, "subject=") - case strings.HasPrefix(line, "issuer="): - info.Issuer = strings.TrimPrefix(line, "issuer=") - case strings.HasPrefix(line, "notAfter="): - info.Expiry = strings.TrimPrefix(line, "notAfter=") - case strings.HasPrefix(line, "SHA1 Fingerprint="): - info.Fingerprint = strings.TrimPrefix(line, "SHA1 Fingerprint=") - case strings.HasPrefix(line, "SHA256 Fingerprint="): - info.Fingerprint = strings.TrimPrefix(line, "SHA256 Fingerprint=") - } - } - } - - certs = append(certs, info) } + // Devices don't ship `openssl`, so read each cert off the device and parse + // it host-side with crypto/x509. Done in parallel — there can be 140+ certs. + certs := make([]CertInfo, len(names)) + var wg sync.WaitGroup + sem := make(chan struct{}, 8) + for i, fname := range names { + wg.Add(1) + go func(i int, fname string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + info := CertInfo{Filename: fname, IsUser: isUser, IsSystem: !isUser} + if data, derr := a.readDeviceFile(path + "/" + fname); derr == nil { + fillCertInfo(&info, data) + } + certs[i] = info + }(i, fname) + } + wg.Wait() return certs, nil } +// readDeviceFile streams a (small) device file's raw bytes via adb exec-out. +func (a *App) readDeviceFile(path string) ([]byte, error) { + adbPath, err := a.getBinaryPath("adb") + if err != nil { + return nil, err + } + cmd := exec.Command(adbPath, "exec-out", "cat", path) + setCommandSysProcAttr(cmd) + return cmd.Output() +} + +// fillCertInfo parses a PEM/DER certificate and fills the friendly fields. +func fillCertInfo(info *CertInfo, data []byte) { + der := data + if block, _ := pem.Decode(data); block != nil { + der = block.Bytes + } + c, err := x509.ParseCertificate(der) + if err != nil { + return + } + info.Subject = friendlyName(c.Subject.CommonName, c.Subject.Organization, c.Subject.String()) + info.Issuer = friendlyName(c.Issuer.CommonName, c.Issuer.Organization, c.Issuer.String()) + info.Expiry = c.NotAfter.Format("2006-01-02") + sum := sha256.Sum256(c.Raw) + info.Fingerprint = hex.EncodeToString(sum[:]) +} + +func friendlyName(cn string, org []string, full string) string { + if cn != "" { + return cn + } + if len(org) > 0 && org[0] != "" { + return org[0] + } + return full +} + // InstallUserCert installs a PEM certificate as a user-trusted CA. // localCertPath is the path to the cert on the host machine. func (a *App) InstallUserCert(localCertPath string) (string, error) { diff --git a/backend_filehttp.go b/backend_filehttp.go new file mode 100644 index 0000000..7d4000b --- /dev/null +++ b/backend_filehttp.go @@ -0,0 +1,57 @@ +package main + +import ( + "net/http" + "os" + "os/exec" +) + +// fileHandler serves device/local files to the webview (used by the Files image +// viewer) over the Wails asset server. Streaming raw bytes avoids the size +// limits WebKitGTK imposes on large base64 data: URLs. +// +// Route: /__file?src=device|local&p= +func (a *App) fileHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + p := q.Get("p") + if p == "" { + http.Error(w, "missing path", http.StatusBadRequest) + return + } + w.Header().Set("Cache-Control", "no-store") + + if q.Get("src") == "local" { + // ServeFile picks the Content-Type and supports range requests. + http.ServeFile(w, r, p) + return + } + + // Device: pull to a temp file via the file-sync protocol, then serve it. + // `adb pull` takes the remote path as a literal argument (no device-shell + // re-parsing), so it handles spaces/parens/etc. — unlike `adb exec-out`, + // which mangles quoted paths. This mirrors the working Pull button. + adbPath, err := a.getBinaryPath("adb") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + tmp, err := os.CreateTemp("", "atk-view-*") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + tmpPath := tmp.Name() + tmp.Close() + defer os.Remove(tmpPath) + + cmd := exec.Command(adbPath, "pull", p, tmpPath) + setCommandSysProcAttr(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + http.Error(w, "failed to read device file: "+string(out), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", mimeForName(p)) + http.ServeFile(w, r, tmpPath) + }) +} diff --git a/backend_firmware.go b/backend_firmware.go new file mode 100644 index 0000000..eff2937 --- /dev/null +++ b/backend_firmware.go @@ -0,0 +1,185 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// In-app firmware download: scrape Google's public factory/OTA image listing +// for a device codename, then download the chosen build with a progress bar and +// SHA-256 verification. Emits firmware:progress / firmware:done events. + +type Firmware struct { + Version string `json:"version"` + URL string `json:"url"` + SHA256 string `json:"sha256"` +} + +var sha256Re = regexp.MustCompile(`[0-9a-fA-F]{64}`) + +// ListFirmware returns available builds for a codename. kind = "factory" | "ota". +func (a *App) ListFirmware(codename, kind string) ([]Firmware, error) { + codename = strings.ToLower(strings.TrimSpace(codename)) + if codename == "" { + return nil, fmt.Errorf("enter a device codename (e.g. oriole, raven, panther, husky)") + } + + var pageURL, cookie string + var urlRe *regexp.Regexp + if kind == "ota" { + pageURL = "https://developers.google.com/android/ota" + cookie = "devsite_wall_acks=nexus-ota-tos" + urlRe = regexp.MustCompile(`https://dl\.google\.com/dl/android/aosp/` + regexp.QuoteMeta(codename) + `-ota-[\w.]+-[0-9a-f]+\.zip`) + } else { + pageURL = "https://developers.google.com/android/images" + cookie = "devsite_wall_acks=nexus-image-tos" + urlRe = regexp.MustCompile(`https://dl\.google\.com/dl/android/aosp/` + regexp.QuoteMeta(codename) + `-[\w.]+-factory-[0-9a-f]+\.zip`) + } + + req, _ := http.NewRequest("GET", pageURL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 ATK") + req.Header.Set("Cookie", cookie) + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, fmt.Errorf("could not reach Google's image server: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + html := string(body) + + // The row's Version cell precedes the link, e.g. "15.0.0 (BP1A.250505.005, May 2025)". + verRe := regexp.MustCompile(`\d+\.\d+\.\d+ \([^)]+\)`) + + var out []Firmware + seen := map[string]bool{} + for _, loc := range urlRe.FindAllStringIndex(html, -1) { + url := html[loc[0]:loc[1]] + if seen[url] { + continue + } + seen[url] = true + + sha := "" + end := loc[1] + 800 + if end > len(html) { + end = len(html) + } + if m := sha256Re.FindString(html[loc[1]:end]); m != "" { + sha = strings.ToLower(m) + } + + // Look back for the human version+date string in the same row. + version := firmwareVersion(url, codename, kind) + start := loc[0] - 800 + if start < 0 { + start = 0 + } + if vs := verRe.FindAllString(html[start:loc[0]], -1); len(vs) > 0 { + version = vs[len(vs)-1] + } + + out = append(out, Firmware{Version: version, URL: url, SHA256: sha}) + } + if len(out) == 0 { + return nil, fmt.Errorf("no %s images found for %q — double-check the codename", kind, codename) + } + return out, nil +} + +func firmwareVersion(url, cn, kind string) string { + base := url[strings.LastIndex(url, "/")+1:] + base = strings.TrimSuffix(base, ".zip") + base = strings.TrimPrefix(base, cn+"-") + if kind == "ota" { + base = strings.TrimPrefix(base, "ota-") + } + if i := strings.Index(base, "-factory-"); i >= 0 { + return base[:i] + } + if i := strings.LastIndex(base, "-"); i >= 0 { + return base[:i] + } + return base +} + +// DownloadFirmware downloads url to a chosen path, streaming progress and +// verifying the SHA-256. Cancellable via CancelOperation(). +func (a *App) DownloadFirmware(url, expectedSHA string) (string, error) { + name := url[strings.LastIndex(url, "/")+1:] + path, err := a.SelectSaveFile(name) + if err != nil { + return "", err + } + if path == "" { + return "Download cancelled.", nil + } + + ctx, cancel := a.beginCancellableOp(0) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 ATK") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("server returned %d", resp.StatusCode) + } + + f, err := os.Create(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + pw := &fwProgressWriter{app: a, total: resp.ContentLength, lastPct: -1} + _, copyErr := io.Copy(io.MultiWriter(f, h, pw), resp.Body) + runtime.EventsEmit(a.ctx, "firmware:done", nil) + if copyErr != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("cancelled") + } + return "", fmt.Errorf("download error: %w", copyErr) + } + + if expectedSHA != "" { + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, expectedSHA) { + return "", fmt.Errorf("SHA-256 MISMATCH — file may be corrupt.\nexpected %s\ngot %s", expectedSHA, got) + } + return fmt.Sprintf("Downloaded & verified ✓\n%s", path), nil + } + return fmt.Sprintf("Downloaded (no checksum listed to verify)\n%s", path), nil +} + +type fwProgressWriter struct { + app *App + total int64 + written int64 + lastPct int +} + +func (p *fwProgressWriter) Write(b []byte) (int, error) { + n := len(b) + p.written += int64(n) + if p.total > 0 { + pct := int(p.written * 100 / p.total) + if pct != p.lastPct { + p.lastPct = pct + runtime.EventsEmit(p.app.ctx, "firmware:progress", map[string]interface{}{"percent": pct}) + } + } + return n, nil +} diff --git a/backend_flasher.go b/backend_flasher.go new file mode 100644 index 0000000..8650a85 --- /dev/null +++ b/backend_flasher.go @@ -0,0 +1,188 @@ +package main + +import ( + "bytes" + "fmt" + "os/exec" + "strings" + "time" +) + +// Phase 1 flasher capabilities (PixelFlasher-inspired): live-boot, slot-aware +// boot flashing, bootloader lock controls, fastboot reboot, and a unified +// device-info panel that works in both adb and fastboot modes. + +var validSlots = map[string]bool{"": true, "a": true, "b": true, "all": true} + +// FlasherInfo is the device summary shown at the top of the Flasher view. +type FlasherInfo struct { + Connection string `json:"connection"` // adb | fastboot | none + Serial string `json:"serial"` + Slot string `json:"slot"` + Bootloader string `json:"bootloader"` + Fingerprint string `json:"fingerprint"` + AndroidVer string `json:"androidVer"` + Codename string `json:"codename"` + LockState string `json:"lockState"` // locked | unlocked | unknown + VerifiedBoot string `json:"verifiedBoot"` + Root string `json:"root"` +} + +// FastbootBoot live-boots an image without flashing (great for testing a +// patched/custom boot or recovery): fastboot boot . +func (a *App) FastbootBoot(filePath string) (string, error) { + if strings.TrimSpace(filePath) == "" { + return "", fmt.Errorf("no image selected") + } + out, err := a.runCommandTimeout(5*time.Minute, "fastboot", "boot", filePath) + if err != nil { + return "", fmt.Errorf("live boot failed: %w", err) + } + return out, nil +} + +// FlashBootImage flashes an image to a (safe-listed) partition, optionally to a +// specific slot, optionally with --force. slot ∈ {"", "a", "b", "all"}. +func (a *App) FlashBootImage(partition, filePath, slot string, force bool) (string, error) { + if err := a.requireDangerUnlocked(); err != nil { + return "", err + } + if err := validatePartitionName(partition); err != nil { + return "", err + } + if !validSlots[slot] { + return "", fmt.Errorf("invalid slot %q", slot) + } + if strings.TrimSpace(filePath) == "" { + return "", fmt.Errorf("no image selected") + } + args := []string{} + if force { + args = append(args, "--force") + } + if slot != "" { + args = append(args, "--slot", slot) + } + args = append(args, "flash", partition, filePath) + out, err := a.runCommandTimeout(10*time.Minute, "fastboot", args...) + if err != nil { + return "", fmt.Errorf("flash failed: %w", err) + } + return out, nil +} + +// FastbootFlashing runs `fastboot flashing ` to change bootloader lock +// state. Unlocking/locking wipes the device and requires on-screen confirmation. +func (a *App) FastbootFlashing(action string) (string, error) { + if err := a.requireDangerUnlocked(); err != nil { + return "", err + } + valid := map[string]bool{ + "unlock": true, "lock": true, + "unlock_critical": true, "lock_critical": true, + "get_unlock_ability": true, + } + if !valid[action] { + return "", fmt.Errorf("unsupported flashing action %q", action) + } + out, err := a.runCommandTimeout(2*time.Minute, "fastboot", "flashing", action) + if err != nil { + return "", fmt.Errorf("flashing %s failed: %w", action, err) + } + if strings.TrimSpace(out) == "" { + out = "Sent. Confirm on the device screen if prompted (use volume keys + power)." + } + return out, nil +} + +// FastbootReboot reboots a device that's in fastboot/bootloader mode. +// target ∈ {"", "bootloader", "fastboot" (fastbootd), "recovery"}. +func (a *App) FastbootReboot(target string) (string, error) { + valid := map[string]bool{"": true, "bootloader": true, "fastboot": true, "recovery": true} + if !valid[target] { + return "", fmt.Errorf("invalid reboot target %q", target) + } + args := []string{"reboot"} + if target != "" { + args = append(args, target) + } + out, err := a.runCommand("fastboot", args...) + if err != nil { + return "", fmt.Errorf("reboot failed: %w", err) + } + return out, nil +} + +// FlasherDeviceInfo returns a unified device summary for whichever mode the +// device is currently in (adb or fastboot). +func (a *App) FlasherDeviceInfo() (FlasherInfo, error) { + info := FlasherInfo{Connection: "none", LockState: "unknown"} + mode, _ := a.detectDeviceMode() + + switch mode { + case DeviceModeFastboot: + info.Connection = "fastboot" + vars := a.fastbootVars("current-slot", "version-bootloader", "product", "unlocked") + info.Slot = vars["current-slot"] + info.Bootloader = vars["version-bootloader"] + info.Codename = vars["product"] + switch vars["unlocked"] { + case "yes": + info.LockState = "unlocked" + case "no": + info.LockState = "locked" + } + if devs, _ := a.GetFastbootDevices(); len(devs) > 0 { + info.Serial = devs[0].Serial + } + + case DeviceModeADB: + info.Connection = "adb" + info.Slot = strings.TrimPrefix(a.getProp("ro.boot.slot_suffix"), "_") + info.Bootloader = a.getProp("ro.bootloader") + info.Fingerprint = a.getProp("ro.build.fingerprint") + info.AndroidVer = a.getProp("ro.build.version.release") + info.Codename = a.getProp("ro.product.device") + info.VerifiedBoot = a.getProp("ro.boot.verifiedbootstate") + switch a.getProp("ro.boot.flash.locked") { + case "1": + info.LockState = "locked" + case "0": + info.LockState = "unlocked" + } + if devs, _ := a.GetDevices(); len(devs) > 0 { + info.Serial = devs[0].Serial + } + if su, _ := a.runAdbShell("which", "su"); strings.TrimSpace(su) != "" { + info.Root = "su present" + } else { + info.Root = "none" + } + } + return info, nil +} + +// fastbootVars queries one or more fastboot variables. fastboot prints getvar +// results to stderr ("var: value"), so we capture combined output and parse it. +func (a *App) fastbootVars(keys ...string) map[string]string { + res := map[string]string{} + fb, err := a.getBinaryPath("fastboot") + if err != nil { + return res + } + for _, k := range keys { + cmd := exec.Command(fb, "getvar", k) + setCommandSysProcAttr(cmd) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + cmd.Run() + for _, line := range strings.Split(buf.String(), "\n") { + if strings.HasPrefix(line, k+":") { + res[k] = strings.TrimSpace(strings.TrimPrefix(line, k+":")) + break + } + } + } + return res +} diff --git a/backend_logcat.go b/backend_logcat.go index 457aeb7..c21ed96 100644 --- a/backend_logcat.go +++ b/backend_logcat.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os/exec" + "regexp" "strings" "sync" @@ -12,12 +13,15 @@ import ( ) type LogcatLine struct { - Raw string `json:"raw"` - Level string `json:"level"` - Tag string `json:"tag"` - Message string `json:"message"` - PID string `json:"pid"` - Time string `json:"time"` + Raw string `json:"raw"` + Level string `json:"level"` + Tag string `json:"tag"` + Message string `json:"message"` + PID string `json:"pid"` + TID string `json:"tid"` + Time string `json:"time"` + Refs []LogRef `json:"refs"` // mined relationships (native, for the visual map) + Mentions []LogRef `json:"mentions"` // generic package mentions (optional/noisy) } var ( @@ -106,36 +110,85 @@ func (a *App) ClearLogcat() error { return err } -// parseLogcatLine parses a threadtime format logcat line: -// MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: message +// LogcatProcessNames returns a best-effort PID -> process/package name map so the +// visual map can label process nodes with real names (e.g. com.android.systemui) +// instead of bare PIDs. Parsed from `ps -A`; the NAME column is the last field +// and the PID is the second. Best-effort: a failure just yields an empty map and +// the UI falls back to PIDs. +func (a *App) LogcatProcessNames() (map[string]string, error) { + out, err := a.runAdbShell("ps", "-A", "-o", "PID,NAME") + if err != nil || strings.TrimSpace(out) == "" { + // Older toybox builds reject -o; fall back to the full table. + out, err = a.runAdbShell("ps", "-A") + if err != nil { + return map[string]string{}, nil + } + } + + names := make(map[string]string) + for i, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + // Skip a header row ("PID NAME" or the ps -A column header). + if i == 0 && !isAllDigits(fields[0]) && !isAllDigits(fields[1]) { + continue + } + var pid, name string + if isAllDigits(fields[0]) { + // `ps -A -o PID,NAME` → "PID NAME" + pid, name = fields[0], fields[len(fields)-1] + } else if isAllDigits(fields[1]) { + // full `ps -A` table → "USER PID PPID ... NAME" + pid, name = fields[1], fields[len(fields)-1] + } else { + continue + } + if pid != "" && name != "" { + names[pid] = name + } + } + return names, nil +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// threadtime format: "MM-DD HH:MM:SS.mmm PID TID L TAG: message" +// PID/TID are right-aligned with variable padding, so a naive split-on-space +// mis-assigns fields. This regex tolerates arbitrary whitespace runs. +var logcatRe = regexp.MustCompile(`^(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([A-Za-z])\s+(.*?):\s?(.*)$`) + +// parseLogcatLine parses a threadtime logcat line. Non-matching lines (e.g. +// "--------- beginning of main") keep their raw text as the message. func parseLogcatLine(line string) LogcatLine { result := LogcatLine{Raw: line} - parts := strings.SplitN(line, " ", 7) - if len(parts) < 7 { - result.Message = line + m := logcatRe.FindStringSubmatch(line) + if m == nil { + result.Message = strings.TrimSpace(line) + result.Refs = []LogRef{} + result.Mentions = []LogRef{} return result } - result.Time = strings.TrimSpace(parts[0] + " " + parts[1]) - result.PID = strings.TrimSpace(parts[2]) - // parts[3] = TID - level := strings.TrimSpace(parts[4]) - if len(level) > 0 { - result.Level = level - } - tagAndMsg := strings.TrimSpace(parts[5]) - if idx := strings.Index(tagAndMsg, ":"); idx >= 0 { - result.Tag = strings.TrimSpace(tagAndMsg[:idx]) - if len(parts) > 6 { - result.Message = strings.TrimSpace(parts[6]) - } - } else { - result.Tag = tagAndMsg - if len(parts) > 6 { - result.Message = strings.TrimSpace(parts[6]) - } - } - + result.Time = m[1] + result.PID = m[2] + result.TID = m[3] + result.Level = m[4] + result.Tag = strings.TrimSpace(m[5]) + result.Message = m[6] + result.Refs = lcpExtractRefs(result.Tag, result.Message) + result.Mentions = lcpExtractMentions(result.Message, 3) return result } diff --git a/backend_logcatpatterns.go b/backend_logcatpatterns.go new file mode 100644 index 0000000..a4c31b8 --- /dev/null +++ b/backend_logcatpatterns.go @@ -0,0 +1,169 @@ +package main + +import ( + "regexp" + "strings" +) + +// Relationship extraction for the Logcat visual map — ported to Go so the mining +// heuristics live in native (compiled) code rather than shipped JavaScript. +// +// A log line is just text, but Android's framework + system-event logs encode +// real relationships: who started whom, who crashed, who got killed, who sent a +// signal to which pid. We mine those so the map can draw meaningful edges on top +// of the ambient co-occurrence web. Runs in the existing per-line log pipeline +// (parseLogcatLine), so the result ships attached to each LogcatLine — no extra IPC. + +// LogRef is one relationship a log line implies. JSON shape matches the frontend. +type LogRef struct { + Kind string `json:"kind"` // activity|spawn|death|crash|anr|signal|gfx|mention + Target string `json:"target"` // package, component, or pid payload + TargetKind string `json:"targetKind"` // package|component|pid +} + +var ( + lcpPkg = regexp.MustCompile(`\b([a-z][a-z0-9_]*(?:\.[a-z0-9_]+){2,})\b`) + lcpPkgAnchor = regexp.MustCompile(`^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+){2,}$`) + lcpComponent = regexp.MustCompile(`(?i)([a-z][a-z0-9_.]+)/([a-z0-9_.$]+)`) + lcpEventCSV = regexp.MustCompile(`\[([^\]]*)\]`) + lcpSig = regexp.MustCompile(`Sending signal\.\s*PID:\s*(\d+)`) + lcpProcess = regexp.MustCompile(`(?i)Process:\s*([a-z][a-z0-9_.]+)`) + lcpANRin = regexp.MustCompile(`\bANR in\b`) + lcpStartProc = regexp.MustCompile(`\bStart proc\b`) + lcpKilling = regexp.MustCompile(`\bKilling\b|\bhas died\b|\bdied\b`) + lcpStartAct = regexp.MustCompile(`\bSTART u\d+|\bDisplayed\b|\bmoveTaskTo`) + lcpFatal = regexp.MustCompile(`FATAL EXCEPTION`) + lcpGfxTag = regexp.MustCompile(`^(SurfaceFlinger|WindowManager|ViewRootImpl|Choreographer|gralloc|OpenGLRenderer)`) + lcpMentSkip = regexp.MustCompile(`^(java|javax|sun|kotlin|android|androidx|dalvik)\.`) +) + +func lcpFirstPackage(s string) string { + if m := lcpPkg.FindStringSubmatch(s); m != nil { + return m[1] + } + return "" +} + +// Pull the package field out of an event-log CSV payload (first dotted token). +func lcpEventPackage(msg string) string { + csv := lcpEventCSV.FindStringSubmatch(msg) + if csv == nil { + return lcpFirstPackage(msg) + } + for _, f := range strings.Split(csv[1], ",") { + t := strings.TrimSpace(f) + if lcpPkgAnchor.MatchString(t) { + return t + } + } + return lcpFirstPackage(msg) +} + +// lcpExtractRefs mines the relationships a single log line implies. Returns an +// empty slice for the vast majority of lines. +func lcpExtractRefs(tag, msg string) []LogRef { + refs := []LogRef{} + push := func(kind, target, targetKind string) { + if target != "" { + refs = append(refs, LogRef{Kind: kind, Target: target, TargetKind: targetKind}) + } + } + + // binary event-log tags (events buffer) + switch tag { + case "am_proc_start", "am_proc_bound": + push("spawn", lcpEventPackage(msg), "package") + return refs + case "am_proc_died", "am_kill", "am_low_memory": + push("death", lcpEventPackage(msg), "package") + return refs + case "am_crash": + push("crash", lcpEventPackage(msg), "package") + return refs + case "am_anr": + push("anr", lcpEventPackage(msg), "package") + return refs + case "am_activity_launch_time", "am_focused_activity", "am_resume_activity", "am_pause_activity", "wm_focused_window": + if c := lcpComponent.FindStringSubmatch(msg); c != nil { + push("activity", c[1]+"/"+c[2], "component") + } else { + push("activity", lcpEventPackage(msg), "package") + } + return refs + } + + // framework text logs (main/system buffers) + if tag == "ActivityManager" || tag == "ActivityTaskManager" { + if lcpANRin.MatchString(msg) { + push("anr", lcpFirstPackage(msg), "package") + } + if lcpStartProc.MatchString(msg) { + push("spawn", lcpFirstPackage(msg), "package") + } + if lcpKilling.MatchString(msg) { + push("death", lcpFirstPackage(msg), "package") + } + if sig := lcpSig.FindStringSubmatch(msg); sig != nil { + push("signal", sig[1], "pid") + } + if lcpStartAct.MatchString(msg) { + if c := lcpComponent.FindStringSubmatch(msg); c != nil { + push("activity", c[1]+"/"+c[2], "component") + } else { + push("activity", lcpFirstPackage(msg), "package") + } + } + if len(refs) > 0 { + return refs + } + } + + if tag == "AndroidRuntime" || lcpFatal.MatchString(msg) { + if p := lcpProcess.FindStringSubmatch(msg); p != nil { + push("crash", p[1], "package") + } else { + push("crash", lcpFirstPackage(msg), "package") + } + if len(refs) > 0 { + return refs + } + } + + if tag == "lowmemorykiller" || tag == "lmkd" { + push("death", lcpFirstPackage(msg), "package") + if len(refs) > 0 { + return refs + } + } + + if lcpGfxTag.MatchString(tag) { + if c := lcpComponent.FindStringSubmatch(msg); c != nil { + push("gfx", c[1]+"/"+c[2], "component") + return refs + } + } + + return refs +} + +// lcpExtractMentions: generic fallback — up to `max` package-looking tokens +// (used when the map's "parsed mentions" toggle is on). +func lcpExtractMentions(msg string, max int) []LogRef { + out := []LogRef{} + seen := map[string]bool{} + for _, m := range lcpPkg.FindAllStringSubmatch(msg, -1) { + if len(out) >= max { + break + } + t := m[1] + if seen[t] { + continue + } + seen[t] = true + if lcpMentSkip.MatchString(t) { + continue + } + out = append(out, LogRef{Kind: "mention", Target: t, TargetKind: "package"}) + } + return out +} diff --git a/backend_magisk.go b/backend_magisk.go new file mode 100644 index 0000000..37888b1 --- /dev/null +++ b/backend_magisk.go @@ -0,0 +1,345 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "time" +) + +// Magisk-assisted boot patching (gated behind a Settings toggle in the UI). +// We use the robust, version-agnostic flow: extract boot/init_boot from the +// factory image, push it to the phone, let the installed Magisk app patch it +// (one tap), then pull the patched image back to live-boot or flash. This works +// without pre-existing root and survives Magisk version changes. + +var magiskPackages = []string{ + "com.topjohnwu.magisk", // official + "io.github.huskydg.magisk", // delta + "io.github.vvb2060.magisk", // alpha +} + +// BootImages holds local temp paths of the boot images pulled out of a factory +// zip ("" when absent — modern Pixels patch init_boot, older ones boot). +type BootImages struct { + Boot string `json:"boot"` + InitBoot string `json:"initBoot"` + Source string `json:"source"` +} + +// MagiskInstalled returns the Magisk package name on the device, or an error. +func (a *App) MagiskInstalled() (string, error) { + for _, pkg := range magiskPackages { + out, err := a.runAdbShell("pm", "path", pkg) + if err == nil && strings.Contains(out, "package:") { + return pkg, nil + } + } + return "", fmt.Errorf("Magisk app not found on device — install Magisk first") +} + +// InstallMagisk downloads the latest official Magisk APK from GitHub and +// installs it on the device — so the root flow is self-contained (ATK does not +// bundle Magisk; it fetches it on demand). +func (a *App) InstallMagisk() (string, error) { + if err := a.requireDangerUnlocked(); err != nil { + return "", err + } + client := &http.Client{Timeout: 5 * time.Minute} + + req, _ := http.NewRequest("GET", "https://api.github.com/repos/topjohnwu/Magisk/releases/latest", nil) + req.Header.Set("User-Agent", "ATK") + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("could not reach GitHub: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("GitHub API returned %d (rate limited? try again later)", resp.StatusCode) + } + + var rel struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return "", fmt.Errorf("could not parse release info: %w", err) + } + + var apkURL string + for _, as := range rel.Assets { + if strings.HasSuffix(strings.ToLower(as.Name), ".apk") { + apkURL = as.URL + break + } + } + if apkURL == "" { + return "", fmt.Errorf("no APK in latest Magisk release") + } + + dreq, _ := http.NewRequest("GET", apkURL, nil) + dreq.Header.Set("User-Agent", "ATK") + dresp, err := client.Do(dreq) + if err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + defer dresp.Body.Close() + + tmp, err := os.CreateTemp("", "magisk-*.apk") + if err != nil { + return "", err + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, dresp.Body); err != nil { + tmp.Close() + return "", fmt.Errorf("download write failed: %w", err) + } + tmp.Close() + + if _, err := a.runCommandTimeout(3*time.Minute, "adb", "install", "-r", tmp.Name()); err != nil { + return "", fmt.Errorf("adb install failed: %w", err) + } + return fmt.Sprintf("Installed Magisk %s — open it once on the phone to finish setup.", rel.TagName), nil +} + +// ExtractBootImages pulls boot.img / init_boot.img out of a Pixel factory zip +// (the nested image-*.zip) into local temp files. +func (a *App) ExtractBootImages(zipPath string) (BootImages, error) { + var res BootImages + r, err := zip.OpenReader(zipPath) + if err != nil { + return res, fmt.Errorf("cannot open zip: %w", err) + } + defer r.Close() + + var imgZip *zip.File + for _, f := range r.File { + base := f.Name + if i := strings.LastIndex(base, "/"); i >= 0 { + base = base[i+1:] + } + if strings.HasPrefix(base, "image-") && strings.HasSuffix(base, ".zip") { + imgZip = f + res.Source = base + break + } + } + if imgZip == nil { + return res, fmt.Errorf("no image-*.zip inside — is this a Pixel factory image?") + } + + rc, err := imgZip.Open() + if err != nil { + return res, err + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return res, err + } + + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return res, fmt.Errorf("cannot read inner image zip: %w", err) + } + for _, f := range zr.File { + switch f.Name { + case "boot.img": + if p, e := extractZipEntryToTemp(f, "atk-boot-*.img"); e == nil { + res.Boot = p + } + case "init_boot.img": + if p, e := extractZipEntryToTemp(f, "atk-initboot-*.img"); e == nil { + res.InitBoot = p + } + } + } + if res.Boot == "" && res.InitBoot == "" { + return res, fmt.Errorf("no boot/init_boot image found in factory image") + } + return res, nil +} + +func extractZipEntryToTemp(f *zip.File, pattern string) (string, error) { + rc, err := f.Open() + if err != nil { + return "", err + } + defer rc.Close() + tmp, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + defer tmp.Close() + if _, err := io.Copy(tmp, rc); err != nil { + return "", err + } + return tmp.Name(), nil +} + +// PushImageToDevice copies a local image into /sdcard/Download for Magisk to +// patch, returning the remote path. +func (a *App) PushImageToDevice(localPath string) (string, error) { + if strings.TrimSpace(localPath) == "" { + return "", fmt.Errorf("no image to push") + } + remote := "/sdcard/Download/" + baseName(localPath) + if _, err := a.runCommandTimeout(5*time.Minute, "adb", "push", localPath, remote); err != nil { + return "", fmt.Errorf("push failed: %w", err) + } + return remote, nil +} + +// OpenMagisk launches the Magisk app on the device. +func (a *App) OpenMagisk() error { + pkg, err := a.MagiskInstalled() + if err != nil { + return err + } + if _, err := a.runAdbShell("monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1"); err != nil { + return fmt.Errorf("could not open Magisk: %w", err) + } + return nil +} + +// ── Magisk module management (requires root / su) ────────────────────────── + +type MagiskModule struct { + Id string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Enabled bool `json:"enabled"` +} + +var moduleIdRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +func validModuleId(id string) error { + if !moduleIdRe.MatchString(id) { + return fmt.Errorf("invalid module id") + } + return nil +} + +// ListMagiskModules reads /data/adb/modules via su. Returns an error if the +// device isn't rooted (su unavailable or not granted to shell). +func (a *App) ListMagiskModules() ([]MagiskModule, error) { + script := `for d in /data/adb/modules/*/; do [ -d "$d" ] || continue; echo "===MODULE==="; echo "dir=$(basename "$d")"; if [ -f "$d/disable" ]; then echo "disabled=1"; else echo "disabled=0"; fi; cat "$d/module.prop" 2>/dev/null; done` + out, err := a.runAdbShell("su", "-c", shellQuote(script)) + if err != nil { + return nil, fmt.Errorf("could not read modules — device must be rooted, and shell granted root in Magisk") + } + + var mods []MagiskModule + for _, b := range strings.Split(out, "===MODULE===") { + b = strings.TrimSpace(b) + if b == "" { + continue + } + m := MagiskModule{Enabled: true} + for _, line := range strings.Split(b, "\n") { + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + switch k { + case "dir": + m.Id = v + case "disabled": + if v == "1" { + m.Enabled = false + } + case "id": + if v != "" { + m.Id = v + } + case "name": + m.Name = v + case "version": + m.Version = v + case "author": + m.Author = v + case "description": + m.Description = v + } + } + if m.Id != "" { + if m.Name == "" { + m.Name = m.Id + } + mods = append(mods, m) + } + } + return mods, nil +} + +// ToggleMagiskModule enables/disables a module (Magisk applies on next reboot). +func (a *App) ToggleMagiskModule(id string, enable bool) (string, error) { + if err := validModuleId(id); err != nil { + return "", err + } + cmd := "touch /data/adb/modules/" + id + "/disable" + if enable { + cmd = "rm -f /data/adb/modules/" + id + "/disable" + } + if _, err := a.runAdbShell("su", "-c", shellQuote(cmd)); err != nil { + return "", fmt.Errorf("failed: %w", err) + } + state := "disabled" + if enable { + state = "enabled" + } + return fmt.Sprintf("%s %s — reboot to apply", id, state), nil +} + +// RemoveMagiskModule flags a module for removal on next reboot. +func (a *App) RemoveMagiskModule(id string) (string, error) { + if err := validModuleId(id); err != nil { + return "", err + } + if _, err := a.runAdbShell("su", "-c", shellQuote("touch /data/adb/modules/"+id+"/remove")); err != nil { + return "", fmt.Errorf("failed: %w", err) + } + return fmt.Sprintf("%s flagged for removal — reboot to apply", id), nil +} + +// PullPatchedBoot finds the newest magisk_patched-*.img in /sdcard/Download and +// pulls it to a local temp file (ready to live-boot or flash). +func (a *App) PullPatchedBoot() (string, error) { + out, err := a.runAdbShell("ls", "-t", "/sdcard/Download/") + if err != nil { + return "", fmt.Errorf("cannot list Download: %w", err) + } + var name string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "magisk_patched") && strings.HasSuffix(line, ".img") { + name = line + break + } + } + if name == "" { + return "", fmt.Errorf("no magisk_patched-*.img in Download — patch the image in Magisk first") + } + + tmp, err := os.CreateTemp("", "atk-patched-*.img") + if err != nil { + return "", err + } + tmp.Close() + if _, err := a.runCommandTimeout(5*time.Minute, "adb", "pull", "/sdcard/Download/"+name, tmp.Name()); err != nil { + return "", fmt.Errorf("pull failed: %w", err) + } + return tmp.Name(), nil +} diff --git a/backend_overview.go b/backend_overview.go new file mode 100644 index 0000000..944f621 --- /dev/null +++ b/backend_overview.go @@ -0,0 +1,98 @@ +package main + +import ( + "strings" + "sync" +) + +// SecurityOverview is a quick at-a-glance device security/diagnostic summary +// for the Dashboard. All fields are best-effort (N/A when unavailable). +type SecurityOverview struct { + Root string `json:"root"` + SELinux string `json:"selinux"` + VerifiedBoot string `json:"verifiedBoot"` + BootloaderLocked string `json:"bootloaderLocked"` + Encryption string `json:"encryption"` + SecurityPatch string `json:"securityPatch"` + DmVerity string `json:"dmVerity"` + Debuggable string `json:"debuggable"` + Secure string `json:"secure"` + BuildType string `json:"buildType"` + BuildTags string `json:"buildTags"` + AdbEnabled string `json:"adbEnabled"` + DevOptions string `json:"devOptions"` +} + +// GetSecurityOverview gathers security-relevant device state concurrently. +func (a *App) GetSecurityOverview() (SecurityOverview, error) { + var o SecurityOverview + var wg sync.WaitGroup + var mu sync.Mutex + run := func(f func()) { wg.Add(1); go func() { defer wg.Done(); f() }() } + put := func(set func()) { mu.Lock(); set(); mu.Unlock() } + + run(func() { + su, _ := a.runAdbShell("which", "su") + put(func() { + if strings.TrimSpace(su) != "" { + o.Root = "su present" + } else { + o.Root = "not detected" + } + }) + }) + run(func() { + e, _ := a.runAdbShell("getenforce") + if e = strings.TrimSpace(e); e != "" { + put(func() { o.SELinux = e }) + } + }) + run(func() { + v := a.getProp("ro.boot.verifiedbootstate") + put(func() { o.VerifiedBoot = v }) + }) + run(func() { + locked := a.getProp("ro.boot.flash.locked") + put(func() { + switch locked { + case "1": + o.BootloaderLocked = "Locked" + case "0": + o.BootloaderLocked = "Unlocked" + default: + o.BootloaderLocked = "unknown" + } + }) + }) + run(func() { + st, ty := a.getProp("ro.crypto.state"), a.getProp("ro.crypto.type") + put(func() { + if ty != "" && ty != "N/A" { + o.Encryption = st + " (" + ty + ")" + } else { + o.Encryption = st + } + }) + }) + run(func() { v := a.getProp("ro.build.version.security_patch"); put(func() { o.SecurityPatch = v }) }) + run(func() { v := a.getProp("ro.boot.veritymode"); put(func() { o.DmVerity = v }) }) + run(func() { v := a.getProp("ro.debuggable"); put(func() { o.Debuggable = v }) }) + run(func() { v := a.getProp("ro.secure"); put(func() { o.Secure = v }) }) + run(func() { v := a.getProp("ro.build.type"); put(func() { o.BuildType = v }) }) + run(func() { v := a.getProp("ro.build.tags"); put(func() { o.BuildTags = v }) }) + run(func() { + v, _ := a.runAdbShell("settings", "get", "global", "adb_enabled") + if v = strings.TrimSpace(v); v != "" { + put(func() { o.AdbEnabled = v }) + } + }) + run(func() { + v, _ := a.runAdbShell("settings", "get", "global", "development_settings_enabled") + if v = strings.TrimSpace(v); v != "" { + put(func() { o.DevOptions = v }) + } + }) + + wg.Wait() + return o, nil +} diff --git a/backend_payload.go b/backend_payload.go new file mode 100644 index 0000000..fdeca7c --- /dev/null +++ b/backend_payload.go @@ -0,0 +1,362 @@ +package main + +import ( + "archive/zip" + "bytes" + "compress/bzip2" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + + "github.com/ulikunitz/xz" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// payload.bin extraction — pull individual partition images out of an A/B OTA +// zip. Supports FULL OTAs (REPLACE / REPLACE_XZ / REPLACE_BZ / ZERO ops); +// incremental/delta OTAs need the source partitions and are not supported. +// +// payload.bin format: header "CrAU" + version + manifest_size [+ metadata sig +// size for v2], a protobuf DeltaArchiveManifest, then the data blobs. We parse +// just the manifest fields we need by hand to avoid pulling in protoc. + +const payloadMagic = "CrAU" + +type PayloadPartition struct { + Name string `json:"name"` + SizeMB int `json:"sizeMB"` +} + +type plExtent struct{ start, num uint64 } +type plOp struct { + typ, dataOffset, dataLength uint64 + dst []plExtent +} +type plPart struct { + name string + ops []plOp +} + +// pbFields walks protobuf wire-format fields, invoking cb(field, wire, data, varint). +func pbFields(b []byte, cb func(field, wire int, data []byte, v uint64) bool) { + i := 0 + for i < len(b) { + tag, n := binary.Uvarint(b[i:]) + if n <= 0 { + return + } + i += n + field, wire := int(tag>>3), int(tag&7) + switch wire { + case 0: + v, n := binary.Uvarint(b[i:]) + if n <= 0 { + return + } + i += n + if !cb(field, wire, nil, v) { + return + } + case 2: + l, n := binary.Uvarint(b[i:]) + if n <= 0 { + return + } + i += n + if i+int(l) > len(b) { + return + } + if !cb(field, wire, b[i:i+int(l)], 0) { + return + } + i += int(l) + case 5: + i += 4 + case 1: + i += 8 + default: + return + } + } +} + +func plParseExtent(b []byte) plExtent { + var e plExtent + pbFields(b, func(f, w int, d []byte, v uint64) bool { + switch f { + case 1: + e.start = v + case 2: + e.num = v + } + return true + }) + return e +} + +func plParseOp(b []byte) plOp { + var o plOp + pbFields(b, func(f, w int, d []byte, v uint64) bool { + switch f { + case 1: + o.typ = v + case 2: + o.dataOffset = v + case 3: + o.dataLength = v + case 6: + o.dst = append(o.dst, plParseExtent(d)) + } + return true + }) + return o +} + +func plParsePartition(b []byte) plPart { + var p plPart + pbFields(b, func(f, w int, d []byte, v uint64) bool { + switch f { + case 1: + p.name = string(d) + case 8: + p.ops = append(p.ops, plParseOp(d)) + } + return true + }) + return p +} + +// openPayload locates payload.bin inside the OTA zip and returns a seekable +// reader over it, the offset where blob data starts, the parsed manifest parts, +// the block size, and a closer. +func (a *App) openPayload(zipPath string) (*io.SectionReader, int64, []plPart, uint64, func(), error) { + zr, err := zip.OpenReader(zipPath) + if err != nil { + return nil, 0, nil, 0, nil, fmt.Errorf("cannot open zip: %w", err) + } + var pf *zip.File + for _, f := range zr.File { + name := f.Name + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + if name == "payload.bin" { + pf = f + break + } + } + if pf == nil { + zr.Close() + return nil, 0, nil, 0, nil, fmt.Errorf("no payload.bin in zip — is this an A/B OTA?") + } + if pf.Method != zip.Store { + zr.Close() + return nil, 0, nil, 0, nil, fmt.Errorf("payload.bin is compressed inside the zip (unsupported)") + } + off, err := pf.DataOffset() + if err != nil { + zr.Close() + return nil, 0, nil, 0, nil, err + } + fh, err := os.Open(zipPath) + if err != nil { + zr.Close() + return nil, 0, nil, 0, nil, err + } + closer := func() { fh.Close(); zr.Close() } + + sr := io.NewSectionReader(fh, off, int64(pf.UncompressedSize64)) + hdr := make([]byte, 20) + if _, err := io.ReadFull(sr, hdr); err != nil { + closer() + return nil, 0, nil, 0, nil, err + } + if string(hdr[0:4]) != payloadMagic { + closer() + return nil, 0, nil, 0, nil, fmt.Errorf("bad payload magic — not a valid payload.bin") + } + version := binary.BigEndian.Uint64(hdr[4:12]) + manifestSize := binary.BigEndian.Uint64(hdr[12:20]) + headerSize := int64(20) + var metaSig uint32 + if version >= 2 { + var b4 [4]byte + if _, err := io.ReadFull(sr, b4[:]); err != nil { + closer() + return nil, 0, nil, 0, nil, err + } + metaSig = binary.BigEndian.Uint32(b4[:]) + headerSize = 24 + } + manifest := make([]byte, manifestSize) + if _, err := io.ReadFull(sr, manifest); err != nil { + closer() + return nil, 0, nil, 0, nil, err + } + dataBase := headerSize + int64(manifestSize) + int64(metaSig) + + blockSize := uint64(4096) + var parts []plPart + pbFields(manifest, func(f, w int, d []byte, v uint64) bool { + switch f { + case 3: + if v > 0 { + blockSize = v + } + case 13: + parts = append(parts, plParsePartition(d)) + } + return true + }) + return sr, dataBase, parts, blockSize, closer, nil +} + +// ListPayloadPartitions returns the partitions inside an OTA's payload.bin. +func (a *App) ListPayloadPartitions(zipPath string) ([]PayloadPartition, error) { + _, _, parts, blockSize, closer, err := a.openPayload(zipPath) + if err != nil { + return nil, err + } + closer() + if len(parts) == 0 { + return nil, fmt.Errorf("no partitions found in payload") + } + out := make([]PayloadPartition, 0, len(parts)) + for _, p := range parts { + var blocks uint64 + for _, o := range p.ops { + for _, e := range o.dst { + blocks += e.num + } + } + out = append(out, PayloadPartition{Name: p.name, SizeMB: int(blocks * blockSize / (1024 * 1024))}) + } + return out, nil +} + +// ExtractPayloadPartition extracts one partition image to a chosen path. +func (a *App) ExtractPayloadPartition(zipPath, partName string) (string, error) { + sr, dataBase, parts, blockSize, closer, err := a.openPayload(zipPath) + if err != nil { + return "", err + } + defer closer() + + var part *plPart + for i := range parts { + if parts[i].name == partName { + part = &parts[i] + break + } + } + if part == nil { + return "", fmt.Errorf("partition %q not in payload", partName) + } + + var totalBlocks uint64 + for _, o := range part.ops { + for _, e := range o.dst { + totalBlocks += e.num + } + } + total := totalBlocks * blockSize + + outPath, err := a.SelectSaveFile(partName + ".img") + if err != nil { + return "", err + } + if outPath == "" { + return "Extraction cancelled.", nil + } + out, err := os.Create(outPath) + if err != nil { + return "", err + } + defer out.Close() + + ctx, cancel := a.beginCancellableOp(0) + defer cancel() + + var written uint64 + lastPct := -1 + emit := func() { + if total == 0 { + return + } + pct := int(written * 100 / total) + if pct != lastPct { + lastPct = pct + runtime.EventsEmit(a.ctx, "payload:progress", map[string]interface{}{"percent": pct}) + } + } + + for _, o := range part.ops { + if ctx.Err() != nil { + runtime.EventsEmit(a.ctx, "payload:done", nil) + return "", fmt.Errorf("cancelled") + } + switch o.typ { + case 6, 7: // ZERO / DISCARD — leave as sparse holes, just count progress + for _, e := range o.dst { + written += e.num * blockSize + } + emit() + continue + case 0, 1, 8: // REPLACE / REPLACE_BZ / REPLACE_XZ + default: + runtime.EventsEmit(a.ctx, "payload:done", nil) + return "", fmt.Errorf("this looks like an incremental OTA (op type %d) — only full OTAs are supported", o.typ) + } + + comp := make([]byte, o.dataLength) + if _, err := sr.Seek(dataBase+int64(o.dataOffset), io.SeekStart); err != nil { + runtime.EventsEmit(a.ctx, "payload:done", nil) + return "", err + } + if _, err := io.ReadFull(sr, comp); err != nil { + runtime.EventsEmit(a.ctx, "payload:done", nil) + return "", err + } + + var raw []byte + switch o.typ { + case 0: + raw = comp + case 1: + raw, err = io.ReadAll(bzip2.NewReader(bytes.NewReader(comp))) + case 8: + var zr *xz.Reader + if zr, err = xz.NewReader(bytes.NewReader(comp)); err == nil { + raw, err = io.ReadAll(zr) + } + } + if err != nil { + runtime.EventsEmit(a.ctx, "payload:done", nil) + return "", fmt.Errorf("decompress failed: %w", err) + } + + pos := 0 + for _, e := range o.dst { + n := int(e.num * blockSize) + if pos+n > len(raw) { + n = len(raw) - pos + } + if n <= 0 { + continue + } + if _, err := out.WriteAt(raw[pos:pos+n], int64(e.start*blockSize)); err != nil { + runtime.EventsEmit(a.ctx, "payload:done", nil) + return "", err + } + pos += n + written += uint64(n) + emit() + } + } + + out.Truncate(int64(total)) // ensure final size incl. trailing zero regions + runtime.EventsEmit(a.ctx, "payload:done", nil) + return fmt.Sprintf("Extracted %s → %s", partName, outPath), nil +} diff --git a/backend_props.go b/backend_props.go index 0fe0a6f..b19bcff 100644 --- a/backend_props.go +++ b/backend_props.go @@ -93,6 +93,17 @@ func (a *App) GetProp(key string) (string, error) { // categorizeProp assigns a category to a property based on its key prefix. func categorizeProp(key string) string { switch { + // Match by substring first so these group regardless of prefix. + case strings.Contains(key, "uwb"): + return "UWB" + case strings.Contains(key, "satellite"): + return "Satellite" + // Verified Boot / AVB + post-quantum signature schemes (Android 17 PQC). + case strings.Contains(key, "vbmeta") || strings.Contains(key, "avb") || + strings.Contains(key, "pqc") || strings.Contains(key, "dilithium") || + strings.Contains(key, "ml_dsa") || strings.Contains(key, "ml-dsa") || + strings.Contains(key, "sphincs") || strings.Contains(key, "falcon"): + return "Verified Boot / PQC" case strings.HasPrefix(key, "ro.build"): return "Build" case strings.HasPrefix(key, "ro.product"): diff --git a/backend_scrcpy.go b/backend_scrcpy.go new file mode 100644 index 0000000..6e698e8 --- /dev/null +++ b/backend_scrcpy.go @@ -0,0 +1,226 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// Screen mirroring via scrcpy. We don't embed scrcpy's video (that would mean +// reimplementing its client); instead we launch the system scrcpy, which opens +// its own movable/resizable window with full touch+keyboard control. ATK is the +// control panel: options + start/stop, and a scrcpy:stopped event when its +// window closes so the UI can reset. + +type ScrcpyOptions struct { + MaxSize int `json:"maxSize"` // longest edge in px; 0 = original + BitRateMbps int `json:"bitRateMbps"` // video bitrate in Mbps + MaxFps int `json:"maxFps"` // 0 = unlimited + StayAwake bool `json:"stayAwake"` + TurnScreenOff bool `json:"turnScreenOff"` + ShowTouches bool `json:"showTouches"` + AlwaysOnTop bool `json:"alwaysOnTop"` + Fullscreen bool `json:"fullscreen"` + Borderless bool `json:"borderless"` // hide the WM title bar / decorations + Record bool `json:"record"` + Detached bool `json:"detached"` // keep the mirror alive after ATK closes + NoAudio bool `json:"noAudio"` + ViewOnly bool `json:"viewOnly"` // --no-control + VideoCodec string `json:"videoCodec"` // "", h264, h265, av1 + Orientation string `json:"orientation"` // "", 0, 90, 180, 270 +} + +var ( + scrcpyMu sync.Mutex + scrcpyCmd *exec.Cmd + scrcpyDetached bool +) + +// ScrcpyAvailable returns the scrcpy version string, or an error if not found. +func (a *App) ScrcpyAvailable() (string, error) { + p, err := exec.LookPath("scrcpy") + if err != nil { + return "", fmt.Errorf("scrcpy not found — install with: sudo apt install scrcpy") + } + out, err := exec.Command(p, "--version").Output() + if err != nil { + return "scrcpy", nil + } + return strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]), nil +} + +// ScrcpyRunning reports whether ATK is currently managing a mirror it launched. +// We deliberately track only our own process (not a system-wide scrcpy scan): +// scanning produced false "Stop" states from processes caught mid-exit, and the +// view never re-polled. A fresh launch always shows Start. +func (a *App) ScrcpyRunning() bool { + scrcpyMu.Lock() + defer scrcpyMu.Unlock() + return scrcpyCmd != nil +} + +// StartScrcpy launches scrcpy in its own window with the given options. +func (a *App) StartScrcpy(opts ScrcpyOptions) error { + scrcpyMu.Lock() + tracked := scrcpyCmd != nil + scrcpyMu.Unlock() + if tracked { + return fmt.Errorf("a mirror is already running") + } + + p, err := exec.LookPath("scrcpy") + if err != nil { + return fmt.Errorf("scrcpy not found — install with: sudo apt install scrcpy") + } + + // Empty title so the WM title bar shows no text. (Omitting --window-title + // would make scrcpy fall back to the device model name, which is still text.) + args := []string{"--window-title", ""} + if opts.Borderless { + args = append(args, "--window-borderless") + } + if opts.MaxSize > 0 { + args = append(args, "--max-size", strconv.Itoa(opts.MaxSize)) + } + if opts.BitRateMbps > 0 { + args = append(args, "--video-bit-rate", strconv.Itoa(opts.BitRateMbps)+"M") + } + if opts.MaxFps > 0 { + args = append(args, "--max-fps", strconv.Itoa(opts.MaxFps)) + } + if opts.StayAwake { + args = append(args, "--stay-awake") + } + if opts.TurnScreenOff { + args = append(args, "--turn-screen-off") + } + if opts.ShowTouches { + args = append(args, "--show-touches") + } + if opts.AlwaysOnTop { + args = append(args, "--always-on-top") + } + if opts.Fullscreen { + args = append(args, "--fullscreen") + } + if opts.NoAudio { + args = append(args, "--no-audio") + } + if opts.ViewOnly { + args = append(args, "--no-control") + } + if opts.VideoCodec != "" { + args = append(args, "--video-codec="+opts.VideoCodec) + } + if opts.Orientation != "" { + args = append(args, "--capture-orientation="+opts.Orientation) + } + if opts.Record { + path, derr := a.SelectSaveFile("scrcpy-recording.mp4") + if derr != nil { + return fmt.Errorf("save dialog failed: %w", derr) + } + if path == "" { + return fmt.Errorf("recording cancelled") + } + args = append(args, "--record", path) + } + + // Clean up any orphan mirror (e.g. left over from a crash/hard-kill of a + // previous ATK) so Start always yields exactly one window, never a stack. + if pk, perr := exec.LookPath("pkill"); perr == nil { + exec.Command(pk, "-x", "scrcpy").Run() + } + + cmd := exec.Command(p, args...) + setCommandSysProcAttr(cmd) + // Inherit the desktop session (DISPLAY/WAYLAND_DISPLAY) and point scrcpy at + // the same adb ATK resolved, so it doesn't depend on adb being on PATH. + env := os.Environ() + if adbPath, aerr := a.getBinaryPath("adb"); aerr == nil { + env = append(env, "ADB="+adbPath) + } + cmd.Env = env + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start scrcpy: %w", err) + } + + scrcpyMu.Lock() + scrcpyCmd = cmd + scrcpyDetached = opts.Detached + scrcpyMu.Unlock() + + // Reap the process and tell the UI when the window is closed. + go func() { + cmd.Wait() + scrcpyMu.Lock() + scrcpyCmd = nil + scrcpyDetached = false + scrcpyMu.Unlock() + runtime.EventsEmit(a.ctx, "scrcpy:stopped", nil) + }() + + return nil +} + +// CaptureScreenshot grabs the device's current screen as a PNG and saves it to +// a user-chosen path. Independent of scrcpy — works whenever a device is +// connected. Uses `adb exec-out screencap -p` (raw bytes, no CRLF mangling). +// Returns the saved path, or "" if the user cancelled the save dialog. +func (a *App) CaptureScreenshot() (string, error) { + adbPath, err := a.getBinaryPath("adb") + if err != nil { + return "", err + } + + cmd := exec.Command(adbPath, "exec-out", "screencap", "-p") + setCommandSysProcAttr(cmd) + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(errb.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("%s", msg) + } + if out.Len() == 0 { + return "", fmt.Errorf("no screen data — is a device connected and unlocked?") + } + + path, err := a.SelectSaveFile("screenshot-" + time.Now().Format("20060102-150405") + ".png") + if err != nil { + return "", err + } + if path == "" { + return "", nil + } + if err := os.WriteFile(path, out.Bytes(), 0o644); err != nil { + return "", fmt.Errorf("failed to save: %w", err) + } + return path, nil +} + +// StopScrcpy terminates the running mirror session (closes the scrcpy window). +func (a *App) StopScrcpy() error { + scrcpyMu.Lock() + cmd := scrcpyCmd + scrcpyMu.Unlock() + if cmd != nil && cmd.Process != nil { + return cmd.Process.Kill() + } + // No tracked handle — kill a leftover/orphan scrcpy by name. + if p, err := exec.LookPath("pkill"); err == nil { + exec.Command(p, "-x", "scrcpy").Run() + } + return nil +} diff --git a/backend_transfer.go b/backend_transfer.go new file mode 100644 index 0000000..be0b4c6 --- /dev/null +++ b/backend_transfer.go @@ -0,0 +1,211 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// Streaming push/pull with live progress. adb prints transfer progress as +// `[ 42%] path` lines terminated by a carriage return; we split on \r as well +// as \n so each update is its own token, parse the percentage, and forward it +// to the frontend via transfer:* events. ETA is computed frontend-side from the +// percentage over elapsed time. Integrates with beginCancellableOp so the +// existing Cancel button (CancelOperation) aborts a transfer. + +var transferPercentRe = regexp.MustCompile(`(\d+)%`) + +// scanCRLF is a bufio.SplitFunc that breaks on either \n or \r, so adb's +// carriage-return progress updates surface as discrete tokens. +func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + for i, b := range data { + if b == '\n' || b == '\r' { + return i + 1, data[:i], nil + } + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil +} + +func baseName(p string) string { + p = strings.TrimRight(p, "/") + if i := strings.LastIndexAny(p, `/\`); i >= 0 { + return p[i+1:] + } + return p +} + +// runTransfer runs an adb push/pull, emitting transfer:progress events as it +// goes. kind is "push" or "pull"; label is the item name shown in the UI. +func (a *App) runTransfer(ctx context.Context, kind, label string, args ...string) error { + adbPath, err := a.getBinaryPath("adb") + if err != nil { + return err + } + + cmd := exec.CommandContext(ctx, adbPath, args...) + setCommandSysProcAttr(cmd) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start adb %s: %w", kind, err) + } + + emit := func(percent int) { + runtime.EventsEmit(a.ctx, "transfer:progress", map[string]interface{}{ + "kind": kind, "label": label, "percent": percent, + }) + } + emit(0) + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + scanner.Split(scanCRLF) + last := -1 + for scanner.Scan() { + tok := strings.TrimSpace(scanner.Text()) + if tok == "" { + continue + } + if m := transferPercentRe.FindStringSubmatch(tok); m != nil { + if p, perr := strconv.Atoi(m[1]); perr == nil && p != last { + last = p + emit(p) + } + } + } + + if werr := cmd.Wait(); werr != nil { + if ctx.Err() == context.Canceled { + return fmt.Errorf("cancelled") + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = werr.Error() + } + return fmt.Errorf("%s", msg) + } + emit(100) + return nil +} + +// PushWithProgress pushes a local file into remoteDir, streaming progress. +func (a *App) PushWithProgress(localPath, remoteDir string) (string, error) { + ctx, cancel := a.beginCancellableOp(60 * time.Minute) + defer cancel() + + name := baseName(localPath) + err := a.runTransfer(ctx, "push", name, "push", localPath, remoteDir) + runtime.EventsEmit(a.ctx, "transfer:done", nil) + if err != nil { + if strings.Contains(err.Error(), "cancelled") { + return "", fmt.Errorf("push cancelled") + } + return "", fmt.Errorf("push failed: %w", err) + } + return fmt.Sprintf("Pushed %s", name), nil +} + +// PushPathsWithProgress pushes the given local files into remoteDir on the +// device, one at a time, each with its own progress bar. Used by the Computer +// browser's "Push to device" action. +func (a *App) PushPathsWithProgress(localPaths []string, remoteDir string) (string, error) { + if len(localPaths) == 0 { + return "", fmt.Errorf("no files selected") + } + if strings.TrimSpace(remoteDir) == "" { + return "", fmt.Errorf("no device destination") + } + + ctx, cancel := a.beginCancellableOp(60 * time.Minute) + defer cancel() + + var ok, fail int + var details strings.Builder + for _, lp := range localPaths { + name := baseName(lp) + err := a.runTransfer(ctx, "push", name, "push", lp, remoteDir) + if err != nil { + if strings.Contains(err.Error(), "cancelled") { + details.WriteString(fmt.Sprintf("• %s: cancelled\n", name)) + fail++ + break + } + fail++ + details.WriteString(fmt.Sprintf("• %s: %v\n", name, err)) + } else { + ok++ + } + } + runtime.EventsEmit(a.ctx, "transfer:done", nil) + + summary := fmt.Sprintf("Pushed %d item(s) to %s.", ok, remoteDir) + if fail > 0 { + summary += fmt.Sprintf(" Failed: %d\n%s", fail, details.String()) + } + return summary, nil +} + +// PullPathsWithProgress pulls the given remote paths into a user-chosen local +// directory, one at a time, each with its own progress bar. +func (a *App) PullPathsWithProgress(remotePaths []string) (string, error) { + if len(remotePaths) == 0 { + return "", fmt.Errorf("no files selected") + } + + localDir, err := a.SelectDirectoryForPull() + if err != nil { + return "", fmt.Errorf("folder dialog failed: %w", err) + } + if localDir == "" { + return "Pull cancelled.", nil + } + + ctx, cancel := a.beginCancellableOp(0) + defer cancel() + + var ok, fail int + var details strings.Builder + for _, rp := range remotePaths { + name := baseName(rp) + // -a preserves timestamps + err := a.runTransfer(ctx, "pull", name, "pull", "-a", rp, localDir) + if err != nil { + if strings.Contains(err.Error(), "cancelled") { + details.WriteString(fmt.Sprintf("• %s: cancelled\n", name)) + fail++ + break + } + fail++ + details.WriteString(fmt.Sprintf("• %s: %v\n", name, err)) + } else { + ok++ + } + } + runtime.EventsEmit(a.ctx, "transfer:done", nil) + + summary := fmt.Sprintf("Pulled %d item(s) to %s.", ok, localDir) + if fail > 0 { + summary += fmt.Sprintf(" Failed: %d\n%s", fail, details.String()) + } + return summary, nil +} diff --git a/build/appicon.png b/build/appicon.png index 76005ae..79bce4d 100644 Binary files a/build/appicon.png and b/build/appicon.png differ diff --git a/build/atk.desktop b/build/atk.desktop index a4e8b1d..758c07c 100644 --- a/build/atk.desktop +++ b/build/atk.desktop @@ -1,4 +1,5 @@ [Desktop Entry] +Version=1.0 Type=Application Name=ATK GenericName=Android Toolkit diff --git a/dialog_service.go b/dialog_service.go index 874494b..6c124f2 100644 --- a/dialog_service.go +++ b/dialog_service.go @@ -57,6 +57,15 @@ func (a *App) SelectSaveFile(defaultName string) (string, error) { return path, err } +// SelectAnyFile opens a native file picker with no type filter. +func (a *App) SelectAnyFile() (string, error) { + path, err := zenity.SelectFile(zenity.Title("Select a file")) + if err == zenity.ErrCanceled { + return "", nil + } + return path, err +} + // SelectDirectoryForPull opens a native directory picker. func (a *App) SelectDirectoryForPull() (string, error) { path, err := zenity.SelectFile( diff --git a/file_service.go b/file_service.go index 15bcb3b..1664b6c 100644 --- a/file_service.go +++ b/file_service.go @@ -2,20 +2,38 @@ package main import ( "fmt" + "os" "regexp" "strings" "time" ) +// shellQuote wraps s in single quotes for safe use inside the device's +// /system/bin/sh. `adb shell a b c` does NOT preserve argument boundaries — it +// joins the args with spaces and re-parses the result through the remote shell. +// So paths containing spaces, parentheses, $, *, etc. break unless we quote them +// ourselves (host-side discrete args only protect the *host* shell, not adb's). +// Embedded single quotes are escaped as '\'' (close, escaped quote, reopen). +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + // ListFiles lists files in the given remote path on the device. -// path is passed as a discrete argument - no shell injection. func (a *App) ListFiles(path string) ([]FileEntry, error) { if path == "" { path = "/" } - // ls, -lA, and path are all discrete args - safe - output, err := a.runAdbShell("ls", "-lA", path) + // Append a trailing slash so the final path component is dereferenced if it's + // a symlink (e.g. /sdcard -> /storage/self/primary). Without it, `ls` on a + // symlink lists the link entry itself, not the directory contents — the + // long-standing "/sdcard shows nothing, /sdcard/ works" bug. + listPath := path + if !strings.HasSuffix(listPath, "/") { + listPath += "/" + } + + output, err := a.runAdbShell("ls", "-lA", shellQuote(listPath)) if err != nil { return nil, fmt.Errorf("failed to list %s: %w", path, err) } @@ -129,8 +147,7 @@ func (a *App) CreateFolder(fullPath string) (string, error) { if err := validateRemotePath(fullPath); err != nil { return "", err } - // mkdir, -p, and path are discrete args - _, err := a.runAdbShell("mkdir", "-p", fullPath) + _, err := a.runAdbShell("mkdir", "-p", shellQuote(fullPath)) if err != nil { return "", fmt.Errorf("failed to create folder: %w", err) } @@ -143,8 +160,7 @@ func (a *App) DeleteFile(fullPath string) (string, error) { if err := validateRemotePath(fullPath); err != nil { return "", err } - // rm, -rf, and path are discrete args - _, err := a.runAdbShell("rm", "-rf", fullPath) + _, err := a.runAdbShell("rm", "-rf", shellQuote(fullPath)) if err != nil { return "", fmt.Errorf("failed to delete: %w", err) } @@ -160,8 +176,7 @@ func (a *App) RenameFile(oldPath, newPath string) (string, error) { if err := validateRemotePath(newPath); err != nil { return "", fmt.Errorf("invalid destination path: %w", err) } - // mv, oldPath, newPath are discrete args - _, err := a.runAdbShell("mv", oldPath, newPath) + _, err := a.runAdbShell("mv", shellQuote(oldPath), shellQuote(newPath)) if err != nil { return "", fmt.Errorf("failed to rename: %w", err) } @@ -177,8 +192,7 @@ func (a *App) CopyFile(srcPath, dstPath string) (string, error) { if err := validateRemotePath(dstPath); err != nil { return "", fmt.Errorf("invalid destination path: %w", err) } - // cp, -r, srcPath, dstPath are all discrete args - _, err := a.runAdbShell("cp", "-r", srcPath, dstPath) + _, err := a.runAdbShell("cp", "-r", shellQuote(srcPath), shellQuote(dstPath)) if err != nil { return "", fmt.Errorf("failed to copy: %w", err) } @@ -249,6 +263,72 @@ func (a *App) PullMultipleFiles(remotePaths []string) (string, error) { return summary, nil } +// HomeDir returns the local user's home directory (starting point for the +// Computer browser in the Files view). +func (a *App) HomeDir() (string, error) { + return os.UserHomeDir() +} + +// ListLocalFiles lists files in a directory on THIS computer (not the device), +// returning the same FileEntry shape as ListFiles so the UI can render either. +func (a *App) ListLocalFiles(path string) ([]FileEntry, error) { + if path == "" { + if h, err := os.UserHomeDir(); err == nil { + path = h + } else { + path = "/" + } + } + + entries, err := os.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("failed to list %s: %w", path, err) + } + + files := make([]FileEntry, 0, len(entries)) + for _, e := range entries { + ftype := "File" + if e.IsDir() { + ftype = "Directory" + } else if e.Type()&os.ModeSymlink != 0 { + ftype = "Symlink" + } + + var size, perms, date, tm string + if info, ierr := e.Info(); ierr == nil { + if ftype == "File" { + size = fmt.Sprintf("%d", info.Size()) + } + perms = info.Mode().String() + t := info.ModTime() + date = t.Format("2006-01-02") + tm = t.Format("15:04") + } + + files = append(files, FileEntry{ + Name: e.Name(), Type: ftype, Size: size, + Permissions: perms, Date: date, Time: tm, + }) + } + return files, nil +} + +// SaveTextFile prompts for a save location and writes text to it. Returns the +// chosen path, or "" if the user cancelled. +func (a *App) SaveTextFile(defaultName, content string) (string, error) { + path, err := a.SelectSaveFile(defaultName) + if err != nil { + return "", err + } + if path == "" { + return "", nil + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + return path, nil +} + // validateRemotePath checks that a remote path is safe to use as an ADB argument. // Rejects empty paths and the bare root "/" to prevent accidental rm -rf /. func validateRemotePath(path string) error { diff --git a/frontend/index.html b/frontend/index.html index 991e73b..db795ab 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,9 +4,6 @@ ATK — Android Toolkit - - -
diff --git a/frontend/package.json b/frontend/package.json index 175b202..b71df3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,10 +9,15 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource/ibm-plex-sans": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@types/three": "^0.184.1", "lucide-react": "^0.383.0", + "pixi.js": "^8.18.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "sonner": "^1.7.4" + "sonner": "^1.7.4", + "three": "^0.184.0" }, "devDependencies": { "@types/react": "^18.3.28", @@ -22,6 +27,7 @@ "postcss": "^8.5.8", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "vite": "^5.4.21" + "vite": "^5.4.21", + "vite-plugin-javascript-obfuscator": "^3.1.0" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 7d12310..edfbbde 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d772c5ee4d5ec9453e4b361871c1c91f \ No newline at end of file +bfb47127747332de1e5119ef153cca76 \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 234d1e2..662a16b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,9 +8,21 @@ importers: .: dependencies: + '@fontsource/ibm-plex-sans': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/jetbrains-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@types/three': + specifier: ^0.184.1 + version: 0.184.1 lucide-react: specifier: ^0.383.0 version: 0.383.0(react@18.3.1) + pixi.js: + specifier: ^8.18.1 + version: 8.18.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -20,6 +32,9 @@ importers: sonner: specifier: ^1.7.4 version: 1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + three: + specifier: ^0.184.0 + version: 0.184.0 devDependencies: '@types/react': specifier: ^18.3.28 @@ -45,6 +60,9 @@ importers: vite: specifier: ^5.4.21 version: 5.4.21(lightningcss@1.32.0) + vite-plugin-javascript-obfuscator: + specifier: ^3.1.0 + version: 3.1.0 packages: @@ -135,6 +153,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -273,6 +294,31 @@ packages: cpu: [x64] os: [win32] + '@fontsource/ibm-plex-sans@5.2.8': + resolution: {integrity: sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==} + + '@fontsource/jetbrains-mono@5.2.8': + resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} + + '@inversifyjs/common@1.3.3': + resolution: {integrity: sha512-ZH0wrgaJwIo3s9gMCDM2wZoxqrJ6gB97jWXncROfYdqZJv8f3EkqT57faZqN5OTeHWgtziQ6F6g3L8rCvGceCw==} + + '@inversifyjs/core@1.3.4': + resolution: {integrity: sha512-gCCmA4BdbHEFwvVZ2elWgHuXZWk6AOu/1frxsS+2fWhjEk2c/IhtypLo5ytSUie1BCiT6i9qnEo4bruBomQsAA==} + + '@inversifyjs/reflect-metadata-utils@0.2.3': + resolution: {integrity: sha512-d3D0o9TeSlvaGM2I24wcNw/Aj3rc4OYvHXOKDC09YEph5fMMiKd6fq1VTQd9tOkDNWvVbw+cnt45Wy9P/t5Lvw==} + peerDependencies: + reflect-metadata: 0.2.2 + + '@javascript-obfuscator/escodegen@2.3.1': + resolution: {integrity: sha512-Z0HEAVwwafOume+6LFXirAVZeuEMKWuPzpFbQhCEU9++BMz0IwEa9bmedJ+rMn/IlXRBID9j3gQ0XYAa6jM10g==} + engines: {node: '>=6.0'} + + '@javascript-obfuscator/estraverse@5.4.0': + resolution: {integrity: sha512-CZFX7UZVN9VopGbjTx4UXaXsi9ewoM1buL0kY7j1ftYdSs7p2spv9opxFjHlQ/QGTgh4UqufYqJJ0WKLml7b6w==} + engines: {node: '>=4.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -301,6 +347,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pixi/colord@2.9.6': + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -338,79 +387,66 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -442,6 +478,9 @@ packages: cpu: [x64] os: [win32] + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -454,9 +493,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/minimatch@3.0.5': + resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -468,12 +513,51 @@ packages: '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.184.1': + resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@webgpu/types@0.1.70': + resolution: {integrity: sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -484,6 +568,24 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + array-differ@3.0.0: + resolution: {integrity: sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==} + engines: {node: '>=8'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + autoprefixer@10.4.27: resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} @@ -491,6 +593,13 @@ packages: peerDependencies: postcss: ^8.1.0 + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + baseline-browser-mapping@2.10.11: resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} engines: {node: '>=6.0.0'} @@ -500,6 +609,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -509,6 +621,21 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -516,17 +643,55 @@ packages: caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chance@1.1.13: + resolution: {integrity: sha512-V6lQCljcLznE7tUYUM9EOAnnKXbctE6j/rdQkYOHIWbfGQbrzTsAXNW9CdU5XCo4ArXQCj/rb6HgxPlmGJcaUg==} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + class-validator@0.14.3: + resolution: {integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + conf@15.0.2: + resolution: {integrity: sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw==} + engines: {node: '>=20'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -535,6 +700,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debounce-fn@6.0.0: + resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -544,6 +713,17 @@ packages: supports-color: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -554,9 +734,36 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + electron-to-chromium@1.5.327: resolution: {integrity: sha512-hLxLdIJDf8zIzKoH2TPCs+Botc+wUmj9sp4jVMwklY/sKleM8xxxOExRX3Gxj73nCXmJe3anhG7SvsDDPDvmuQ==} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -566,10 +773,47 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -582,10 +826,17 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -597,10 +848,25 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -609,14 +875,50 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inversify@6.1.4: + resolution: {integrity: sha512-PbxrZH/gTa1fpPEEGAjJQzK8tKMIp5gRg6EFNJlCtzUcycuNdmhv3uk5P8Itm/RIjgHJO16oQRLo9IHzQN51bA==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -625,18 +927,49 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + + javascript-obfuscator@4.2.2: + resolution: {integrity: sha512-+7oXAUnFCA6vS0omIGHcWpSr67dUBIF7FKGYSXyzxShSLqM6LBgdugWKFl0XrYtGWyJMGfQR5F4LL85iCefkRA==} + engines: {node: '>=18.0.0'} + hasBin: true + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + + js-string-escape@1.0.1: + resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==} + engines: {node: '>= 0.8'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -645,11 +978,24 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + levn@0.3.0: + resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} + engines: {node: '>= 0.8.0'} + + libphonenumber-js@1.13.5: + resolution: {integrity: sha512-7/kRezHmQlMfO6pmvt34orO/g3j1C47k8FCBXFgj/mklTLwQdBca1LkhDK6RM8UyM6JqHFAIikMdkKkyfQy39A==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -685,28 +1031,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -743,17 +1085,43 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multimatch@5.0.0: + resolution: {integrity: sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==} + engines: {node: '>=10'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -777,6 +1145,25 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + optionator@0.8.3: + resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} + engines: {node: '>= 0.8.0'} + + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -799,6 +1186,13 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pixi.js@8.18.1: + resolution: {integrity: sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -846,6 +1240,14 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.1.2: + resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} + engines: {node: '>= 0.8.0'} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -869,6 +1271,13 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -886,6 +1295,10 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -893,6 +1306,15 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: @@ -903,15 +1325,42 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + string-template@1.0.0: + resolution: {integrity: sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==} + + stringz@2.1.0: + resolution: {integrity: sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A==} + + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -924,6 +1373,13 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -935,11 +1391,26 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.3.2: + resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} + engines: {node: '>= 0.8.0'} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -949,6 +1420,16 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vite-plugin-javascript-obfuscator@3.1.0: + resolution: {integrity: sha512-sf4JFlG1iUPl7bLXHGOy+bKWOQUFyXzJFWa+n2S2xMMvyfM+V9R40HhpZoIF1eAjifArM1SF7fbSFIaTuUIbPA==} + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -980,6 +1461,17 @@ packages: terser: optional: true + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + + which-typed-array@1.1.21: + resolution: {integrity: sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==} + engines: {node: '>= 0.4'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1099,6 +1591,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@dimforge/rapier3d-compat@0.12.0': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1168,6 +1662,34 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@fontsource/ibm-plex-sans@5.2.8': {} + + '@fontsource/jetbrains-mono@5.2.8': {} + + '@inversifyjs/common@1.3.3': {} + + '@inversifyjs/core@1.3.4(reflect-metadata@0.2.2)': + dependencies: + '@inversifyjs/common': 1.3.3 + '@inversifyjs/reflect-metadata-utils': 0.2.3(reflect-metadata@0.2.2) + transitivePeerDependencies: + - reflect-metadata + + '@inversifyjs/reflect-metadata-utils@0.2.3(reflect-metadata@0.2.2)': + dependencies: + reflect-metadata: 0.2.2 + + '@javascript-obfuscator/escodegen@2.3.1': + dependencies: + '@javascript-obfuscator/estraverse': 5.4.0 + esprima: 4.0.1 + esutils: 2.0.3 + optionator: 0.8.3 + optionalDependencies: + source-map: 0.6.1 + + '@javascript-obfuscator/estraverse@5.4.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1199,6 +1721,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@pixi/colord@2.9.6': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.0': @@ -1276,6 +1800,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true + '@tweenjs/tween.js@23.1.3': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -1297,8 +1823,12 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/earcut@3.0.0': {} + '@types/estree@1.0.8': {} + '@types/minimatch@3.0.5': {} + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.28)': @@ -1310,6 +1840,21 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/stats.js@0.17.4': {} + + '@types/three@0.184.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + + '@types/validator@13.15.10': {} + + '@types/webxr@0.5.24': {} + '@vitejs/plugin-react@4.7.0(vite@5.4.21(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.0 @@ -1322,6 +1867,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@webgpu/types@0.1.70': {} + + '@xmldom/xmldom@0.8.13': {} + + acorn@8.15.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -1331,6 +1897,25 @@ snapshots: arg@5.0.2: {} + array-differ@3.0.0: {} + + array-union@2.1.0: {} + + arrify@2.0.1: {} + + assert@2.1.0: + dependencies: + call-bind: 1.0.9 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 @@ -1340,10 +1925,21 @@ snapshots: postcss: 8.5.8 postcss-value-parser: 4.2.0 + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + balanced-match@1.0.2: {} + baseline-browser-mapping@2.10.11: {} binary-extensions@2.3.0: {} + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1356,10 +1952,40 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-from@1.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase-css@2.0.1: {} caniuse-lite@1.0.30001781: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chance@1.1.13: {} + + char-regex@1.0.2: {} + + charenc@0.0.2: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -1372,18 +1998,66 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + class-validator@0.14.3: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.5 + validator: 13.15.35 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@12.1.0: {} + commander@4.1.1: {} + concat-map@0.0.1: {} + + conf@15.0.2: + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + atomically: 2.1.1 + debounce-fn: 6.0.0 + dot-prop: 10.1.0 + env-paths: 3.0.0 + json-schema-typed: 8.0.2 + semver: 7.8.1 + uint8array-extras: 1.5.0 + convert-source-map@2.0.0: {} + crypt@0.0.2: {} + cssesc@3.0.0: {} csstype@3.2.3: {} + debounce-fn@6.0.0: + dependencies: + mimic-function: 5.0.1 + debug@4.4.3: dependencies: ms: 2.1.3 + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + detect-libc@2.1.2: optional: true @@ -1391,8 +2065,30 @@ snapshots: dlv@1.1.3: {} + dot-prop@10.1.0: + dependencies: + type-fest: 5.7.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + earcut@3.0.2: {} + electron-to-chromium@1.5.327: {} + env-paths@3.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1421,6 +2117,27 @@ snapshots: escalade@3.2.0: {} + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@4.2.1: {} + + esprima@4.0.1: {} + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + eventemitter3@5.0.4: {} + + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1429,6 +2146,10 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.2: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -1437,10 +2158,16 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + fraction.js@5.3.4: {} fsevents@2.3.3: @@ -1448,8 +2175,32 @@ snapshots: function-bind@1.1.2: {} + generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -1458,34 +2209,133 @@ snapshots: dependencies: is-glob: 4.0.3 + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 + inherits@2.0.4: {} + + inversify@6.1.4(reflect-metadata@0.2.2): + dependencies: + '@inversifyjs/common': 1.3.3 + '@inversifyjs/core': 1.3.4(reflect-metadata@0.2.2) + transitivePeerDependencies: + - reflect-metadata + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 + is-buffer@1.1.6: {} + + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 is-extglob@2.1.1: {} + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-nan@1.3.2: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + is-number@7.0.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.21 + + ismobilejs@1.1.1: {} + + javascript-obfuscator@4.2.2: + dependencies: + '@javascript-obfuscator/escodegen': 2.3.1 + '@javascript-obfuscator/estraverse': 5.4.0 + acorn: 8.15.0 + assert: 2.1.0 + chalk: 4.1.2 + chance: 1.1.13 + class-validator: 0.14.3 + commander: 12.1.0 + conf: 15.0.2 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + fast-deep-equal: 3.1.3 + inversify: 6.1.4(reflect-metadata@0.2.2) + js-string-escape: 1.0.1 + md5: 2.3.0 + mkdirp: 3.0.1 + multimatch: 5.0.0 + process: 0.11.10 + reflect-metadata: 0.2.2 + source-map-support: 0.5.21 + string-template: 1.0.0 + stringz: 2.1.0 + tslib: 2.8.1 + jiti@1.21.7: {} + js-binary-schema-parser@2.0.3: {} + + js-string-escape@1.0.1: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json5@2.2.3: {} + levn@0.3.0: + dependencies: + prelude-ls: 1.1.2 + type-check: 0.3.2 + + libphonenumber-js@1.13.5: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1552,15 +2402,41 @@ snapshots: dependencies: react: 18.3.1 + math-intrinsics@1.1.0: {} + + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.2 + mimic-function@5.0.1: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + mkdirp@3.0.1: {} + ms@2.1.3: {} + multimatch@5.0.0: + dependencies: + '@types/minimatch': 3.0.5 + array-differ: 3.0.0 + array-union: 2.1.0 + arrify: 2.0.1 + minimatch: 3.1.5 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -1577,6 +2453,33 @@ snapshots: object-hash@3.0.0: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + optionator@0.8.3: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.3.0 + prelude-ls: 1.1.2 + type-check: 0.3.2 + word-wrap: 1.2.5 + + parse-svg-path@0.1.2: {} + path-parse@1.0.7: {} picocolors@1.1.1: {} @@ -1589,6 +2492,21 @@ snapshots: pirates@4.0.7: {} + pixi.js@8.18.1: + dependencies: + '@pixi/colord': 2.9.6 + '@types/earcut': 3.0.0 + '@webgpu/types': 0.1.70 + '@xmldom/xmldom': 0.8.13 + earcut: 3.0.2 + eventemitter3: 5.0.4 + gifuct-js: 2.1.2 + ismobilejs: 1.1.1 + parse-svg-path: 0.1.2 + tiny-lru: 11.4.7 + + possible-typed-array-names@1.1.0: {} + postcss-import@15.1.0(postcss@8.5.8): dependencies: postcss: 8.5.8 @@ -1626,6 +2544,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.1.2: {} + + process@0.11.10: {} + queue-microtask@1.2.3: {} react-dom@18.3.1(react@18.3.1): @@ -1648,6 +2570,10 @@ snapshots: dependencies: picomatch: 2.3.2 + reflect-metadata@0.2.2: {} + + require-from-string@2.0.2: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -1691,12 +2617,29 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 semver@6.3.1: {} + semver@7.8.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + sonner@1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -1704,6 +2647,25 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + string-template@1.0.0: {} + + stringz@2.1.0: + dependencies: + char-regex: 1.0.2 + + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -1714,8 +2676,14 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} + tailwindcss@3.4.19: dependencies: '@alloc/quick-lru': 5.2.0 @@ -1752,6 +2720,10 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.184.0: {} + + tiny-lru@11.4.7: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -1763,8 +2735,20 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + + type-check@0.3.2: + dependencies: + prelude-ls: 1.1.2 + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + typescript@5.9.3: {} + uint8array-extras@1.5.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -1773,6 +2757,21 @@ snapshots: util-deprecate@1.0.2: {} + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.21 + + validator@13.15.35: {} + + vite-plugin-javascript-obfuscator@3.1.0: + dependencies: + anymatch: 3.1.3 + javascript-obfuscator: 4.2.2 + vite@5.4.21(lightningcss@1.32.0): dependencies: esbuild: 0.21.5 @@ -1782,4 +2781,18 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.32.0 + when-exit@2.1.5: {} + + which-typed-array@1.1.21: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + word-wrap@1.2.5: {} + yallist@3.1.1: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7396946..73668ce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,48 +1,67 @@ import { useState, useEffect } from 'react' import { Toaster } from 'sonner' import Sidebar from './components/layout/Sidebar' +import TitleBar from './components/layout/TitleBar' +import DismissibleBanner from './components/DismissibleBanner' +import LockGate from './components/LockGate' +import DangerGate from './components/DangerGate' import ViewDashboard from './components/views/ViewDashboard' import ViewFiles from './components/views/ViewFiles' +import ViewScreenMirror from './components/views/ViewScreenMirror' import ViewPackages from './components/views/ViewPackages' import ViewDebloater from './components/views/ViewDebloater' import ViewShell from './components/views/ViewShell' import ViewLogcat from './components/views/ViewLogcat' import ViewAppInspect from './components/views/ViewAppInspect' +import ViewApkAudit from './components/views/ViewApkAudit' import ViewCerts from './components/views/ViewCerts' import ViewBackup from './components/views/ViewBackup' import ViewProps from './components/views/ViewProps' import ViewFlasher from './components/views/ViewFlasher' -import ViewPixelFlasher from './components/views/ViewPixelFlasher' import ViewUtilities from './components/views/ViewUtilities' import ViewSettings from './components/views/ViewSettings' import { CheckSystemRequirements } from './lib/wails' +import { getSidebarPosition, onSidebarPositionChange, getSidebarLabels, onSidebarLabelsChange } from './lib/layout' +import { refreshAppLockStatus } from './lib/applock' import type { View } from './lib/types' export default function App() { const [view, setView] = useState('dashboard') const [ready, setReady] = useState(false) const [initError, setInitError] = useState('') + const [sidebarPos, setSidebarPos] = useState(getSidebarPosition()) + const [sidebarLabels, setSidebarLabels] = useState(getSidebarLabels()) + const [locked, setLocked] = useState(false) useEffect(() => { - CheckSystemRequirements() - .then(() => setReady(true)) - .catch((err: string) => { setInitError(err); setReady(true) }) + // Resolve the lock status before anything else so the gate can show. + refreshAppLockStatus() + .then(s => setLocked(s.enabled)) + .finally(() => { + CheckSystemRequirements() + .then(() => setReady(true)) + .catch((err: string) => { setInitError(err); setReady(true) }) + }) }, []) + useEffect(() => onSidebarPositionChange(setSidebarPos), []) + useEffect(() => onSidebarLabelsChange(setSidebarLabels), []) + const renderView = () => { switch (view) { case 'dashboard': return case 'files': return + case 'mirror': return case 'packages': return case 'debloater': return case 'shell': return case 'logcat': return case 'appinspect': return + case 'apkaudit': return case 'certs': return case 'backup': return case 'props': return case 'flasher': return - case 'pixelflasher': return case 'utilities': return case 'settings': return default: return @@ -58,25 +77,36 @@ export default function App() { ) + if (locked) return setLocked(false)} /> + + const sidebar = + return ( -
- -
- {initError && ( -
- - {initError} -
- )} -
{renderView()}
-
+
+ + +
+ {sidebarPos !== 'bottom' && sidebar} +
+ {initError && ( + + + {initError} + + )} +
{renderView()}
+
+ {sidebarPos === 'bottom' && sidebar} +
diff --git a/frontend/src/components/DangerGate.tsx b/frontend/src/components/DangerGate.tsx new file mode 100644 index 0000000..58169e3 --- /dev/null +++ b/frontend/src/components/DangerGate.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react' +import { ShieldAlert } from 'lucide-react' +import { _registerDangerHost, tryUnlockDanger, type DangerRequest } from '../lib/applock' + +// Modal host for the destructive-action re-auth prompt. Mounted once in App.tsx. +// ensureDangerUnlocked() (lib/applock) drives it: when a destructive action +// needs re-auth, it hands us a request whose `resolve` we call with the outcome. +export default function DangerGate() { + const [req, setReq] = useState(null) + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => _registerDangerHost(r => { + setPassword('') + setError('') + setReq(r) + }), []) + + if (!req) return null + + const close = (ok: boolean) => { + req.resolve(ok) + setReq(null) + } + + const submit = async (e: React.FormEvent) => { + e.preventDefault() + if (!password || busy) return + setBusy(true) + setError('') + try { + const ok = await tryUnlockDanger(password) + if (ok) { close(true); return } + setError('Incorrect password') + setPassword('') + } catch (err: any) { + setError(String(err)) + } finally { + setBusy(false) + } + } + + return ( +
{ if (e.target === e.currentTarget) close(false) }} + > +
+
+ +

Confirm with password

+
+

+ This is a destructive action. Re-enter your app password to continue. You won't be + asked again for a few minutes. +

+ setPassword(e.target.value)} + /> + {error &&

{error}

} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/DismissibleBanner.tsx b/frontend/src/components/DismissibleBanner.tsx new file mode 100644 index 0000000..5b32c64 --- /dev/null +++ b/frontend/src/components/DismissibleBanner.tsx @@ -0,0 +1,32 @@ +import { useState, type ReactNode } from 'react' +import { X } from 'lucide-react' +import { isDismissed, dismiss } from '../lib/dismissible' + +interface Props { + /** Stable unique id - dismissal is remembered against this. */ + id: string + /** Container classes (background, border, padding, text colour). */ + className?: string + children: ReactNode +} + +/** + * A banner the user can permanently hide with the ✕ button. The dismissal is + * remembered across restarts (keyed by `id`). Renders nothing once dismissed. + */ +export default function DismissibleBanner({ id, className = '', children }: Props) { + const [hidden, setHidden] = useState(() => isDismissed(id)) + if (hidden) return null + return ( +
+
{children}
+ +
+ ) +} diff --git a/frontend/src/components/LockGate.tsx b/frontend/src/components/LockGate.tsx new file mode 100644 index 0000000..78dab31 --- /dev/null +++ b/frontend/src/components/LockGate.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react' +import { Lock } from 'lucide-react' +import { VerifyAppPassword } from '../lib/wails' + +// Full-window launch gate. Rendered in place of the app when the lock is +// enabled and the session hasn't been unlocked yet. The backend stores only a +// salted scrypt hash; this just verifies and reveals the UI. +export default function LockGate({ onUnlock }: { onUnlock: () => void }) { + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + const submit = async (e: React.FormEvent) => { + e.preventDefault() + if (!password || busy) return + setBusy(true) + setError('') + try { + const ok = await VerifyAppPassword(password) + if (ok) { onUnlock(); return } + setError('Incorrect password') + setPassword('') + } catch (err: any) { + setError(String(err)) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+
+ +
+

ATK is locked

+

Enter your app password to continue

+
+ setPassword(e.target.value)} + /> + {error &&

{error}

} + +
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 68b50a2..9a79be7 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,77 +1,167 @@ import { LayoutDashboard, FolderOpen, Package, Terminal, - Zap, Wrench, Settings, Radio, Shield, Smartphone, - ScrollText, Search, Lock, Archive, SlidersHorizontal + Zap, Wrench, Settings, Shield, + ScrollText, Search, Lock, Archive, SlidersHorizontal, ScanSearch, MonitorSmartphone } from 'lucide-react' +import { useState, useEffect, useMemo, useRef } from 'react' import type { View } from '../../lib/types' +import type { SidebarPosition } from '../../lib/layout' +import { getHiddenViews, onHiddenViewsChange, getNavOrder, setNavOrder } from '../../lib/featureflags' interface Props { activeView: View onViewChange: (v: View) => void + position: SidebarPosition + showLabels: boolean } -const navItems: { view: View; icon: React.ReactNode; label: string; dividerBefore?: boolean }[] = [ +interface NavItem { view: View; icon: React.ReactNode; label: string; dividerBefore?: boolean } + +const navItems: NavItem[] = [ { view: 'dashboard', icon: , label: 'Dashboard' }, { view: 'files', icon: , label: 'Files' }, + { view: 'mirror', icon: , label: 'Screen Mirror' }, { view: 'packages', icon: , label: 'Packages' }, { view: 'debloater', icon: , label: 'Debloater' }, { view: 'shell', icon: , label: 'Shell' }, { view: 'logcat', icon: , label: 'Logcat', dividerBefore: true }, { view: 'appinspect', icon: , label: 'App Inspector' }, + { view: 'apkaudit', icon: , label: 'APK Audit' }, { view: 'certs', icon: , label: 'Certificates' }, { view: 'backup', icon: , label: 'Backup' }, { view: 'props', icon: , label: 'Prop Editor' }, { view: 'utilities', icon: , label: 'Utilities', dividerBefore: true }, { view: 'flasher', icon: , label: 'Flasher' }, - { view: 'pixelflasher', icon: , label: 'Pixel Flash' }, ] -export default function Sidebar({ activeView, onViewChange }: Props) { - return ( -