Add GSI Loader, APK Audit, Firmware, Intent Lab, Screen Mirror, Magisk, and App Lock features; bump to v1.2.0
- New sidebar views: GSI Loader (DSU + permanent fastboot flash), APK Audit, Firmware, Intent Lab, Screen Mirror - New backend modules: backend_gsi.go, backend_apkaudit*.go, backend_firmware.go, backend_intent.go, backend_magisk.go, backend_applock.go, backend_scrcpy.go, backend_payload.go, backend_privacy.go, backend_overview.go, backend_bootinfo.go, backend_flasher.go, backend_transfer.go, backend_filehttp.go - App Lock / danger-gate infrastructure (DangerGate, LockGate, applock.ts) - Privileged uninstall via embedded Android helper dex (android-helper/) - nfpm packaging version bump to 1.2.0
189
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
7
.gitignore
vendored
|
|
@ -37,3 +37,10 @@ ATK_*.log
|
|||
# Temp
|
||||
*.tmp
|
||||
/tmp/
|
||||
|
||||
# ── PROPRIETARY — Logcat visual map. NEVER publish to GitHub. ──
|
||||
# (Builds into the shipped binary as obfuscated JS + native Go; source stays private.)
|
||||
/proprietary/
|
||||
/backend_logcatpatterns.go
|
||||
/frontend/src/lib/logcatgraph.ts
|
||||
/frontend/src/components/views/LogcatMap.tsx
|
||||
|
|
|
|||
BIN
ATK_SCREENSHOTS/APK_Audit.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO1.mp4
Normal file
BIN
ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO2.mp4
Normal file
BIN
ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO3.mp4
Normal file
BIN
ATK_SCREENSHOTS/ATK_MAP_ENGINE_DEMO4.mp4
Normal file
BIN
ATK_SCREENSHOTS/ATK_screen_mirror_pref.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
ATK_SCREENSHOTS/App_Inspector.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
ATK_SCREENSHOTS/Backup.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
ATK_SCREENSHOTS/Certificates.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
ATK_SCREENSHOTS/Dashboard.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
ATK_SCREENSHOTS/Debloater.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
ATK_SCREENSHOTS/Files.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
ATK_SCREENSHOTS/Flasher.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
ATK_SCREENSHOTS/Logcat.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
ATK_SCREENSHOTS/Login_Window_Password.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
ATK_SCREENSHOTS/Packages.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
ATK_SCREENSHOTS/Prop_Editor.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
ATK_SCREENSHOTS/Settings.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
ATK_SCREENSHOTS/Settings2.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
ATK_SCREENSHOTS/Settings3.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
ATK_SCREENSHOTS/Shell.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
ATK_SCREENSHOTS/Utilities.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
428
README.md
|
|
@ -1,137 +1,306 @@
|
|||
```
|
||||
█████╗ ████████╗██╗ ██╗
|
||||
██╔══██╗╚══██╔══╝██║ ██╔╝
|
||||
███████║ ██║ █████╔╝
|
||||
██╔══██║ ██║ ██╔═██╗
|
||||
██║ ██║ ██║ ██║ ██╗
|
||||
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
|
||||
ANDROID TOOLKIT — v1.0.5
|
||||
```
|
||||
<p align="center">
|
||||
<img src="assets/appicon.png" alt="ATK" width="132" />
|
||||
</p>
|
||||
|
||||
> 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.
|
||||
<h1 align="center">ATK · Android Toolkit</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>An all-in-one Android command centre with a real-time system-map debugging engine.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/downloads/jegly/ATK/total?style=for-the-badge&color=50FA7B&label=Downloads" alt="Downloads" />
|
||||
<img src="https://img.shields.io/badge/License-GPLv3-BD93F9?style=for-the-badge" alt="License GPLv3" />
|
||||
<img src="https://img.shields.io/badge/Platform-Linux-50FA7B?style=for-the-badge&logo=linux&logoColor=282A36" alt="Linux" />
|
||||
<img src="https://img.shields.io/badge/Go%20+%20React%20(Wails)-8BE9FD?style=for-the-badge&color=8BE9FD&logoColor=282A36" alt="Go + React via Wails" />
|
||||
<a href="https://deepwiki.com/jegly/ATK"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
[ 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 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:
|
||||
|
||||
**▶️ 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
|
||||
|
||||
<p align="center"><img src="screenshot/Logcat.png" width="100%" alt="Live System Map"></p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshot/MAP1.png" alt="Live System Map view 1"></td>
|
||||
<td width="50%"><img src="screenshot/MAP2.png" alt="Live System Map view 2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshot/MAP3.png" alt="Live System Map view 3"></td>
|
||||
<td width="50%"><img src="screenshot/MAP4.png" alt="Live System Map view 4"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>📸 More screenshots</summary>
|
||||
|
||||
<table>
|
||||
<tr><td align="center"><b>Dashboard</b><br><img src="screenshot/Dashboard.png" alt="Dashboard"></td><td align="center"><b>File Explorer</b><br><img src="screenshot/Files.png" alt="Files"></td></tr>
|
||||
<tr><td align="center"><b>Package Manager</b><br><img src="screenshot/Packages.png" alt="Packages"></td><td align="center"><b>Debloater</b><br><img src="screenshot/Debloater.png" alt="Debloater"></td></tr>
|
||||
<tr><td align="center"><b>APK Audit</b><br><img src="screenshot/APK_Audit.png" alt="APK Audit"></td><td align="center"><b>App Inspector</b><br><img src="screenshot/App_Inspector.png" alt="App Inspector"></td></tr>
|
||||
<tr><td align="center"><b>Certificate Manager</b><br><img src="screenshot/Certificates.png" alt="Certificates"></td><td align="center"><b>Device Backup</b><br><img src="screenshot/Backup.png" alt="Backup"></td></tr>
|
||||
<tr><td align="center"><b>Prop Editor</b><br><img src="screenshot/Prop_Editor.png" alt="Prop Editor"></td><td align="center"><b>Shell Terminal</b><br><img src="screenshot/Shell.png" alt="Shell"></td></tr>
|
||||
<tr><td align="center"><b>Utilities</b><br><img src="screenshot/Utilities.png" alt="Utilities"></td><td align="center"><b>Flasher</b><br><img src="screenshot/Flasher.png" alt="Flasher"></td></tr>
|
||||
<tr><td align="center"><b>Screen Mirror prefs</b><br><img src="screenshot/ATK_screen_mirror_pref.png" alt="Screen Mirror prefs"></td><td align="center"><b>Settings: Appearance</b><br><img src="screenshot/Settings.png" alt="Settings"></td></tr>
|
||||
<tr><td align="center"><b>Settings: Features</b><br><img src="screenshot/Settings2.png" alt="Settings 2"></td><td align="center"><b>Settings: Advanced</b><br><img src="screenshot/Settings3.png" alt="Settings 3"></td></tr>
|
||||
<tr><td align="center"><b>Password lock</b><br><img src="screenshot/Login_Window_Password.png" alt="Login"></td><td align="center"><b>Logcat (map mode)</b><br><img src="screenshot/Logcat.png" alt="Logcat"></td></tr>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> The four demos above are hosted on GitHub's attachment CDN, so they play inline
|
||||
> here. The copies in `screenshot/*.mp4` are no longer needed for playback and you
|
||||
> can delete them to keep the repo small.
|
||||
|
||||
---
|
||||
|
||||
## 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 +319,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 +329,13 @@ 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`
|
||||
|
||||
> [!NOTE]
|
||||
> **About the Live System Map.** The map engine is the one closed-source part of
|
||||
> ATK, and its sources are not in this public repo. The **pre-built releases ship
|
||||
> the complete app**, map included, and that is the supported way to run ATK with
|
||||
> the map. Building from this repo gives you the full toolkit minus the map module.
|
||||
|
||||
**Package as .deb**
|
||||
```bash
|
||||
|
|
@ -173,44 +345,20 @@ 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.
|
||||
|
||||
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
|
||||
```
|
||||
<p align="center"><sub>github.com/jegly/ATK</sub></p>
|
||||
|
|
|
|||
45
RELEASE_NOTES_v1.1.0.md
Normal file
|
|
@ -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.
|
||||
79
android-helper/Main.java
Normal file
|
|
@ -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 <packageName> [userId]
|
||||
//
|
||||
// Prints "ATK_OK <pkg>" / "ATK_ERR <message>" 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: <packageName> [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<IntentSender> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
android-helper/atk-helper.dex
Normal file
23
android-helper/build.sh
Normal file
|
|
@ -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)"
|
||||
21
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 49 KiB |
18
assets/appicon.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="42%" r="65%">
|
||||
<stop offset="0%" stop-color="#1e1e2e"/><stop offset="100%" stop-color="#181825"/>
|
||||
</radialGradient>
|
||||
<clipPath id="gear"><polygon points="411.58,137.22 438.79,49.76 585.21,49.76 612.42,137.22 660.48,153.53 706.00,175.98 787.08,133.38 890.62,236.92 848.02,318.00 870.47,363.52 886.78,411.58 974.24,438.79 974.24,585.21 886.78,612.42 870.47,660.48 848.02,706.00 890.62,787.08 787.08,890.62 706.00,848.02 660.48,870.47 612.42,886.78 585.21,974.24 438.79,974.24 411.58,886.78 363.52,870.47 318.00,848.02 236.92,890.62 133.38,787.08 175.98,706.00 153.53,660.48 137.22,612.42 49.76,585.21 49.76,438.79 137.22,411.58 153.53,363.52 175.98,318.00 133.38,236.92 236.92,133.38 318.00,175.98 363.52,153.53"/></clipPath>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="1024" height="1024" rx="200" ry="200" fill="url(#bg)"/>
|
||||
<rect x="6" y="6" width="1012" height="1012" rx="196" ry="196" fill="none" stroke="#11111b" stroke-width="10"/>
|
||||
<!-- gear drop for depth -->
|
||||
<polygon points="411.58,137.22 438.79,49.76 585.21,49.76 612.42,137.22 660.48,153.53 706.00,175.98 787.08,133.38 890.62,236.92 848.02,318.00 870.47,363.52 886.78,411.58 974.24,438.79 974.24,585.21 886.78,612.42 870.47,660.48 848.02,706.00 890.62,787.08 787.08,890.62 706.00,848.02 660.48,870.47 612.42,886.78 585.21,974.24 438.79,974.24 411.58,886.78 363.52,870.47 318.00,848.02 236.92,890.62 133.38,787.08 175.98,706.00 153.53,660.48 137.22,612.42 49.76,585.21 49.76,438.79 137.22,411.58 153.53,363.52 175.98,318.00 133.38,236.92 236.92,133.38 318.00,175.98 363.52,153.53" fill="#11111b" transform="translate(0,10)" opacity="0.55"/>
|
||||
<!-- coloured gear -->
|
||||
<g clip-path="url(#gear)"><path d="M 512.0,512.0 L 302.29,5.71 A 548 548 0 0 1 721.71,5.71 Z" fill="#f38ba8"/><path d="M 512.0,512.0 L 721.71,5.71 A 548 548 0 0 1 1018.29,302.29 Z" fill="#fab387"/><path d="M 512.0,512.0 L 1018.29,302.29 A 548 548 0 0 1 1018.29,721.71 Z" fill="#f9e2af"/><path d="M 512.0,512.0 L 1018.29,721.71 A 548 548 0 0 1 721.71,1018.29 Z" fill="#a6e3a1"/><path d="M 512.0,512.0 L 721.71,1018.29 A 548 548 0 0 1 302.29,1018.29 Z" fill="#94e2d5"/><path d="M 512.0,512.0 L 302.29,1018.29 A 548 548 0 0 1 5.71,721.71 Z" fill="#89dceb"/><path d="M 512.0,512.0 L 5.71,721.71 A 548 548 0 0 1 5.71,302.29 Z" fill="#89b4fa"/><path d="M 512.0,512.0 L 5.71,302.29 A 548 548 0 0 1 302.29,5.71 Z" fill="#cba6f7"/></g>
|
||||
<polygon points="411.58,137.22 438.79,49.76 585.21,49.76 612.42,137.22 660.48,153.53 706.00,175.98 787.08,133.38 890.62,236.92 848.02,318.00 870.47,363.52 886.78,411.58 974.24,438.79 974.24,585.21 886.78,612.42 870.47,660.48 848.02,706.00 890.62,787.08 787.08,890.62 706.00,848.02 660.48,870.47 612.42,886.78 585.21,974.24 438.79,974.24 411.58,886.78 363.52,870.47 318.00,848.02 236.92,890.62 133.38,787.08 175.98,706.00 153.53,660.48 137.22,612.42 49.76,585.21 49.76,438.79 137.22,411.58 153.53,363.52 175.98,318.00 133.38,236.92 236.92,133.38 318.00,175.98 363.52,153.53" fill="none" stroke="#11111b" stroke-width="10" stroke-linejoin="round"/>
|
||||
<!-- centre bore -->
|
||||
<circle cx="512.0" cy="512.0" r="162" fill="url(#bg)" stroke="#11111b" stroke-width="10"/>
|
||||
<circle cx="512.0" cy="512.0" r="140" fill="none" stroke="#b4befe" stroke-width="8" opacity="0.85"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
951
backend_apkaudit.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
350
backend_apkaudit_export.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
215
backend_apkaudit_purego.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
430
backend_apkaudit_rules.go
Normal file
|
|
@ -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
|
||||
}
|
||||
177
backend_apkaudit_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
216
backend_applock.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
137
backend_bootinfo.go
Normal file
|
|
@ -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
|
||||
}
|
||||
100
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) {
|
||||
|
|
|
|||
57
backend_filehttp.go
Normal file
|
|
@ -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=<path>
|
||||
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)
|
||||
})
|
||||
}
|
||||
185
backend_firmware.go
Normal file
|
|
@ -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
|
||||
}
|
||||
188
backend_flasher.go
Normal file
|
|
@ -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 <img>.
|
||||
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 <action>` 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
|
||||
}
|
||||
302
backend_gsi.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package main
|
||||
|
||||
// GSI Loader backend — two ways to run a Generic System Image on the device:
|
||||
//
|
||||
// 1. DSU (temporary): install a GSI as a guest OS via Dynamic System Updates.
|
||||
// Non-destructive, no unlock, no wipe. Follows the exact adb flow from the
|
||||
// Android DSU docs (see dsi_info.txt): gzip the raw image, push it to
|
||||
// /storage/emulated/0/Download, then fire the START_INSTALL intent at
|
||||
// com.android.dynsystem. Managed afterwards with gsi_tool.
|
||||
//
|
||||
// 2. GSI flash (permanent): fastboot-flash a GSI to the system partition.
|
||||
// Destructive; DANGER-gated (App Lock). Sequences the documented fastboot
|
||||
// steps with a dry-run preview.
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dsuDefaultUserdata int64 = 8589934592 // 8 GiB
|
||||
const dsuRemoteDir = "/storage/emulated/0/Download"
|
||||
|
||||
// GsiCompat reports whether the device can run a GSI and which one.
|
||||
type GsiCompat struct {
|
||||
TrebleEnabled bool `json:"trebleEnabled"`
|
||||
Abi string `json:"abi"` // ro.product.cpu.abi, e.g. arm64-v8a
|
||||
GsiArch string `json:"gsiArch"` // derived: arm64 / x86_64 / arm / x86
|
||||
VndkIsolated bool `json:"vndkIsolated"` // true => any newer GSI ok; false => same-version only
|
||||
AndroidRelease string `json:"androidRelease"`
|
||||
Sdk string `json:"sdk"`
|
||||
DsuStatus string `json:"dsuStatus"`
|
||||
}
|
||||
|
||||
// GsiCompat runs the documented compatibility checks (getprop + ld.config).
|
||||
func (a *App) GsiCompat() (GsiCompat, error) {
|
||||
c := GsiCompat{}
|
||||
if v, err := a.runAdbShell("getprop", "ro.treble.enabled"); err == nil {
|
||||
c.TrebleEnabled = strings.TrimSpace(v) == "true"
|
||||
}
|
||||
if v, err := a.runAdbShell("getprop", "ro.product.cpu.abi"); err == nil {
|
||||
c.Abi = strings.TrimSpace(v)
|
||||
c.GsiArch = gsiArchForAbi(c.Abi)
|
||||
}
|
||||
if v, err := a.runAdbShell("getprop", "ro.build.version.release"); err == nil {
|
||||
c.AndroidRelease = strings.TrimSpace(v)
|
||||
}
|
||||
if v, err := a.runAdbShell("getprop", "ro.build.version.sdk"); err == nil {
|
||||
c.Sdk = strings.TrimSpace(v)
|
||||
}
|
||||
if v, err := a.runAdbShell("cat", "/system/etc/ld.config.version_identifier.txt"); err == nil {
|
||||
c.VndkIsolated = vendorNamespaceIsolated(v)
|
||||
}
|
||||
if v, err := a.runAdbShell("gsi_tool", "status"); err == nil {
|
||||
c.DsuStatus = strings.TrimSpace(v)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func gsiArchForAbi(abi string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(abi, "arm64"):
|
||||
return "arm64"
|
||||
case strings.HasPrefix(abi, "x86_64"):
|
||||
return "x86_64"
|
||||
case strings.HasPrefix(abi, "x86"):
|
||||
return "x86"
|
||||
case strings.HasPrefix(abi, "arm"):
|
||||
return "arm"
|
||||
}
|
||||
return abi
|
||||
}
|
||||
|
||||
// vendorNamespaceIsolated parses ld.config for the [vendor] section and reports
|
||||
// whether namespace.default.isolated is true (full VNDK => any newer GSI works).
|
||||
func vendorNamespaceIsolated(ld string) bool {
|
||||
inVendor := false
|
||||
for _, line := range strings.Split(ld, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
|
||||
inVendor = t == "[vendor]"
|
||||
continue
|
||||
}
|
||||
if inVendor && strings.Contains(t, "namespace.default.isolated") {
|
||||
return strings.Contains(strings.ToLower(t), "true")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- gsi_tool management ---------------------------------------------------
|
||||
|
||||
func (a *App) GsiDsuStatus() (string, error) { return a.runAdbShell("gsi_tool", "status") }
|
||||
func (a *App) DsuEnable() (string, error) { return a.runAdbShell("gsi_tool", "enable") }
|
||||
func (a *App) DsuDisable() (string, error) { return a.runAdbShell("gsi_tool", "disable") }
|
||||
func (a *App) DsuWipe() (string, error) { return a.runAdbShell("gsi_tool", "wipe") }
|
||||
|
||||
// --- DSU install -----------------------------------------------------------
|
||||
|
||||
// InstallDsu prepares and installs a temporary DSU from a GSI image, then fires
|
||||
// the DynamicSystemInstallationService intent. `systemSize` is the UNCOMPRESSED
|
||||
// raw image size in bytes (auto = file size for a raw .img; REQUIRED for a .gz).
|
||||
// `userdataSize` defaults to 8 GiB when <= 0.
|
||||
func (a *App) InstallDsu(imagePath string, systemSize int64, userdataSize int64) (string, error) {
|
||||
imagePath = strings.TrimSpace(imagePath)
|
||||
if imagePath == "" {
|
||||
return "", fmt.Errorf("no GSI image selected")
|
||||
}
|
||||
info, err := os.Stat(imagePath)
|
||||
if err != nil || info.IsDir() {
|
||||
return "", fmt.Errorf("image not found: %s", imagePath)
|
||||
}
|
||||
if isSparseImage(imagePath) {
|
||||
return "", fmt.Errorf("this looks like a SPARSE image — DSU needs an unsparsed raw image. Convert first:\n simg2img system.img system_raw.img\nthen select the raw .img (or a .gz you made from it).")
|
||||
}
|
||||
|
||||
if userdataSize <= 0 {
|
||||
userdataSize = dsuDefaultUserdata
|
||||
}
|
||||
|
||||
var gzPath string
|
||||
var sysSize int64
|
||||
isGz := strings.HasSuffix(strings.ToLower(imagePath), ".gz")
|
||||
if isGz {
|
||||
gzPath = imagePath
|
||||
if systemSize <= 0 {
|
||||
return "", fmt.Errorf("for a .gz image, provide the uncompressed system image size (bytes) — DSU needs KEY_SYSTEM_SIZE")
|
||||
}
|
||||
sysSize = systemSize
|
||||
} else {
|
||||
// Raw image: size is exact; gzip it host-side as the docs require.
|
||||
sysSize = info.Size()
|
||||
if systemSize > 0 {
|
||||
sysSize = systemSize
|
||||
}
|
||||
gzPath = filepath.Join(os.TempDir(), "atk-dsu.gz")
|
||||
if err := gzipFile(imagePath, gzPath); err != nil {
|
||||
return "", fmt.Errorf("failed to gzip image: %w", err)
|
||||
}
|
||||
defer os.Remove(gzPath)
|
||||
}
|
||||
|
||||
// Push the gzipped image to the device (progress via transfer:* events).
|
||||
if _, err := a.PushWithProgress(gzPath, dsuRemoteDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
remote := dsuRemoteDir + "/" + filepath.Base(gzPath)
|
||||
|
||||
// Fire the DSU install intent — verbatim from the Android docs.
|
||||
out, err := a.runAdbShell(
|
||||
"am", "start-activity",
|
||||
"-n", "com.android.dynsystem/com.android.dynsystem.VerificationActivity",
|
||||
"-a", "android.os.image.action.START_INSTALL",
|
||||
"-d", "file://"+remote,
|
||||
"--el", "KEY_SYSTEM_SIZE", strconv.FormatInt(sysSize, 10),
|
||||
"--el", "KEY_USERDATA_SIZE", strconv.FormatInt(userdataSize, 10),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to launch DSU install: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return fmt.Sprintf("DSU install started (system %s, userdata %s). On the device, tap Restart in the notification to boot the GSI, or Discard to cancel. Use 'gsi_tool enable' for sticky mode.",
|
||||
humanBytes(sysSize), humanBytes(userdataSize)), nil
|
||||
}
|
||||
|
||||
// isSparseImage checks the Android sparse-image magic (0xed26ff3a, little-endian).
|
||||
func isSparseImage(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
var b [4]byte
|
||||
if _, err := io.ReadFull(f, b[:]); err != nil {
|
||||
return false
|
||||
}
|
||||
return b[0] == 0x3a && b[1] == 0xff && b[2] == 0x26 && b[3] == 0xed
|
||||
}
|
||||
|
||||
func gzipFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
zw := gzip.NewWriter(out)
|
||||
if _, err := io.Copy(zw, in); err != nil {
|
||||
zw.Close()
|
||||
return err
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const u = 1024
|
||||
if n < u {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(u), 0
|
||||
for x := n / u; x >= u; x /= u {
|
||||
div *= u
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// --- GSI permanent flash (fastboot) ----------------------------------------
|
||||
|
||||
type GsiFlashOpts struct {
|
||||
Fastbootd bool `json:"fastbootd"` // reboot fastboot (fastbootd) first
|
||||
WipeData bool `json:"wipeData"` // fastboot -w
|
||||
DisableVerity bool `json:"disableVerity"` // flash vbmeta --disable-verification
|
||||
DeleteProduct bool `json:"deleteProduct"` // free space: delete product_<slot>
|
||||
Slot string `json:"slot"` // "a" / "b" / "" (for delete-logical-partition)
|
||||
VbmetaPath string `json:"vbmetaPath"` // required if DisableVerity
|
||||
DryRun bool `json:"dryRun"`
|
||||
}
|
||||
|
||||
type gsiStep struct {
|
||||
desc string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (a *App) gsiFlashSteps(imagePath string, opts GsiFlashOpts) []gsiStep {
|
||||
var steps []gsiStep
|
||||
if opts.Fastbootd {
|
||||
steps = append(steps, gsiStep{"Reboot to fastbootd", []string{"reboot", "fastboot"}})
|
||||
}
|
||||
if opts.DeleteProduct {
|
||||
part := "product"
|
||||
if opts.Slot != "" {
|
||||
part += "_" + opts.Slot
|
||||
}
|
||||
steps = append(steps, gsiStep{"Free space: delete " + part, []string{"delete-logical-partition", part}})
|
||||
}
|
||||
steps = append(steps,
|
||||
gsiStep{"Erase system", []string{"erase", "system"}},
|
||||
gsiStep{"Flash system", []string{"flash", "system", imagePath}},
|
||||
)
|
||||
if opts.WipeData {
|
||||
steps = append(steps, gsiStep{"Wipe userdata", []string{"-w"}})
|
||||
}
|
||||
if opts.DisableVerity && strings.TrimSpace(opts.VbmetaPath) != "" {
|
||||
steps = append(steps, gsiStep{"Flash vbmeta (disable verification)", []string{"--disable-verification", "flash", "vbmeta", opts.VbmetaPath}})
|
||||
}
|
||||
steps = append(steps, gsiStep{"Reboot", []string{"reboot"}})
|
||||
return steps
|
||||
}
|
||||
|
||||
// FlashGsiSystem flashes a GSI to the system partition via fastboot. With
|
||||
// DryRun, it returns the command list without executing. Otherwise it runs each
|
||||
// step, gated behind the App Lock danger check.
|
||||
func (a *App) FlashGsiSystem(imagePath string, opts GsiFlashOpts) (string, error) {
|
||||
imagePath = strings.TrimSpace(imagePath)
|
||||
if imagePath == "" {
|
||||
return "", fmt.Errorf("no GSI system image selected")
|
||||
}
|
||||
if !opts.DryRun {
|
||||
if info, err := os.Stat(imagePath); err != nil || info.IsDir() {
|
||||
return "", fmt.Errorf("image not found: %s", imagePath)
|
||||
}
|
||||
}
|
||||
steps := a.gsiFlashSteps(imagePath, opts)
|
||||
|
||||
if opts.DryRun {
|
||||
var b strings.Builder
|
||||
for _, s := range steps {
|
||||
b.WriteString("fastboot " + strings.Join(s.args, " ") + "\n")
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
if err := a.requireDangerUnlocked(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for _, s := range steps {
|
||||
out.WriteString("$ fastboot " + strings.Join(s.args, " ") + "\n")
|
||||
res, err := a.runCommandTimeout(10*time.Minute, "fastboot", s.args...)
|
||||
if strings.TrimSpace(res) != "" {
|
||||
out.WriteString(res + "\n")
|
||||
}
|
||||
if err != nil {
|
||||
return out.String(), fmt.Errorf("%s failed: %w", s.desc, err)
|
||||
}
|
||||
// fastbootd takes a few seconds to come up before it accepts commands.
|
||||
if len(s.args) == 2 && s.args[0] == "reboot" && s.args[1] == "fastboot" {
|
||||
time.Sleep(8 * time.Second)
|
||||
}
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
123
backend_intent.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package main
|
||||
|
||||
// Intent Lab — list an app's launchable (exported) activities and start them via
|
||||
// `am start`, plus a free-form implicit-intent launcher. Lets a user reach hidden
|
||||
// settings menus / internal screens that aren't on the launcher.
|
||||
//
|
||||
// Activity discovery uses `dumpsys package <pkg>`: components that appear in the
|
||||
// Activity Resolver Table have an intent filter, so they're launchable by the
|
||||
// shell user. Non-filtered/exported=false activities generally can't be started
|
||||
// from adb; the launcher surfaces the real `am start` result either way.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type IntentActivity struct {
|
||||
Name string `json:"name"` // activity class, relative to the package
|
||||
Component string `json:"component"` // full "package/activity" target for am start
|
||||
Exported bool `json:"exported"` // appears in the resolver table (has an intent filter)
|
||||
}
|
||||
|
||||
// componentRe guards the `am start -n <component>` target against shell injection
|
||||
// on the device side (adb shell reparses the command).
|
||||
var componentRe = regexp.MustCompile(`^[A-Za-z0-9_.]+/[A-Za-z0-9_.$]+$`)
|
||||
var actionRe = regexp.MustCompile(`^[A-Za-z0-9_.]+$`)
|
||||
|
||||
// ListActivities returns the launchable activities of an installed package.
|
||||
func (a *App) ListActivities(packageName string) ([]IntentActivity, error) {
|
||||
if err := validatePackageName(packageName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query package: %w", err)
|
||||
}
|
||||
|
||||
prefix := packageName + "/"
|
||||
seen := map[string]bool{}
|
||||
var acts []IntentActivity
|
||||
inActivities := false
|
||||
|
||||
for _, line := range strings.Split(dump, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.Contains(t, "Activity Resolver Table"):
|
||||
inActivities = true
|
||||
continue
|
||||
case strings.Contains(t, "Receiver Resolver Table"),
|
||||
strings.Contains(t, "Service Resolver Table"),
|
||||
strings.Contains(t, "Provider Resolver Table"),
|
||||
strings.Contains(t, "Preferred Activities"),
|
||||
strings.Contains(t, "Key Set Manager"):
|
||||
inActivities = false
|
||||
}
|
||||
if !inActivities {
|
||||
continue
|
||||
}
|
||||
for _, tok := range strings.Fields(t) {
|
||||
if strings.HasPrefix(tok, prefix) && len(tok) > len(prefix) && !seen[tok] {
|
||||
seen[tok] = true
|
||||
acts = append(acts, IntentActivity{
|
||||
Name: strings.TrimPrefix(tok, prefix),
|
||||
Component: tok,
|
||||
Exported: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(acts, func(i, j int) bool { return acts[i].Name < acts[j].Name })
|
||||
return acts, nil
|
||||
}
|
||||
|
||||
// StartActivity launches an explicit component ("package/activity").
|
||||
func (a *App) StartActivity(component string) (string, error) {
|
||||
component = strings.TrimSpace(component)
|
||||
if !componentRe.MatchString(component) {
|
||||
return "", fmt.Errorf("invalid component, expected package/activity: %s", component)
|
||||
}
|
||||
out, err := a.runAdbShell("am", "start", "-n", component)
|
||||
return interpretAmResult(out, err)
|
||||
}
|
||||
|
||||
// StartIntentAction launches an implicit intent by action, with an optional data URI.
|
||||
func (a *App) StartIntentAction(action, data string) (string, error) {
|
||||
action = strings.TrimSpace(action)
|
||||
if action == "" || !actionRe.MatchString(action) {
|
||||
return "", fmt.Errorf("invalid or empty action")
|
||||
}
|
||||
args := []string{"am", "start", "-a", action}
|
||||
if data = strings.TrimSpace(data); data != "" {
|
||||
// Reject shell metacharacters — adb shell reparses this on the device.
|
||||
if strings.ContainsAny(data, " \t\n\r;&|`$<>()\"'\\") {
|
||||
return "", fmt.Errorf("data URI contains disallowed characters")
|
||||
}
|
||||
args = append(args, "-d", data)
|
||||
}
|
||||
out, err := a.runAdbShell(args...)
|
||||
return interpretAmResult(out, err)
|
||||
}
|
||||
|
||||
// interpretAmResult normalises `am start` output — it often prints errors to
|
||||
// stdout with a zero exit, so inspect the text as well as err.
|
||||
func interpretAmResult(out string, err error) (string, error) {
|
||||
out = strings.TrimSpace(out)
|
||||
if err != nil {
|
||||
if out != "" {
|
||||
return "", fmt.Errorf("%s", firstLine(out))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(out, "Error:") || strings.Contains(out, "Exception") ||
|
||||
strings.Contains(out, "does not exist") || strings.Contains(out, "Permission Denial") {
|
||||
return "", fmt.Errorf("%s", firstLine(out))
|
||||
}
|
||||
if out == "" {
|
||||
out = "Started."
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
345
backend_magisk.go
Normal file
|
|
@ -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
|
||||
}
|
||||
98
backend_overview.go
Normal file
|
|
@ -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
|
||||
}
|
||||
362
backend_payload.go
Normal file
|
|
@ -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
|
||||
}
|
||||
263
backend_privacy.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
package main
|
||||
|
||||
// Privacy & Tracker Scanner.
|
||||
//
|
||||
// Pulls an installed app's base APK, scans its DEX bytecode for known
|
||||
// third-party tracker/analytics/ad SDK signatures, cross-references declared
|
||||
// dangerous permissions, and derives a 0-100 Privacy Score (A-F grade). This
|
||||
// reuses the APK auditor's DEX tracker matcher (matchTrackers /
|
||||
// trackerSignatures) and the shared dangerousPermissions set, and additionally
|
||||
// enriches the shared tracker DB below (which also improves the full auditor).
|
||||
//
|
||||
// Data source: a bundled static signature list (Exodus-Privacy-style code
|
||||
// signatures = Java package prefixes as they appear in classes*.dex). No runtime
|
||||
// network — works fully offline on any device.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// extraTrackerSignatures widens the built-in trackerSignatures set with more
|
||||
// well-known Exodus-catalogued SDKs. Merged into the shared maps at init so both
|
||||
// the Privacy Scanner and the APK auditor see them. Signatures are conservative
|
||||
// package prefixes chosen to avoid false positives.
|
||||
var extraTrackerSignatures = map[string][]string{
|
||||
"Google Tag Manager": {"com/google/android/gms/tagmanager"},
|
||||
"Amazon Mobile Ads": {"com/amazon/device/ads", "com/amazon/aps"},
|
||||
"AdColony": {"com/adcolony"},
|
||||
"Startapp": {"com/startapp"},
|
||||
"Mintegral": {"com/mbridge", "com/mintegral"},
|
||||
"Pangle (ByteDance)": {"com/bytedance/sdk/openadsdk", "com/bytedance/pangle"},
|
||||
"ByteDance AppLog": {"com/bytedance/applog"},
|
||||
"PubMatic": {"com/pubmatic"},
|
||||
"Criteo": {"com/criteo"},
|
||||
"Smaato": {"com/smaato"},
|
||||
"Fyber": {"com/fyber"},
|
||||
"Taboola": {"com/taboola"},
|
||||
"Outbrain": {"com/outbrain"},
|
||||
"CleverTap": {"com/clevertap"},
|
||||
"MoEngage": {"com/moengage"},
|
||||
"Airship": {"com/urbanairship"},
|
||||
"Leanplum": {"com/leanplum"},
|
||||
"Batch": {"com/batch/android"},
|
||||
"Iterable": {"com/iterable"},
|
||||
"Pushwoosh": {"com/pushwoosh"},
|
||||
"Swrve": {"com/swrve"},
|
||||
"Optimizely": {"com/optimizely"},
|
||||
"Adobe Experience": {"com/adobe/marketing/mobile", "com/adobe/mobile"},
|
||||
"New Relic": {"com/newrelic"},
|
||||
"Datadog": {"com/datadog/android"},
|
||||
"Instabug": {"com/instabug"},
|
||||
"Embrace": {"io/embrace/android"},
|
||||
"Countly": {"ly/count/android"},
|
||||
"Matomo": {"org/matomo", "org/piwik"},
|
||||
"Snowplow": {"com/snowplowanalytics"},
|
||||
"Smartlook": {"com/smartlook"},
|
||||
"Nielsen": {"com/nielsen/app"},
|
||||
"Mapbox Telemetry": {"com/mapbox/android/telemetry"},
|
||||
"Foursquare": {"com/foursquare"},
|
||||
"Gimbal": {"com/gimbal"},
|
||||
"Radar": {"io/radar/sdk"},
|
||||
}
|
||||
|
||||
var extraTrackerCategory = map[string]string{
|
||||
"Google Tag Manager": "Analytics", "Amazon Mobile Ads": "Advertising", "AdColony": "Advertising",
|
||||
"Startapp": "Advertising", "Mintegral": "Advertising", "Pangle (ByteDance)": "Advertising",
|
||||
"ByteDance AppLog": "Analytics", "PubMatic": "Advertising", "Criteo": "Advertising",
|
||||
"Smaato": "Advertising", "Fyber": "Advertising", "Taboola": "Advertising", "Outbrain": "Advertising",
|
||||
"CleverTap": "Analytics", "MoEngage": "Marketing", "Airship": "Marketing", "Leanplum": "Marketing",
|
||||
"Batch": "Marketing", "Iterable": "Marketing", "Pushwoosh": "Push/Analytics", "Swrve": "Marketing",
|
||||
"Optimizely": "Analytics", "Adobe Experience": "Analytics", "New Relic": "Analytics",
|
||||
"Datadog": "Analytics", "Instabug": "Crash reporting", "Embrace": "Crash reporting",
|
||||
"Countly": "Analytics", "Matomo": "Analytics", "Snowplow": "Analytics", "Smartlook": "Analytics",
|
||||
"Nielsen": "Analytics", "Mapbox Telemetry": "Location", "Foursquare": "Location",
|
||||
"Gimbal": "Location", "Radar": "Location",
|
||||
}
|
||||
|
||||
func init() {
|
||||
for name, sigs := range extraTrackerSignatures {
|
||||
if _, exists := trackerSignatures[name]; !exists {
|
||||
trackerSignatures[name] = sigs
|
||||
}
|
||||
}
|
||||
for name, cat := range extraTrackerCategory {
|
||||
if _, exists := trackerCategory[name]; !exists {
|
||||
trackerCategory[name] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// privacyCategoryWeight is the score penalty per unique tracker of a category.
|
||||
// Advertising / attribution / location are weighted heaviest (most invasive);
|
||||
// crash reporting is light (usually operational, not surveillance).
|
||||
var privacyCategoryWeight = map[string]int{
|
||||
"Advertising": 12,
|
||||
"Location": 12,
|
||||
"Attribution": 8,
|
||||
"Marketing": 8,
|
||||
"Analytics": 7,
|
||||
"Push/Analytics": 6,
|
||||
"Crash reporting": 3,
|
||||
}
|
||||
|
||||
const defaultTrackerWeight = 6
|
||||
|
||||
type PrivacyTracker struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Matches int `json:"matches"`
|
||||
}
|
||||
|
||||
type PrivacyReport struct {
|
||||
PackageName string `json:"packageName"`
|
||||
Score int `json:"score"` // 0-100, higher = more private
|
||||
Grade string `json:"grade"` // A-F
|
||||
TrackerCount int `json:"trackerCount"`
|
||||
Trackers []PrivacyTracker `json:"trackers"`
|
||||
DangerousPermissions []string `json:"dangerousPermissions"`
|
||||
ApkSize int64 `json:"apkSize"`
|
||||
}
|
||||
|
||||
// ScanAppPrivacy pulls the base APK of an installed package, scans it for
|
||||
// tracker SDKs, collects its declared dangerous permissions, and scores it.
|
||||
func (a *App) ScanAppPrivacy(packageName string) (PrivacyReport, error) {
|
||||
if err := validatePackageName(packageName); err != nil {
|
||||
return PrivacyReport{}, err
|
||||
}
|
||||
report := PrivacyReport{PackageName: packageName}
|
||||
|
||||
// Locate the base APK on the device.
|
||||
out, err := a.runAdbShell("pm", "path", packageName)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("could not locate package on device: %w", err)
|
||||
}
|
||||
var remote string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
p := strings.TrimPrefix(strings.TrimSpace(line), "package:")
|
||||
if strings.HasSuffix(p, "base.apk") {
|
||||
remote = p
|
||||
break
|
||||
}
|
||||
if remote == "" && strings.HasSuffix(p, ".apk") {
|
||||
remote = p
|
||||
}
|
||||
}
|
||||
if remote == "" {
|
||||
return report, fmt.Errorf("no APK path found for %s", packageName)
|
||||
}
|
||||
|
||||
// Pull to a temp file; unlike the auditor we don't need to keep it around.
|
||||
tmp := filepath.Join(os.TempDir(), "atk-privacy-"+sanitizeFileToken(packageName)+".apk")
|
||||
if _, err := a.runCommandTimeout(auditCommandTimeout, "adb", "pull", remote, tmp); err != nil {
|
||||
return report, fmt.Errorf("failed to pull APK: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
if info, statErr := os.Stat(tmp); statErr == nil {
|
||||
report.ApkSize = info.Size()
|
||||
}
|
||||
|
||||
// Scan DEX bytecode for tracker signatures.
|
||||
trackerHits := map[string]int{}
|
||||
if zr, zerr := zip.OpenReader(tmp); zerr == nil {
|
||||
for _, f := range zr.File {
|
||||
if !strings.HasPrefix(f.Name, "classes") || !strings.HasSuffix(f.Name, ".dex") {
|
||||
continue
|
||||
}
|
||||
if f.UncompressedSize64 > maxDexBytes {
|
||||
continue
|
||||
}
|
||||
if data := readZipEntry(f); data != nil {
|
||||
matchTrackers(data, trackerHits)
|
||||
}
|
||||
}
|
||||
zr.Close()
|
||||
} else {
|
||||
return report, fmt.Errorf("could not open pulled APK: %w", zerr)
|
||||
}
|
||||
for name, n := range trackerHits {
|
||||
report.Trackers = append(report.Trackers, PrivacyTracker{
|
||||
Name: name, Category: trackerCategory[name], Matches: n,
|
||||
})
|
||||
}
|
||||
// Heaviest categories first, then alphabetical.
|
||||
sort.Slice(report.Trackers, func(i, j int) bool {
|
||||
wi, wj := privacyCategoryWeight[report.Trackers[i].Category], privacyCategoryWeight[report.Trackers[j].Category]
|
||||
if wi != wj {
|
||||
return wi > wj
|
||||
}
|
||||
return report.Trackers[i].Name < report.Trackers[j].Name
|
||||
})
|
||||
report.TrackerCount = len(report.Trackers)
|
||||
|
||||
// Declared dangerous permissions (a permission named anywhere in the package
|
||||
// dump is declared/involved for this package).
|
||||
report.DangerousPermissions = a.declaredDangerousPermissions(packageName)
|
||||
|
||||
report.Score, report.Grade = computePrivacyScore(report.Trackers, report.DangerousPermissions)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// declaredDangerousPermissions returns the dangerous permissions the package
|
||||
// declares, read from its dumpsys output.
|
||||
func (a *App) declaredDangerousPermissions(packageName string) []string {
|
||||
dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
|
||||
if err != nil || dump == "" {
|
||||
return nil
|
||||
}
|
||||
var found []string
|
||||
for perm := range dangerousPermissions {
|
||||
if strings.Contains(dump, perm) {
|
||||
found = append(found, perm)
|
||||
}
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found
|
||||
}
|
||||
|
||||
// computePrivacyScore derives a 0-100 score (higher = more private) and an A-F
|
||||
// grade from the detected trackers and declared dangerous permissions. Trackers
|
||||
// dominate; permissions are a secondary, capped penalty.
|
||||
func computePrivacyScore(trackers []PrivacyTracker, dangerousPerms []string) (int, string) {
|
||||
score := 100
|
||||
for _, t := range trackers {
|
||||
w, ok := privacyCategoryWeight[t.Category]
|
||||
if !ok {
|
||||
w = defaultTrackerWeight
|
||||
}
|
||||
score -= w
|
||||
}
|
||||
// Permissions: -2 each, capped at -24 so a permission-heavy but tracker-free
|
||||
// app (e.g. a camera app) isn't punished as hard as a tracker-laden one.
|
||||
permPenalty := len(dangerousPerms) * 2
|
||||
if permPenalty > 24 {
|
||||
permPenalty = 24
|
||||
}
|
||||
score -= permPenalty
|
||||
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
var grade string
|
||||
switch {
|
||||
case score >= 85:
|
||||
grade = "A"
|
||||
case score >= 70:
|
||||
grade = "B"
|
||||
case score >= 55:
|
||||
grade = "C"
|
||||
case score >= 40:
|
||||
grade = "D"
|
||||
default:
|
||||
grade = "F"
|
||||
}
|
||||
return score, grade
|
||||
}
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
226
backend_scrcpy.go
Normal file
|
|
@ -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
|
||||
}
|
||||
211
backend_transfer.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 49 KiB |
|
|
@ -1,4 +1,5 @@
|
|||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=ATK
|
||||
GenericName=Android Toolkit
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
102
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 {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ATK — Android Toolkit</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
d772c5ee4d5ec9453e4b361871c1c91f
|
||||
bfb47127747332de1e5119ef153cca76
|
||||
1047
frontend/pnpm-lock.yaml
generated
|
|
@ -1,48 +1,71 @@
|
|||
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 ViewIntentLab from './components/views/ViewIntentLab'
|
||||
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 ViewGsiLoader from './components/views/ViewGsiLoader'
|
||||
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<View>('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 <ViewDashboard />
|
||||
case 'files': return <ViewFiles />
|
||||
case 'mirror': return <ViewScreenMirror />
|
||||
case 'packages': return <ViewPackages />
|
||||
case 'debloater': return <ViewDebloater />
|
||||
case 'shell': return <ViewShell />
|
||||
case 'logcat': return <ViewLogcat />
|
||||
case 'appinspect': return <ViewAppInspect />
|
||||
case 'intentlab': return <ViewIntentLab />
|
||||
case 'apkaudit': return <ViewApkAudit />
|
||||
case 'certs': return <ViewCerts />
|
||||
case 'backup': return <ViewBackup />
|
||||
case 'props': return <ViewProps />
|
||||
case 'flasher': return <ViewFlasher />
|
||||
case 'pixelflasher': return <ViewPixelFlasher />
|
||||
case 'gsiloader': return <ViewGsiLoader />
|
||||
case 'utilities': return <ViewUtilities />
|
||||
case 'settings': return <ViewSettings />
|
||||
default: return <ViewDashboard />
|
||||
|
|
@ -58,25 +81,36 @@ export default function App() {
|
|||
</div>
|
||||
)
|
||||
|
||||
if (locked) return <LockGate onUnlock={() => setLocked(false)} />
|
||||
|
||||
const sidebar = <Sidebar activeView={view} onViewChange={setView} position={sidebarPos} showLabels={sidebarLabels} />
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-bg-base overflow-hidden">
|
||||
<Sidebar activeView={view} onViewChange={setView} />
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{initError && (
|
||||
<div className="bg-danger/10 border-b border-danger/20 px-4 py-2 text-danger text-sm flex items-center gap-2">
|
||||
<span className="font-mono">⚠</span>
|
||||
<span>{initError}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">{renderView()}</div>
|
||||
</main>
|
||||
<div className="flex flex-col h-full bg-bg-base overflow-hidden rounded-[10px]">
|
||||
<DangerGate />
|
||||
<TitleBar />
|
||||
<div className={`flex-1 flex overflow-hidden ${sidebarPos === 'left' ? 'flex-row' : 'flex-col'}`}>
|
||||
{sidebarPos !== 'bottom' && sidebar}
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{initError && (
|
||||
<DismissibleBanner
|
||||
id={`init-error:${initError}`}
|
||||
className="bg-danger/10 border-b border-danger/20 px-4 py-2 text-danger text-sm"
|
||||
>
|
||||
<span className="font-mono">⚠</span>
|
||||
<span>{initError}</span>
|
||||
</DismissibleBanner>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">{renderView()}</div>
|
||||
</main>
|
||||
{sidebarPos === 'bottom' && sidebar}
|
||||
</div>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: '#18181f', border: '1px solid #252530',
|
||||
color: '#e8e8f0', fontFamily: "'IBM Plex Sans', sans-serif", fontSize: '13px',
|
||||
background: 'rgb(var(--bg-raised))', border: '1px solid rgb(var(--bg-border))',
|
||||
color: 'rgb(var(--text-primary))', fontFamily: "'IBM Plex Sans', sans-serif", fontSize: '13px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
76
frontend/src/components/DangerGate.tsx
Normal file
|
|
@ -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<DangerRequest | null>(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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={e => { if (e.target === e.currentTarget) close(false) }}
|
||||
>
|
||||
<form onSubmit={submit} className="card p-5 w-80 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert size={18} className="text-warn shrink-0" />
|
||||
<p className="text-sm font-medium text-text-primary">Confirm with password</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
This is a destructive action. Re-enter your app password to continue. You won't be
|
||||
asked again for a few minutes.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
className="input text-sm w-full"
|
||||
placeholder="App password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => close(false)} className="btn-ghost text-xs">Cancel</button>
|
||||
<button type="submit" disabled={!password || busy} className="btn-primary text-xs">
|
||||
{busy ? 'Verifying…' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/components/DismissibleBanner.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={`flex items-start gap-2 ${className}`}>
|
||||
<div className="flex-1 flex items-start gap-2 min-w-0">{children}</div>
|
||||
<button
|
||||
onClick={() => { dismiss(id); setHidden(true) }}
|
||||
title="Hide this message"
|
||||
className="shrink-0 -my-0.5 -mr-1 p-1 rounded opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/LockGate.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex h-full items-center justify-center bg-bg-base rounded-[10px]">
|
||||
<form onSubmit={submit} className="card p-6 w-80 space-y-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-bg-raised flex items-center justify-center">
|
||||
<Lock size={22} className="text-accent-green" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary">ATK is locked</p>
|
||||
<p className="text-xs text-text-muted">Enter your app password to continue</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
className="input text-sm w-full text-center"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
<button type="submit" disabled={!password || busy} className="btn-primary text-sm w-full justify-center">
|
||||
{busy ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,77 +1,169 @@
|
|||
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, Rocket, HardDriveDownload
|
||||
} 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: <LayoutDashboard size={17} />, label: 'Dashboard' },
|
||||
{ view: 'files', icon: <FolderOpen size={17} />, label: 'Files' },
|
||||
{ view: 'mirror', icon: <MonitorSmartphone size={17} />, label: 'Screen Mirror' },
|
||||
{ view: 'packages', icon: <Package size={17} />, label: 'Packages' },
|
||||
{ view: 'debloater', icon: <Shield size={17} />, label: 'Debloater' },
|
||||
{ view: 'shell', icon: <Terminal size={17} />, label: 'Shell' },
|
||||
{ view: 'logcat', icon: <ScrollText size={17} />, label: 'Logcat', dividerBefore: true },
|
||||
{ view: 'appinspect', icon: <Search size={17} />, label: 'App Inspector' },
|
||||
{ view: 'intentlab', icon: <Rocket size={17} />, label: 'Intent Lab' },
|
||||
{ view: 'apkaudit', icon: <ScanSearch size={17} />, label: 'APK Audit' },
|
||||
{ view: 'certs', icon: <Lock size={17} />, label: 'Certificates' },
|
||||
{ view: 'backup', icon: <Archive size={17} />, label: 'Backup' },
|
||||
{ view: 'props', icon: <SlidersHorizontal size={17}/>, label: 'Prop Editor' },
|
||||
{ view: 'utilities', icon: <Wrench size={17} />, label: 'Utilities', dividerBefore: true },
|
||||
{ view: 'flasher', icon: <Zap size={17} />, label: 'Flasher' },
|
||||
{ view: 'pixelflasher', icon: <Smartphone size={17} />, label: 'Pixel Flash' },
|
||||
{ view: 'gsiloader', icon: <HardDriveDownload size={17} />, label: 'GSI Loader' },
|
||||
]
|
||||
|
||||
export default function Sidebar({ activeView, onViewChange }: Props) {
|
||||
return (
|
||||
<aside className="w-[52px] flex flex-col bg-bg-surface border-r border-bg-border shrink-0">
|
||||
<div className="h-12 flex items-center justify-center border-b border-bg-border shrink-0">
|
||||
<Radio size={18} className="text-accent-green" />
|
||||
</div>
|
||||
interface DragProps {
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
over: boolean
|
||||
}
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-0.5 p-1 pt-1.5 overflow-auto">
|
||||
{navItems.map(({ view, icon, label, dividerBefore }) => (
|
||||
<div key={view}>
|
||||
{dividerBefore && <div className="w-full h-px bg-bg-border my-1" />}
|
||||
<button
|
||||
onClick={() => onViewChange(view)}
|
||||
title={label}
|
||||
className={`
|
||||
w-full flex items-center justify-center h-8 rounded
|
||||
transition-all duration-150 relative
|
||||
${activeView === view
|
||||
? 'bg-accent-green/10 text-accent-green'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-bg-raised'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
{activeView === view && (
|
||||
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-accent-green rounded-r" />
|
||||
)}
|
||||
</button>
|
||||
export default function Sidebar({ activeView, onViewChange, position, showLabels }: Props) {
|
||||
const horizontal = position !== 'left'
|
||||
|
||||
const [hidden, setHidden] = useState<string[]>(getHiddenViews())
|
||||
useEffect(() => onHiddenViewsChange(setHidden), [])
|
||||
|
||||
// Drag-to-reorder (dock style). Saved order first, then any new defaults.
|
||||
const [order, setOrder] = useState<string[]>(getNavOrder())
|
||||
const dragRef = useRef<string | null>(null)
|
||||
const [overView, setOverView] = useState<string | null>(null)
|
||||
|
||||
const ordered = useMemo(() => {
|
||||
const map = new Map(navItems.map(i => [i.view as string, i]))
|
||||
const seen = new Set<string>()
|
||||
const res: NavItem[] = []
|
||||
for (const v of order) {
|
||||
const it = map.get(v)
|
||||
if (it) { res.push(it); seen.add(v) }
|
||||
}
|
||||
for (const it of navItems) if (!seen.has(it.view)) res.push(it)
|
||||
return res
|
||||
}, [order])
|
||||
|
||||
const visibleItems = ordered.filter(i => !hidden.includes(i.view))
|
||||
|
||||
const handleDrop = (target: string) => {
|
||||
const from = dragRef.current
|
||||
dragRef.current = null
|
||||
setOverView(null)
|
||||
if (!from || from === target) return
|
||||
const base = ordered.map(i => i.view as string)
|
||||
const fi = base.indexOf(from)
|
||||
const ti = base.indexOf(target)
|
||||
if (fi < 0 || ti < 0) return
|
||||
base.splice(fi, 1)
|
||||
base.splice(ti, 0, from)
|
||||
setOrder(base)
|
||||
setNavOrder(base)
|
||||
}
|
||||
|
||||
const edgeBorder =
|
||||
position === 'left' ? 'border-r' : position === 'top' ? 'border-b' : 'border-t'
|
||||
|
||||
const asideCls = horizontal
|
||||
? `${showLabels ? 'h-[68px]' : 'h-[52px]'} w-full flex flex-row items-center bg-bg-surface ${edgeBorder} border-bg-border shrink-0`
|
||||
: `${showLabels ? 'w-[84px]' : 'w-[52px]'} flex flex-col bg-bg-surface ${edgeBorder} border-bg-border shrink-0`
|
||||
|
||||
const navCls = horizontal
|
||||
? 'flex-1 flex flex-row items-center justify-center gap-0.5 px-1 overflow-x-auto'
|
||||
: 'flex-1 flex flex-col gap-0.5 p-1 pt-1.5 overflow-auto'
|
||||
|
||||
const dividerCls = horizontal ? 'h-7 w-px bg-bg-border mx-1' : 'w-full h-px bg-bg-border my-1'
|
||||
|
||||
const settingsWrapCls = horizontal
|
||||
? 'px-1 h-full flex items-center border-l border-bg-border shrink-0'
|
||||
: 'p-1 pb-1.5 border-t border-bg-border shrink-0'
|
||||
|
||||
const btnSizing = !showLabels
|
||||
? 'w-8 h-8'
|
||||
: horizontal
|
||||
? 'flex-col gap-1 px-2 py-1.5 min-w-[3.25rem] h-full justify-center'
|
||||
: 'flex-col gap-1 px-1 py-1.5 w-full'
|
||||
|
||||
const labelCls = `text-[10px] leading-tight text-center ${horizontal ? 'whitespace-nowrap' : ''}`
|
||||
|
||||
const renderButton = (view: View | 'settings', icon: React.ReactNode, label: string, drag?: DragProps) => {
|
||||
const active = activeView === view
|
||||
return (
|
||||
<button
|
||||
draggable={!!drag}
|
||||
onDragStart={drag?.onDragStart}
|
||||
onDragOver={drag?.onDragOver}
|
||||
onDragLeave={drag?.onDragLeave}
|
||||
onDrop={drag?.onDrop}
|
||||
onDragEnd={drag?.onDragEnd}
|
||||
onClick={() => onViewChange(view as View)}
|
||||
title={label}
|
||||
className={`
|
||||
flex items-center justify-center rounded transition-all duration-150 relative
|
||||
${btnSizing}
|
||||
${drag ? 'cursor-grab active:cursor-grabbing' : ''}
|
||||
${drag?.over ? 'ring-1 ring-accent-green ring-inset' : ''}
|
||||
${active
|
||||
? 'bg-accent-green/10 text-accent-green'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-bg-raised'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
{showLabels && <span className={labelCls}>{label}</span>}
|
||||
{active && (
|
||||
horizontal
|
||||
? <span className="absolute bottom-0 left-1/2 -translate-x-1/2 h-0.5 w-5 bg-accent-green rounded-t" />
|
||||
: <span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-accent-green rounded-r" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={asideCls}>
|
||||
<nav className={navCls}>
|
||||
{visibleItems.map(({ view, icon, label, dividerBefore }, idx) => (
|
||||
<div key={view} className={horizontal ? 'flex items-center' : undefined}>
|
||||
{dividerBefore && idx > 0 && <div className={dividerCls} />}
|
||||
{renderButton(view, icon, label, {
|
||||
onDragStart: e => { dragRef.current = view; e.dataTransfer.setData('text/plain', view); e.dataTransfer.effectAllowed = 'move' },
|
||||
onDragOver: e => { e.preventDefault(); if (overView !== view) setOverView(view) },
|
||||
onDragLeave: () => setOverView(s => (s === view ? null : s)),
|
||||
onDrop: e => { e.preventDefault(); handleDrop(view) },
|
||||
onDragEnd: () => { dragRef.current = null; setOverView(null) },
|
||||
over: overView === view && dragRef.current !== view,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-1 pb-1.5 border-t border-bg-border shrink-0">
|
||||
<button
|
||||
onClick={() => onViewChange('settings')}
|
||||
title="Settings"
|
||||
className={`
|
||||
w-full flex items-center justify-center h-8 rounded transition-all duration-150
|
||||
${activeView === 'settings'
|
||||
? 'bg-accent-green/10 text-accent-green'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-bg-raised'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Settings size={17} />
|
||||
</button>
|
||||
<div className={settingsWrapCls}>
|
||||
{renderButton('settings', <Settings size={17} />, 'Settings')}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
|
|
|||
38
frontend/src/components/layout/TitleBar.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Custom frameless title bar. The window is Frameless (main.go), which on GTK
|
||||
// also removes the native title (so no app name shows). This thin bar provides
|
||||
// the drag region via Wails' `--wails-draggable:drag` CSS hint, plus macOS-style
|
||||
// traffic-light controls tinted in Catppuccin pastels. No app name by design.
|
||||
|
||||
// Runtime is injected by Wails on window['runtime'] (same access pattern as
|
||||
// ViewLogcat.tsx); guarded with ?. so a browser dev session won't crash.
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
function TrafficLight({ color, hover, title, onClick }: {
|
||||
color: string; hover: string; title: string; onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
style={{ backgroundColor: color }}
|
||||
className={`w-3 h-3 rounded-full transition-colors ${hover}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TitleBar() {
|
||||
return (
|
||||
<div
|
||||
className="titlebar h-8 shrink-0 flex items-center gap-2 px-3 bg-bg-surface border-b border-bg-border"
|
||||
style={{ '--wails-draggable': 'drag' } as React.CSSProperties}
|
||||
>
|
||||
{/* Catppuccin Frappé: green #a6d189, peach/yellow #e5c890, red #e78284.
|
||||
Right-aligned (ml-auto), close at the far edge. */}
|
||||
<div className="flex items-center gap-2 ml-auto" style={{ '--wails-draggable': 'no-drag' } as React.CSSProperties}>
|
||||
<TrafficLight color="#e5c890" hover="hover:brightness-110" title="Minimise" onClick={() => rt()?.WindowMinimise?.()} />
|
||||
<TrafficLight color="#a6d189" hover="hover:brightness-110" title="Maximise" onClick={() => rt()?.WindowToggleMaximise?.()} />
|
||||
<TrafficLight color="#e78284" hover="hover:brightness-110" title="Close" onClick={() => rt()?.Quit?.()} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
589
frontend/src/components/views/ViewApkAudit.tsx
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
ScanSearch, FileUp, Package, Shield, AlertTriangle, FileCode,
|
||||
Lock, FolderTree, Search, ChevronRight, Activity, Radar,
|
||||
Download, X,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
SelectAPKForAudit, AuditAPK, AuditInstalledApp, ListPackages,
|
||||
ReadAPKEntry, ExportAudit,
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { CodeView, detectLang } from '../../lib/syntax'
|
||||
import type { APKAudit, APKAuditFinding, APKEntryContent, PackageInfo } from '../../lib/types'
|
||||
|
||||
type Tab = 'overview' | 'findings' | 'manifest' | 'components' | 'cert' | 'explorer'
|
||||
type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'
|
||||
|
||||
const SEV_ORDER: Severity[] = ['critical', 'high', 'medium', 'low', 'info']
|
||||
|
||||
function sevText(s: string): string {
|
||||
switch (s) {
|
||||
case 'critical': return 'text-danger'
|
||||
case 'high': return 'text-danger'
|
||||
case 'medium': return 'text-warn'
|
||||
case 'low': return 'text-text-secondary'
|
||||
default: return 'text-text-muted'
|
||||
}
|
||||
}
|
||||
|
||||
function sevBadge(s: string): string {
|
||||
switch (s) {
|
||||
case 'critical': return 'bg-danger/20 text-danger border border-danger/30'
|
||||
case 'high': return 'bg-danger/10 text-danger border border-danger/20'
|
||||
case 'medium': return 'bg-warn/15 text-warn border border-warn/25'
|
||||
case 'low': return 'bg-bg-raised text-text-secondary border border-bg-border'
|
||||
default: return 'bg-bg-raised text-text-muted border border-bg-border'
|
||||
}
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score >= 75) return 'text-accent-green'
|
||||
if (score >= 40) return 'text-warn'
|
||||
return 'text-danger'
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(n) / Math.log(1024))
|
||||
return `${(n / Math.pow(1024, i)).toFixed(i ? 1 : 0)} ${u[i]}`
|
||||
}
|
||||
|
||||
export default function ViewApkAudit() {
|
||||
const [result, setResult] = useState<APKAudit | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tab, setTab] = useState<Tab>('overview')
|
||||
|
||||
// package picker
|
||||
const [search, setSearch] = useState('')
|
||||
const [packages, setPackages] = useState<PackageInfo[]>([])
|
||||
const [pkgsLoaded, setPkgsLoaded] = useState(false)
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
|
||||
// findings controls
|
||||
const [findFilter, setFindFilter] = useState<Severity | 'all'>('all')
|
||||
const [findSearch, setFindSearch] = useState('')
|
||||
const [openFinding, setOpenFinding] = useState<string | null>(null)
|
||||
|
||||
// explorer
|
||||
const [fileSearch, setFileSearch] = useState('')
|
||||
const [entry, setEntry] = useState<APKEntryContent | null>(null)
|
||||
const [entryPath, setEntryPath] = useState('')
|
||||
const [entryLoading, setEntryLoading] = useState(false)
|
||||
|
||||
// export
|
||||
const [showExport, setShowExport] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (pkgsLoaded) return
|
||||
try {
|
||||
const pkgs = await ListPackages('all')
|
||||
setPackages(pkgs || [])
|
||||
setPkgsLoaded(true)
|
||||
} catch { /* device may be offline; ignore */ }
|
||||
}
|
||||
|
||||
const run = async (fn: () => Promise<APKAudit>) => {
|
||||
setLoading(true); setResult(null); setTab('overview'); setShowPicker(false)
|
||||
setFindFilter('all'); setFindSearch(''); setOpenFinding(null)
|
||||
setEntry(null); setEntryPath(''); setFileSearch(''); setShowExport(false)
|
||||
try {
|
||||
setResult(await fn())
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const auditFile = async () => {
|
||||
const path = await SelectAPKForAudit()
|
||||
if (path) run(() => AuditAPK(path))
|
||||
}
|
||||
|
||||
const openEntry = async (path: string) => {
|
||||
if (!result) return
|
||||
setEntryPath(path); setEntry(null); setEntryLoading(true)
|
||||
try {
|
||||
setEntry(await ReadAPKEntry(result.localPath, path))
|
||||
} catch (e: any) {
|
||||
notify.error(e); setEntryPath('')
|
||||
} finally {
|
||||
setEntryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doExport = async (format: 'json' | 'csv' | 'sarif') => {
|
||||
if (!result) return
|
||||
setShowExport(false); setExporting(true)
|
||||
try {
|
||||
const path = await ExportAudit(result, format)
|
||||
if (path) notify.success(`Exported to ${path}`)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPkgs = packages
|
||||
.filter(p => p.packageName.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
const findings = result?.findings ?? []
|
||||
const visibleFindings = useMemo(() => findings.filter(f => {
|
||||
if (findFilter !== 'all' && f.severity !== findFilter) return false
|
||||
if (findSearch) {
|
||||
const q = findSearch.toLowerCase()
|
||||
return (f.title + f.category + f.cwe + f.masvs).toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
}), [findings, findFilter, findSearch])
|
||||
|
||||
const visibleFiles = useMemo(() => (result?.files ?? []).filter(f =>
|
||||
!fileSearch || f.path.toLowerCase().includes(fileSearch.toLowerCase())
|
||||
).slice(0, 2000), [result, fileSearch])
|
||||
|
||||
const dangerousPerms = (result?.permissions ?? []).filter(p => p.dangerous)
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: <Package size={12} /> },
|
||||
{ id: 'findings', label: `Findings (${findings.length})`, icon: <AlertTriangle size={12} /> },
|
||||
{ id: 'manifest', label: 'Manifest', icon: <FileCode size={12} /> },
|
||||
{ id: 'components', label: `Components (${result?.components?.length || 0})`, icon: <Activity size={12} /> },
|
||||
{ id: 'cert', label: 'Cert', icon: <Lock size={12} /> },
|
||||
{ id: 'explorer', label: `Explorer (${result?.files?.length || 0})`, icon: <FolderTree size={12} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Source bar */}
|
||||
<div className="border-b border-bg-border px-4 py-3 flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScanSearch size={16} className="text-accent-green" />
|
||||
<span className="section-title">APK Audit</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<button onClick={auditFile} disabled={loading} className="btn-primary text-xs">
|
||||
<FileUp size={12} /> Browse APK…
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setShowPicker(v => !v); loadPackages() }}
|
||||
disabled={loading}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Package size={12} /> Installed app…
|
||||
</button>
|
||||
{showPicker && (
|
||||
<div className="absolute z-20 mt-1 w-72 bg-bg-surface border border-bg-border rounded shadow-lg">
|
||||
<div className="p-2 border-b border-bg-border">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
className="input pl-7 text-xs w-full"
|
||||
placeholder="Filter packages…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
{!pkgsLoaded && <p className="text-text-muted text-xs p-3 text-center">Loading… (device must be connected)</p>}
|
||||
{pkgsLoaded && filteredPkgs.length === 0 && (
|
||||
<p className="text-text-muted text-xs p-3 text-center">No matching packages</p>
|
||||
)}
|
||||
{filteredPkgs.map(p => (
|
||||
<button
|
||||
key={p.packageName}
|
||||
onClick={() => run(() => AuditInstalledApp(p.packageName))}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text-secondary hover:bg-bg-raised hover:text-text-primary border-b border-bg-border/30 truncate mono"
|
||||
>
|
||||
{p.packageName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty / loading */}
|
||||
{!result && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-muted">
|
||||
<ScanSearch size={36} className="opacity-20" />
|
||||
<p className="text-sm">Browse for an APK file or pick an installed app to audit</p>
|
||||
<p className="text-xs opacity-70">Static analysis: manifest, signing, permissions, components, secrets & trackers</p>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<div className="w-6 h-6 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-text-muted text-xs">Auditing… (pulling & parsing DEX, this can take a few seconds)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="border-b border-bg-border px-4 py-3 flex items-center gap-4 shrink-0">
|
||||
<div className={`text-3xl font-bold ${scoreColor(result.score)}`}>{result.grade}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-text-primary truncate">
|
||||
{result.appLabel || result.fileName} <span className="text-text-muted mono text-xs">({result.packageName})</span>
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
v{result.versionName} (code {result.versionCode}) · SDK {result.minSdk}–{result.targetSdk} · {formatBytes(result.fileSize)} · score {result.score}/100
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="flex gap-1.5 flex-wrap justify-end">
|
||||
{SEV_ORDER.map(s => (result.counts?.[s] ? (
|
||||
<span key={s} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${sevBadge(s)}`}>
|
||||
{result.counts[s]} {s}
|
||||
</span>
|
||||
) : null))}
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<button onClick={() => setShowExport(v => !v)} disabled={exporting} className="btn-ghost text-xs">
|
||||
<Download size={12} /> {exporting ? 'Exporting…' : 'Export'}
|
||||
</button>
|
||||
{showExport && (
|
||||
<div className="absolute right-0 z-20 mt-1 w-32 bg-bg-surface border border-bg-border rounded shadow-lg">
|
||||
{(['json', 'csv', 'sarif'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => doExport(f)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text-secondary hover:bg-bg-raised hover:text-text-primary uppercase mono border-b border-bg-border/30 last:border-0"
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-bg-border flex shrink-0 overflow-x-auto">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-xs whitespace-nowrap border-b-2 transition-colors ${
|
||||
tab === t.id ? 'border-accent-green text-accent-green'
|
||||
: 'border-transparent text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/* OVERVIEW */}
|
||||
{tab === 'overview' && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2">
|
||||
{[
|
||||
{ label: 'Package', value: result.packageName },
|
||||
{ label: 'Version', value: `${result.versionName} (${result.versionCode})` },
|
||||
{ label: 'SDK', value: `min ${result.minSdk} · target ${result.targetSdk} · compile ${result.compileSdk}` },
|
||||
{ label: 'Source', value: result.source === 'device' ? 'Installed app' : result.path },
|
||||
{ label: 'SHA-256', value: result.sha256 },
|
||||
{ label: 'Size', value: formatBytes(result.fileSize) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex gap-2 min-w-0">
|
||||
<span className="text-text-muted text-xs w-24 shrink-0">{label}</span>
|
||||
<span className="text-xs text-text-primary mono truncate" title={value}>{value || 'N/A'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* manifest flags */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{result.debuggable && <span className="badge-red">debuggable</span>}
|
||||
{result.allowBackup && <span className="badge-yellow">allowBackup</span>}
|
||||
{result.usesCleartext && <span className="badge-yellow">cleartext traffic</span>}
|
||||
{result.hasNetworkSecurityConfig && <span className="badge-green">network-security-config</span>}
|
||||
{result.cert.verified
|
||||
? <span className="badge-green">signature verified</span>
|
||||
: <span className="badge-red">unsigned / unverified</span>}
|
||||
{result.cert.v3 && <span className="badge-gray">v3 sig</span>}
|
||||
{result.cert.v2 && <span className="badge-gray">v2 sig</span>}
|
||||
{result.cert.v1 && <span className="badge-gray">v1 sig</span>}
|
||||
</div>
|
||||
|
||||
{/* dangerous perms */}
|
||||
<div>
|
||||
<p className="section-title mb-2">Dangerous permissions ({dangerousPerms.length})</p>
|
||||
{dangerousPerms.length === 0 && <p className="text-text-muted text-xs">None of the runtime-dangerous permissions are requested.</p>}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{dangerousPerms.map(p => (
|
||||
<span key={p.name} className="px-1.5 py-0.5 rounded text-[10px] bg-warn/10 text-warn border border-warn/20 mono">
|
||||
{p.name.replace('android.permission.', '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* trackers */}
|
||||
<div>
|
||||
<p className="section-title mb-2 flex items-center gap-1.5"><Radar size={12} /> Trackers / SDKs ({result.trackers?.length || 0})</p>
|
||||
{(!result.trackers || result.trackers.length === 0) && <p className="text-text-muted text-xs">No known tracker SDK signatures detected.</p>}
|
||||
<div className="space-y-1">
|
||||
{result.trackers?.map(tr => (
|
||||
<div key={tr.name} className="flex items-center gap-2 text-xs py-0.5">
|
||||
<span className="text-text-primary w-44 truncate">{tr.name}</span>
|
||||
<span className="text-text-muted w-32">{tr.category}</span>
|
||||
<span className="text-text-muted">×{tr.matches}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FINDINGS */}
|
||||
{tab === 'findings' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{(['all', ...SEV_ORDER] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFindFilter(s)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] capitalize ${
|
||||
findFilter === s ? 'bg-accent-green/15 text-accent-green border border-accent-green/30'
|
||||
: 'bg-bg-raised text-text-muted border border-bg-border'
|
||||
}`}
|
||||
>
|
||||
{s}{s !== 'all' && result.counts?.[s] ? ` ${result.counts[s]}` : ''}
|
||||
</button>
|
||||
))}
|
||||
<div className="relative ml-auto">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-7 text-xs w-48"
|
||||
placeholder="Search findings…"
|
||||
value={findSearch}
|
||||
onChange={e => setFindSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleFindings.length === 0 && (
|
||||
<p className="text-text-muted text-xs py-6 text-center">No findings match.</p>
|
||||
)}
|
||||
{visibleFindings.map(f => (
|
||||
<FindingRow
|
||||
key={f.id}
|
||||
f={f}
|
||||
open={openFinding === f.id}
|
||||
onToggle={() => setOpenFinding(openFinding === f.id ? null : f.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MANIFEST */}
|
||||
{tab === 'manifest' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="section-title mb-2">Permissions ({result.permissions?.length || 0})</p>
|
||||
<div className="space-y-0.5">
|
||||
{result.permissions?.map(p => (
|
||||
<div key={p.name} className="flex items-center gap-2 py-0.5 border-b border-bg-border/30">
|
||||
<Shield size={11} className={p.dangerous ? 'text-warn shrink-0' : 'text-text-muted shrink-0'} />
|
||||
<span className="mono text-xs text-text-secondary">{p.name}</span>
|
||||
{p.dangerous && <span className="badge-yellow ml-auto">dangerous</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="section-title mb-2">Decoded AndroidManifest.xml</p>
|
||||
<pre className="mono text-xs text-text-secondary whitespace-pre-wrap break-words leading-relaxed bg-bg-raised rounded p-3 border border-bg-border max-h-[55vh] overflow-auto">
|
||||
{result.manifestXml || 'Not available'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* COMPONENTS */}
|
||||
{tab === 'components' && (
|
||||
<div className="space-y-4">
|
||||
{['activity', 'service', 'receiver', 'provider'].map(type => {
|
||||
const items = result.components.filter(c => c.type === type)
|
||||
return (
|
||||
<div key={type}>
|
||||
<p className="section-title mb-2 capitalize">{type} ({items.length})</p>
|
||||
{items.length === 0 && <p className="text-text-muted text-xs">None</p>}
|
||||
{items.map((c, i) => (
|
||||
<div key={c.name + i} className="py-1 border-b border-bg-border/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="mono text-xs text-text-secondary truncate">{c.name}</span>
|
||||
{c.exported && <span className="badge-red shrink-0">exported</span>}
|
||||
{!c.exported && c.exportedImplicit && <span className="badge-yellow shrink-0">implicit export</span>}
|
||||
{c.permission && <span className="badge-gray shrink-0" title={c.permission}>protected</span>}
|
||||
</div>
|
||||
{c.intentFilters?.filter(Boolean).length > 0 && (
|
||||
<p className="text-[10px] text-text-muted mt-0.5 pl-1">↳ {c.intentFilters.filter(Boolean).join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CERT */}
|
||||
{tab === 'cert' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{result.cert.verified ? <span className="badge-green">verified</span> : <span className="badge-red">does not verify</span>}
|
||||
{result.cert.v1 && <span className="badge-gray">v1 scheme</span>}
|
||||
{result.cert.v2 && <span className="badge-gray">v2 scheme</span>}
|
||||
{result.cert.v3 && <span className="badge-gray">v3 scheme</span>}
|
||||
{result.cert.isDebug && <span className="badge-red">debug cert</span>}
|
||||
{result.cert.expired && <span className="badge-yellow">expired</span>}
|
||||
{result.cert.weakAlgo && <span className="badge-red">weak algorithm</span>}
|
||||
</div>
|
||||
{result.cert.error && (
|
||||
<p className="text-xs text-danger bg-danger/10 border border-danger/20 rounded px-3 py-1.5">{result.cert.error}</p>
|
||||
)}
|
||||
{[
|
||||
{ label: 'Subject', value: result.cert.subject },
|
||||
{ label: 'Issuer', value: result.cert.issuer },
|
||||
{ label: 'Algorithm', value: result.cert.sigAlgo },
|
||||
{ label: 'Serial', value: result.cert.serial },
|
||||
{ label: 'Valid from', value: result.cert.validFrom },
|
||||
{ label: 'Valid to', value: result.cert.validTo },
|
||||
{ label: 'SHA-256', value: result.cert.sha256 },
|
||||
{ label: 'SHA-1', value: result.cert.sha1 },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-xs text-text-muted mb-0.5">{label}</p>
|
||||
<p className="mono text-xs text-text-primary bg-bg-raised rounded px-3 py-1.5 break-all">{value || 'N/A'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EXPLORER */}
|
||||
{tab === 'explorer' && (
|
||||
<div className="flex gap-3 h-full min-h-0">
|
||||
{/* file list */}
|
||||
<div className="w-72 shrink-0 flex flex-col min-h-0">
|
||||
<div className="relative mb-2">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-7 text-xs w-full"
|
||||
placeholder="Filter files…"
|
||||
value={fileSearch}
|
||||
onChange={e => setFileSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border border-bg-border rounded overflow-auto flex-1">
|
||||
{visibleFiles.map(f => (
|
||||
<button
|
||||
key={f.path}
|
||||
onClick={() => openEntry(f.path)}
|
||||
className={`w-full flex items-center gap-2 px-2.5 py-1 text-xs border-b border-bg-border/30 text-left ${
|
||||
entryPath === f.path ? 'bg-accent-green/10' : 'hover:bg-bg-raised'
|
||||
}`}
|
||||
>
|
||||
<FileCode size={11} className="text-text-muted shrink-0" />
|
||||
<span className="mono text-text-secondary truncate flex-1">{f.path}</span>
|
||||
<span className="text-text-muted shrink-0">{formatBytes(f.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
{(result.files?.length || 0) > visibleFiles.length && (
|
||||
<p className="text-text-muted text-[10px] p-2 text-center">Showing {visibleFiles.length} of {result.files.length} — refine the filter.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* viewer */}
|
||||
<div className="flex-1 min-w-0 flex flex-col border border-bg-border rounded overflow-hidden">
|
||||
{!entryPath && (
|
||||
<div className="flex items-center justify-center h-full text-text-muted text-xs">Select a file to view its contents</div>
|
||||
)}
|
||||
{entryPath && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-bg-border bg-bg-surface shrink-0">
|
||||
<span className="mono text-xs text-text-primary truncate flex-1">{entryPath}</span>
|
||||
{entry && <span className="text-[10px] text-text-muted shrink-0">{entry.kind} · {formatBytes(entry.size)}{entry.truncated ? ' · truncated' : ''}</span>}
|
||||
<button onClick={() => { setEntry(null); setEntryPath('') }} className="text-text-muted hover:text-text-primary shrink-0"><X size={13} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{entryLoading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{entry?.kind === 'image' && (
|
||||
<div className="p-4 flex items-center justify-center bg-bg-base">
|
||||
<img src={`data:${entry.mime};base64,${entry.base64}`} alt={entry.name} className="max-w-full max-h-[55vh] object-contain" />
|
||||
</div>
|
||||
)}
|
||||
{entry?.kind === 'text' && (
|
||||
<CodeView
|
||||
code={entry.text || ''}
|
||||
lang={detectLang(entry.name || entryPath, entry.text || '')}
|
||||
className="mono text-[11px] text-text-secondary whitespace-pre-wrap break-words leading-relaxed p-3"
|
||||
/>
|
||||
)}
|
||||
{entry?.kind === 'binary' && (
|
||||
<pre className="mono text-[11px] text-text-secondary whitespace-pre p-3 leading-snug">{entry.hex}</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FindingRow({ f, open, onToggle }: { f: APKAuditFinding; open: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="border border-bg-border rounded overflow-hidden">
|
||||
<button onClick={onToggle} className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-bg-raised">
|
||||
<ChevronRight size={13} className={`text-text-muted shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium uppercase shrink-0 ${sevBadge(f.severity)}`}>{f.severity}</span>
|
||||
<span className="text-xs text-text-primary flex-1">{f.title}</span>
|
||||
{f.matches?.length > 0 && <span className="text-[10px] text-text-muted shrink-0">{f.matches.length} match{f.matches.length > 1 ? 'es' : ''}</span>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 pt-1 space-y-2 bg-bg-base/50">
|
||||
<p className="text-xs text-text-secondary">{f.description}</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{f.cwe && <span className="badge-gray">{f.cwe}</span>}
|
||||
{f.masvs && <span className="badge-gray">{f.masvs}</span>}
|
||||
<span className="badge-gray">{f.category}</span>
|
||||
<span className="badge-gray">confidence {f.confidence}%</span>
|
||||
</div>
|
||||
{f.matches?.length > 0 && (
|
||||
<div className="space-y-0.5 mt-1">
|
||||
{f.matches.map((m, i) => (
|
||||
<div key={i} className="flex gap-2 text-[11px] mono bg-bg-raised rounded px-2 py-1">
|
||||
{m.file && <span className="text-text-muted shrink-0">{m.file}</span>}
|
||||
<span className="text-text-secondary break-all">{m.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
import { useState } from 'react'
|
||||
import { Search, Package, Shield, Activity, Server, Database, Cpu, FileCode, AlertTriangle } from 'lucide-react'
|
||||
import { InspectApp, CheckPinning, ListPackages } from '../../lib/wails'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Package, Shield, ShieldCheck, Activity, Server, Database, Cpu, FileCode, AlertTriangle, Radar } from 'lucide-react'
|
||||
import { InspectApp, CheckPinning, ListPackages, ScanAppPrivacy } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { AppInspection, PackageInfo } from '../../lib/types'
|
||||
import { CodeView } from '../../lib/syntax'
|
||||
import type { AppInspection, PackageInfo, PrivacyReport } from '../../lib/types'
|
||||
|
||||
// Privacy grade -> tailwind text/badge colour.
|
||||
const GRADE_COLOR: Record<string, string> = {
|
||||
A: 'text-accent-green', B: 'text-accent-green', C: 'text-warn', D: 'text-warn', F: 'text-danger',
|
||||
}
|
||||
|
||||
export default function ViewAppInspect() {
|
||||
const [search, setSearch] = useState('')
|
||||
|
|
@ -13,6 +19,34 @@ export default function ViewAppInspect() {
|
|||
const [pinning, setPinning] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [showManifest, setShowManifest] = useState(false)
|
||||
const [privacy, setPrivacy] = useState<PrivacyReport | null>(null)
|
||||
const [privacyLoading, setPrivacyLoading] = useState(false)
|
||||
// Width of the package picker rail. Draggable so long package names (which
|
||||
// truncate at the old fixed 256px) can be read in full. Persisted.
|
||||
const [panelW, setPanelW] = useState(() => {
|
||||
const v = parseInt(localStorage.getItem('atk-appinspect-w') || '', 10)
|
||||
return Number.isFinite(v) ? Math.min(560, Math.max(200, v)) : 256
|
||||
})
|
||||
|
||||
const startResize = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const startX = e.clientX
|
||||
const startW = panelW
|
||||
let latest = startW
|
||||
document.body.style.userSelect = 'none'
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
latest = Math.min(560, Math.max(200, startW + ev.clientX - startX))
|
||||
setPanelW(latest)
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
document.body.style.userSelect = ''
|
||||
localStorage.setItem('atk-appinspect-w', String(latest))
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (pkgsLoaded) return
|
||||
|
|
@ -23,11 +57,16 @@ export default function ViewAppInspect() {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
// Populate the picker as soon as the view opens (so it isn't empty until the
|
||||
// search box is focused). Safe with no device — it just stays empty.
|
||||
useEffect(() => { loadPackages() }, [])
|
||||
|
||||
const inspect = async (pkg: string) => {
|
||||
if (!pkg.trim()) return
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
setPinning('')
|
||||
setPrivacy(null)
|
||||
setActiveTab('overview')
|
||||
try {
|
||||
const data = await InspectApp(pkg.trim())
|
||||
|
|
@ -39,6 +78,19 @@ export default function ViewAppInspect() {
|
|||
}
|
||||
}
|
||||
|
||||
const scanPrivacy = async () => {
|
||||
if (!result) return
|
||||
setPrivacyLoading(true)
|
||||
try {
|
||||
const rep = await ScanAppPrivacy(result.packageName)
|
||||
setPrivacy(rep)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setPrivacyLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkPinning = async () => {
|
||||
if (!result) return
|
||||
try {
|
||||
|
|
@ -51,10 +103,11 @@ export default function ViewAppInspect() {
|
|||
|
||||
const filtered = packages.filter(p =>
|
||||
p.packageName.toLowerCase().includes(search.toLowerCase())
|
||||
).slice(0, 20)
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: <Package size={12} /> },
|
||||
{ id: 'privacy', label: privacy ? `Privacy (${privacy.grade})` : 'Privacy', icon: <ShieldCheck size={12} /> },
|
||||
{ id: 'permissions', label: `Permissions (${result?.permissions?.length || 0})`, icon: <Shield size={12} /> },
|
||||
{ id: 'components', label: 'Components', icon: <Activity size={12} /> },
|
||||
{ id: 'libs', label: 'Native Libs', icon: <Cpu size={12} /> },
|
||||
|
|
@ -64,8 +117,8 @@ export default function ViewAppInspect() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Left: package picker */}
|
||||
<div className="w-64 shrink-0 border-r border-bg-border flex flex-col overflow-hidden">
|
||||
{/* Left: package picker (resizable) */}
|
||||
<div className="shrink-0 border-r border-bg-border flex flex-col overflow-hidden relative" style={{ width: panelW }}>
|
||||
<div className="p-3 border-b border-bg-border space-y-2 shrink-0">
|
||||
<p className="section-title">App Inspector</p>
|
||||
<div className="relative">
|
||||
|
|
@ -99,6 +152,12 @@ export default function ViewAppInspect() {
|
|||
<p className="text-text-muted text-xs text-center p-4">Type to search or focus to load package list</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Drag handle to widen the rail when package names get cut off */}
|
||||
<div
|
||||
onMouseDown={startResize}
|
||||
title="Drag to resize"
|
||||
className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize hover:bg-accent-green/40 active:bg-accent-green/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: inspection results */}
|
||||
|
|
@ -186,6 +245,100 @@ export default function ViewAppInspect() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<div className="space-y-4">
|
||||
{!privacy && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8 text-center">
|
||||
<Radar size={28} className="text-text-muted opacity-40" />
|
||||
<p className="text-xs text-text-muted max-w-sm">
|
||||
Scans the app's bytecode for known tracker / analytics / ad SDKs and
|
||||
cross-references dangerous permissions to compute a privacy score.
|
||||
Pulls the APK off the device — may take a few seconds.
|
||||
</p>
|
||||
<button onClick={scanPrivacy} disabled={privacyLoading} className="btn-primary text-xs">
|
||||
{privacyLoading
|
||||
? <><div className="w-3 h-3 border-2 border-bg-base border-t-transparent rounded-full animate-spin" /> Scanning...</>
|
||||
: <><Radar size={12} /> Scan privacy</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{privacy && (
|
||||
<>
|
||||
{/* Score header */}
|
||||
<div className="flex items-center gap-4 rounded border border-bg-border bg-bg-raised p-4">
|
||||
<div className={`text-4xl font-bold ${GRADE_COLOR[privacy.grade] || 'text-text-primary'}`}>
|
||||
{privacy.grade}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-lg font-semibold ${GRADE_COLOR[privacy.grade] || 'text-text-primary'}`}>{privacy.score}</span>
|
||||
<span className="text-xs text-text-muted">/ 100 privacy score</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{privacy.trackerCount} tracker{privacy.trackerCount === 1 ? '' : 's'} ·{' '}
|
||||
{privacy.dangerousPermissions.length} dangerous permission{privacy.dangerousPermissions.length === 1 ? '' : 's'}
|
||||
{privacy.apkSize > 0 && <> · {(privacy.apkSize / 1048576).toFixed(1)} MB APK</>}
|
||||
</p>
|
||||
{/* Score bar */}
|
||||
<div className="mt-2 h-1.5 rounded-full bg-bg-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${privacy.score >= 70 ? 'bg-accent-green' : privacy.score >= 40 ? 'bg-warn' : 'bg-danger'}`}
|
||||
style={{ width: `${privacy.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={scanPrivacy} disabled={privacyLoading} className="btn-ghost text-xs shrink-0">
|
||||
<Radar size={12} /> {privacyLoading ? 'Scanning...' : 'Rescan'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Trackers */}
|
||||
<div>
|
||||
<p className="section-title mb-2">Trackers ({privacy.trackerCount})</p>
|
||||
{privacy.trackerCount === 0 && (
|
||||
<p className="text-xs text-accent-green flex items-center gap-1.5">
|
||||
<ShieldCheck size={12} /> No known trackers detected in bytecode.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{privacy.trackers.map(t => (
|
||||
<div key={t.name} className="flex items-center gap-2 rounded border border-bg-border bg-bg-surface px-2.5 py-1.5" title={`${t.matches} signature match${t.matches === 1 ? '' : 'es'}`}>
|
||||
<Radar size={11} className="text-danger shrink-0" />
|
||||
<span className="text-xs text-text-primary">{t.name}</span>
|
||||
<span className="badge-gray text-xs">{t.category}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dangerous permissions */}
|
||||
<div>
|
||||
<p className="section-title mb-2">Dangerous permissions ({privacy.dangerousPermissions.length})</p>
|
||||
{privacy.dangerousPermissions.length === 0 && (
|
||||
<p className="text-xs text-text-muted">None declared.</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{privacy.dangerousPermissions.map(p => (
|
||||
<div key={p} className="flex items-center gap-2 py-1 border-b border-bg-border/30">
|
||||
<AlertTriangle size={11} className="text-warn shrink-0" />
|
||||
<span className="mono text-xs text-text-secondary">{p}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted leading-relaxed pt-1">
|
||||
Heuristic: matches known SDK package signatures in DEX bytecode (string
|
||||
constants aren't decrypted, so obfuscated/encrypted trackers may be missed)
|
||||
and counts declared Android "dangerous" permissions. A lower score means more
|
||||
trackers / invasive permissions.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'permissions' && (
|
||||
<div className="space-y-1">
|
||||
{result.permissions?.length === 0 && (
|
||||
|
|
@ -263,9 +416,11 @@ export default function ViewAppInspect() {
|
|||
{showManifest ? 'Hide' : 'Show'} full package dump ({result.manifestDump?.split('\n').length} lines)
|
||||
</button>
|
||||
{showManifest && (
|
||||
<pre className="mono text-xs text-text-secondary whitespace-pre-wrap break-words leading-relaxed bg-bg-raised rounded p-3 border border-bg-border max-h-[60vh] overflow-auto">
|
||||
{result.manifestDump}
|
||||
</pre>
|
||||
<CodeView
|
||||
code={result.manifestDump || ''}
|
||||
lang="log"
|
||||
className="mono text-xs text-text-secondary whitespace-pre-wrap break-words leading-relaxed bg-bg-raised rounded p-3 border border-bg-border max-h-[60vh] overflow-auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState } from 'react'
|
||||
import { Archive, RotateCcw, AlertTriangle, Package, Check } from 'lucide-react'
|
||||
import { StartBackup, RestoreBackup, SelectBackupFile, ListPackages } from '../../lib/wails'
|
||||
import { Archive, RotateCcw, AlertTriangle, Check, FolderDown, X, Eye, EyeOff } from 'lucide-react'
|
||||
import { StartBackup, RestoreBackup, SelectBackupFile, ListPackages, PullPathsWithProgress } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import type { PackageInfo } from '../../lib/types'
|
||||
|
||||
export default function ViewBackup() {
|
||||
|
|
@ -14,6 +15,28 @@ export default function ViewBackup() {
|
|||
const [backing, setBacking] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [tipsHidden, setTipsHidden] = useState(localStorage.getItem('atk-backup-tips') === 'hidden')
|
||||
const [folders, setFolders] = useState<string[]>([])
|
||||
const [folderInput, setFolderInput] = useState('')
|
||||
|
||||
const toggleTips = () => {
|
||||
const v = !tipsHidden
|
||||
setTipsHidden(v)
|
||||
localStorage.setItem('atk-backup-tips', v ? 'hidden' : 'shown')
|
||||
}
|
||||
const addFolder = (p: string) => {
|
||||
const v = p.trim()
|
||||
if (v && !folders.includes(v)) setFolders([...folders, v])
|
||||
setFolderInput('')
|
||||
}
|
||||
const backupFolders = async () => {
|
||||
if (folders.length === 0) { notify.error('Add at least one folder to back up'); return }
|
||||
const id = notify.loading('Folder backup — choose a destination folder…')
|
||||
try {
|
||||
const out = await PullPathsWithProgress(folders)
|
||||
notify.dismiss(id); notify.success(out)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
}
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (pkgsLoaded) return
|
||||
|
|
@ -91,15 +114,21 @@ export default function ViewBackup() {
|
|||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden p-4 gap-4">
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-3 bg-warn/5 border border-warn/20 rounded-lg px-4 py-3 shrink-0">
|
||||
<DismissibleBanner id="warn-backup" className="bg-warn/5 border border-warn/20 rounded-lg px-4 py-3 shrink-0 text-warn">
|
||||
<AlertTriangle size={15} className="text-warn shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-warn/80 space-y-1">
|
||||
<p className="font-medium">Android 12+ heavily restricts adb backup</p>
|
||||
<p>Apps must opt-in via <span className="mono">android:allowBackup="true"</span> and the <span className="mono">ALLOW_ADB_BACKUP</span> flag. Many modern apps will not be backed up. For full backup, use a rooted device with Titanium Backup or Swift Backup.</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
<div className="flex justify-end shrink-0 -mt-2">
|
||||
<button onClick={toggleTips} className="btn-ghost text-xs">
|
||||
{tipsHidden ? <><Eye size={12} /> Show tips</> : <><EyeOff size={12} /> Hide tips</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 flex-1 overflow-hidden">
|
||||
<div className={`grid grid-cols-1 ${tipsHidden ? '' : 'xl:grid-cols-2'} gap-4 flex-1 overflow-hidden`}>
|
||||
{/* Backup config */}
|
||||
<div className="card p-4 space-y-4 overflow-auto">
|
||||
<p className="section-title">Backup Configuration</p>
|
||||
|
|
@ -182,9 +211,44 @@ export default function ViewBackup() {
|
|||
{result}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder / file backup (no app-opt-in needed — straight adb pull) */}
|
||||
<div className="border-t border-bg-border pt-4 space-y-2">
|
||||
<p className="section-title">Folder backup</p>
|
||||
<p className="text-xs text-text-muted">Pull device folders/files straight to your computer — works regardless of an app's backup flags.</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{['/sdcard/DCIM', '/sdcard/Download', '/sdcard/Pictures', '/sdcard/Documents', '/sdcard'].map(p => (
|
||||
<button key={p} onClick={() => addFolder(p)} className="btn-ghost text-xs py-0.5 px-1.5">+ {p.replace('/sdcard/', '') || '/sdcard'}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1 mono"
|
||||
placeholder="/sdcard/path/to/folder"
|
||||
value={folderInput}
|
||||
onChange={e => setFolderInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addFolder(folderInput)}
|
||||
/>
|
||||
<button onClick={() => addFolder(folderInput)} className="btn-ghost text-xs shrink-0">Add</button>
|
||||
</div>
|
||||
{folders.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{folders.map(f => (
|
||||
<div key={f} className="flex items-center justify-between bg-bg-raised rounded px-2 py-1">
|
||||
<span className="mono text-xs text-text-secondary truncate">{f}</span>
|
||||
<button onClick={() => setFolders(folders.filter(x => x !== f))} className="text-text-muted hover:text-danger shrink-0"><X size={12} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={backupFolders} disabled={folders.length === 0} className="btn-ghost w-full justify-center text-xs">
|
||||
<FolderDown size={13} /> Back up {folders.length || ''} folder(s) → computer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info panel */}
|
||||
{!tipsHidden && (
|
||||
<div className="card p-4 space-y-4 overflow-auto">
|
||||
<p className="section-title">How adb backup works</p>
|
||||
<div className="space-y-3 text-xs text-text-muted">
|
||||
|
|
@ -219,6 +283,7 @@ export default function ViewBackup() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||
import { Shield, RefreshCw, Plus, Trash2, AlertTriangle, Check, Lock } from 'lucide-react'
|
||||
import { ListSystemCerts, ListUserCerts, InstallUserCert, RemoveUserCert, SelectCertFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import type { CertInfo } from '../../lib/types'
|
||||
|
||||
export default function ViewCerts() {
|
||||
|
|
@ -70,15 +71,13 @@ export default function ViewCerts() {
|
|||
</div>
|
||||
|
||||
{/* Burp/MITM info banner */}
|
||||
<div className="border-b border-bg-border/50 bg-accent-green/5 px-4 py-2 shrink-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={13} className="text-accent-green shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-text-secondary space-y-0.5">
|
||||
<p className="font-medium text-accent-green">HTTPS Interception Setup (Burp Suite / mitmproxy)</p>
|
||||
<p>1. Export your proxy CA cert as DER/PEM 2. Click "Install User CA" above 3. Set device proxy to your machine IP 4. For Android 7+ apps with pinning — use Magisk TrustUserCerts module or patch the APK</p>
|
||||
</div>
|
||||
<DismissibleBanner id="info-certs-burp" className="border-b border-bg-border/50 bg-accent-green/5 px-4 py-2 shrink-0 text-accent-green">
|
||||
<Shield size={13} className="text-accent-green shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-text-secondary space-y-0.5">
|
||||
<p className="font-medium text-accent-green">HTTPS Interception Setup (Burp Suite / mitmproxy)</p>
|
||||
<p>1. Export your proxy CA cert as DER/PEM 2. Click "Install User CA" above 3. Set device proxy to your machine IP 4. For Android 7+ apps with pinning — use Magisk TrustUserCerts module or patch the APK</p>
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-bg-border flex shrink-0">
|
||||
|
|
@ -99,13 +98,13 @@ export default function ViewCerts() {
|
|||
|
||||
{/* Warning for user certs */}
|
||||
{activeTab === 'user' && (
|
||||
<div className="border-b border-warn/20 bg-warn/5 px-4 py-2 flex items-start gap-2 shrink-0">
|
||||
<DismissibleBanner id="warn-certs-user" className="border-b border-warn/20 bg-warn/5 px-4 py-2 shrink-0 text-warn">
|
||||
<AlertTriangle size={13} className="text-warn shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-warn/80">
|
||||
<span className="font-medium">Android 7+ restricts user certs</span> — apps targeting API 24+ won't trust them by default.
|
||||
Use <span className="mono">TrustUserCerts</span> Magisk module or recompile the app's network security config to include user certs.
|
||||
</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
)}
|
||||
|
||||
{/* Cert list */}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, Wifi, WifiOff, RotateCcw, Shield, Cpu, Battery, HardDrive, Monitor } from 'lucide-react'
|
||||
import {
|
||||
GetDevices, GetDeviceInfo, EnableWirelessAdb,
|
||||
GetDevices, GetDeviceInfo, GetSecurityOverview, EnableWirelessAdb,
|
||||
ConnectWirelessAdb, DisconnectWirelessAdb, Reboot
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { Device, DeviceInfo } from '../../lib/types'
|
||||
|
||||
interface SecurityOverview {
|
||||
root: string; selinux: string; verifiedBoot: string; bootloaderLocked: string
|
||||
encryption: string; securityPatch: string; dmVerity: string; debuggable: string
|
||||
secure: string; buildType: string; buildTags: string; adbEnabled: string; devOptions: string
|
||||
}
|
||||
|
||||
export default function ViewDashboard() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [info, setInfo] = useState<DeviceInfo | null>(null)
|
||||
const [sec, setSec] = useState<SecurityOverview | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [infoLoading, setInfoLoading] = useState(false)
|
||||
const [wirelessIp, setWirelessIp] = useState('')
|
||||
|
|
@ -30,9 +37,11 @@ export default function ViewDashboard() {
|
|||
const loadDeviceInfo = useCallback(async () => {
|
||||
setInfoLoading(true)
|
||||
setInfo(null)
|
||||
setSec(null)
|
||||
try {
|
||||
const i = await GetDeviceInfo()
|
||||
const [i, s] = await Promise.all([GetDeviceInfo(), GetSecurityOverview().catch(() => null)])
|
||||
setInfo(i)
|
||||
setSec(s)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
|
|
@ -99,6 +108,26 @@ export default function ViewDashboard() {
|
|||
|
||||
const connectedDevices = devices.filter(d => d.status === 'device')
|
||||
|
||||
type Tone = 'good' | 'warn' | 'bad' | 'none'
|
||||
const toneCls: Record<Tone, string> = {
|
||||
good: 'text-accent-green', warn: 'text-warn', bad: 'text-danger', none: 'text-text-primary',
|
||||
}
|
||||
const secRows: { label: string; value: string; tone: Tone }[] = sec ? [
|
||||
{ label: 'Bootloader', value: sec.bootloaderLocked, tone: sec.bootloaderLocked === 'Locked' ? 'good' : sec.bootloaderLocked === 'Unlocked' ? 'warn' : 'none' },
|
||||
{ label: 'Root', value: sec.root, tone: sec.root.includes('su') ? 'warn' : 'good' },
|
||||
{ label: 'SELinux', value: sec.selinux, tone: /enforc/i.test(sec.selinux) ? 'good' : /permiss/i.test(sec.selinux) ? 'bad' : 'none' },
|
||||
{ label: 'Verified boot', value: sec.verifiedBoot, tone: sec.verifiedBoot === 'green' ? 'good' : (sec.verifiedBoot === 'orange' || sec.verifiedBoot === 'yellow') ? 'warn' : sec.verifiedBoot === 'red' ? 'bad' : 'none' },
|
||||
{ label: 'dm-verity', value: sec.dmVerity, tone: /enforc/i.test(sec.dmVerity) ? 'good' : /disabled|logging/i.test(sec.dmVerity) ? 'warn' : 'none' },
|
||||
{ label: 'Encryption', value: sec.encryption, tone: /^encrypted/i.test(sec.encryption) ? 'good' : /unencrypted/i.test(sec.encryption) ? 'bad' : 'none' },
|
||||
{ label: 'Security patch', value: sec.securityPatch, tone: 'none' },
|
||||
{ label: 'Build type', value: sec.buildType, tone: sec.buildType === 'user' ? 'good' : (sec.buildType === 'userdebug' || sec.buildType === 'eng') ? 'warn' : 'none' },
|
||||
{ label: 'Build tags', value: sec.buildTags, tone: /release-keys/.test(sec.buildTags) ? 'good' : /test-keys/.test(sec.buildTags) ? 'warn' : 'none' },
|
||||
{ label: 'ro.debuggable', value: sec.debuggable, tone: sec.debuggable === '1' ? 'bad' : sec.debuggable === '0' ? 'good' : 'none' },
|
||||
{ label: 'ro.secure', value: sec.secure, tone: sec.secure === '0' ? 'bad' : sec.secure === '1' ? 'good' : 'none' },
|
||||
{ label: 'ADB enabled', value: sec.adbEnabled, tone: sec.adbEnabled === '1' ? 'warn' : 'none' },
|
||||
{ label: 'Dev options', value: sec.devOptions, tone: sec.devOptions === '1' ? 'warn' : 'none' },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 h-full overflow-auto">
|
||||
{/* Header */}
|
||||
|
|
@ -189,6 +218,25 @@ export default function ViewDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Overview — quick audit */}
|
||||
{sec && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={14} className="text-accent-green" />
|
||||
<p className="section-title">Security Overview</p>
|
||||
<span className="text-[11px] text-text-muted ml-1">quick device audit</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-x-6 gap-y-2">
|
||||
{secRows.map(r => (
|
||||
<div key={r.label} className="flex items-start gap-2 min-w-0">
|
||||
<span className="text-text-muted text-xs w-24 shrink-0 pt-0.5">{r.label}</span>
|
||||
<span className={`text-xs truncate ${toneCls[r.tone]}`}>{r.value || 'N/A'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{/* Wireless ADB */}
|
||||
<div className="card p-4 space-y-3">
|
||||
|
|
|
|||
|
|
@ -1,41 +1,93 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Shield, RefreshCw, Search, Trash2, PowerOff, AlertTriangle, Check, X, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ListPackages, DisableMultiplePackages, UninstallMultiplePackages } from '../../lib/wails'
|
||||
import { Shield, RefreshCw, Search, Trash2, PowerOff, Zap, RotateCcw, AlertTriangle, Check, X, HelpCircle, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ListPackages, DisableMultiplePackages, UninstallMultiplePackages, UninstallAndDisableMultiplePackages, RestoreMultiplePackages } from '../../lib/wails'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import { notify } from '../../lib/notify'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import { DEBLOAT_CATEGORIES } from '../../lib/debloat_db'
|
||||
import type { Safety } from '../../lib/debloat_db'
|
||||
import type { PackageInfo } from '../../lib/types'
|
||||
|
||||
const SAFETY_CONFIG: Record<Safety, { label: string; cls: string; icon: React.ReactNode }> = {
|
||||
// Display safety includes 'unknown' for device packages not in the UAD database.
|
||||
type RowSafety = Safety | 'unknown'
|
||||
|
||||
const UNCATEGORIZED = 'Uncategorized'
|
||||
|
||||
const SAFETY_CONFIG: Record<RowSafety, { label: string; cls: string; icon: React.ReactNode }> = {
|
||||
safe: { label: 'Safe', cls: 'badge-green', icon: <Check size={10} /> },
|
||||
caution: { label: 'Caution', cls: 'badge-yellow', icon: <AlertTriangle size={10} /> },
|
||||
keep: { label: 'Keep', cls: 'badge-red', icon: <X size={10} /> },
|
||||
unknown: { label: 'Unknown', cls: 'badge-gray', icon: <HelpCircle size={10} /> },
|
||||
}
|
||||
|
||||
// A single package row shown in the list. Device packages are enriched from the
|
||||
// UAD database where a match exists; unmatched device packages fall into
|
||||
// 'Uncategorized' with 'unknown' safety.
|
||||
interface Row {
|
||||
pkg: string
|
||||
label: string
|
||||
description: string
|
||||
safety: RowSafety
|
||||
category: string
|
||||
deps?: string[]
|
||||
neededBy?: string[]
|
||||
isInstalled: boolean
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
// Order categories appear in: the UAD categories in their defined order, then
|
||||
// the catch-all Uncategorized group last.
|
||||
const CATEGORY_ORDER = [...DEBLOAT_CATEGORIES.map(c => c.name), UNCATEGORIZED]
|
||||
|
||||
// Derive a readable label from a bare package name for uncategorized packages,
|
||||
// e.g. "com.sec.android.app.launcher" -> "Launcher".
|
||||
function shortLabel(pkg: string): string {
|
||||
const seg = pkg.split('.').filter(Boolean).pop() || pkg
|
||||
return seg.charAt(0).toUpperCase() + seg.slice(1)
|
||||
}
|
||||
|
||||
export default function ViewDebloater() {
|
||||
const [installed, setInstalled] = useState<Set<string>>(new Set())
|
||||
const [disabled, setDisabled] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [safetyFilter, setSafety] = useState<Safety | 'all'>('all')
|
||||
const [safetyFilter, setSafety] = useState<RowSafety | 'all'>('all')
|
||||
const [mfrFilter, setMfrFilter] = useState('all')
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
||||
const [operating, setOperating] = useState(false)
|
||||
const [showNotInstalled, setShowNotInstalled] = useState(false)
|
||||
const [stateFilter, setStateFilter] = useState<'installed' | 'enabled' | 'disabled' | 'notinstalled' | 'all'>('installed')
|
||||
|
||||
// pkg -> UAD database entry (with its category). Built once; first match wins.
|
||||
const dbIndex = useMemo(() => {
|
||||
const m = new Map<string, Row>()
|
||||
for (const cat of DEBLOAT_CATEGORIES) {
|
||||
for (const p of cat.packages) {
|
||||
if (!m.has(p.pkg)) {
|
||||
m.set(p.pkg, {
|
||||
pkg: p.pkg, label: p.label, description: p.description, safety: p.safety,
|
||||
category: cat.name, deps: p.deps, neededBy: p.neededBy,
|
||||
isInstalled: false, isDisabled: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}, [])
|
||||
|
||||
const loadInstalled = async () => {
|
||||
setLoading(true)
|
||||
setInstalled(new Set())
|
||||
setDisabled(new Set())
|
||||
setSelected(new Set())
|
||||
try {
|
||||
const pkgs = await ListPackages('all')
|
||||
const names = new Set<string>((pkgs || []).map((p: PackageInfo) => p.packageName))
|
||||
setInstalled(names)
|
||||
// Auto-open categories that have installed packages
|
||||
setDisabled(new Set<string>((pkgs || []).filter((p: PackageInfo) => !p.isEnabled).map((p: PackageInfo) => p.packageName)))
|
||||
// Auto-open every category that has at least one package on the device.
|
||||
const withInstalled = new Set<string>()
|
||||
DEBLOAT_CATEGORIES.forEach(cat => {
|
||||
if (cat.packages.some(p => names.has(p.pkg))) withInstalled.add(cat.name)
|
||||
})
|
||||
names.forEach(name => withInstalled.add(dbIndex.get(name)?.category ?? UNCATEGORIZED))
|
||||
setOpenCats(withInstalled)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
|
|
@ -46,30 +98,62 @@ export default function ViewDebloater() {
|
|||
|
||||
useEffect(() => { loadInstalled() }, [])
|
||||
|
||||
const manufacturers = useMemo(() => ['all', ...DEBLOAT_CATEGORIES.map(c => c.name)], [])
|
||||
const manufacturers = useMemo(() => ['all', ...CATEGORY_ORDER], [])
|
||||
|
||||
// The unified row set: every device package (enriched or uncategorized), plus
|
||||
// database-only packages so the "Not installed" / "All" filters can browse the
|
||||
// full UAD catalogue.
|
||||
const allRows = useMemo(() => {
|
||||
const rows: Row[] = []
|
||||
installed.forEach(name => {
|
||||
const e = dbIndex.get(name)
|
||||
if (e) {
|
||||
rows.push({ ...e, isInstalled: true, isDisabled: disabled.has(name) })
|
||||
} else {
|
||||
rows.push({
|
||||
pkg: name, label: shortLabel(name),
|
||||
description: 'Not in the debloat database — likely an OEM, carrier, or region-specific package. Safety unknown; research before removing.',
|
||||
safety: 'unknown', category: UNCATEGORIZED,
|
||||
isInstalled: true, isDisabled: disabled.has(name),
|
||||
})
|
||||
}
|
||||
})
|
||||
dbIndex.forEach((e, pkg) => {
|
||||
if (!installed.has(pkg)) rows.push({ ...e, isInstalled: false, isDisabled: false })
|
||||
})
|
||||
return rows
|
||||
}, [installed, disabled, dbIndex])
|
||||
|
||||
const visibleCategories = useMemo(() => {
|
||||
return DEBLOAT_CATEGORIES
|
||||
.filter(cat => mfrFilter === 'all' || cat.name === mfrFilter)
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
packages: cat.packages.filter(p => {
|
||||
if (safetyFilter !== 'all' && p.safety !== safetyFilter) return false
|
||||
if (!showNotInstalled && !installed.has(p.pkg)) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return p.pkg.toLowerCase().includes(q) || p.label.toLowerCase().includes(q) || p.description.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}))
|
||||
.filter(cat => cat.packages.length > 0)
|
||||
}, [search, safetyFilter, mfrFilter, installed, showNotInstalled])
|
||||
const q = search.toLowerCase()
|
||||
const byCat = new Map<string, Row[]>()
|
||||
for (const r of allRows) {
|
||||
if (mfrFilter !== 'all' && r.category !== mfrFilter) continue
|
||||
if (safetyFilter !== 'all' && r.safety !== safetyFilter) continue
|
||||
switch (stateFilter) {
|
||||
case 'installed': if (!r.isInstalled) continue; break
|
||||
case 'enabled': if (!r.isInstalled || r.isDisabled) continue; break
|
||||
case 'disabled': if (!r.isDisabled) continue; break
|
||||
case 'notinstalled': if (r.isInstalled) continue; break
|
||||
// 'all' → no state restriction
|
||||
}
|
||||
if (q && !(r.pkg.toLowerCase().includes(q) || r.label.toLowerCase().includes(q) || r.description.toLowerCase().includes(q))) continue
|
||||
if (!byCat.has(r.category)) byCat.set(r.category, [])
|
||||
byCat.get(r.category)!.push(r)
|
||||
}
|
||||
return CATEGORY_ORDER
|
||||
.filter(name => byCat.has(name))
|
||||
.map(name => ({ name, packages: byCat.get(name)!.sort((a, b) => a.pkg.localeCompare(b.pkg)) }))
|
||||
}, [allRows, search, safetyFilter, mfrFilter, stateFilter])
|
||||
|
||||
const totalInstalled = useMemo(() =>
|
||||
DEBLOAT_CATEGORIES.reduce((n, cat) => n + cat.packages.filter(p => installed.has(p.pkg)).length, 0),
|
||||
[installed]
|
||||
)
|
||||
// Device counts — mirror the Packages tab (deviceCount) and explain the gap.
|
||||
const deviceCount = installed.size
|
||||
const cataloguedCount = useMemo(() => {
|
||||
let n = 0
|
||||
installed.forEach(name => { if (dbIndex.has(name)) n++ })
|
||||
return n
|
||||
}, [installed, dbIndex])
|
||||
const uncategorizedCount = deviceCount - cataloguedCount
|
||||
|
||||
const toggleCat = (name: string) => setOpenCats(prev => {
|
||||
const next = new Set(prev)
|
||||
|
|
@ -84,11 +168,8 @@ export default function ViewDebloater() {
|
|||
})
|
||||
|
||||
const selectAllVisible = () => {
|
||||
const selectable = visibleCategories
|
||||
.flatMap(c => c.packages)
|
||||
.filter(p => installed.has(p.pkg) && p.safety !== 'keep')
|
||||
.map(p => p.pkg)
|
||||
if (selected.size === selectable.length) {
|
||||
const selectable = visibleCategories.flatMap(c => c.packages).map(p => p.pkg)
|
||||
if (selected.size > 0 && selected.size >= selectable.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(selectable))
|
||||
|
|
@ -98,6 +179,7 @@ export default function ViewDebloater() {
|
|||
const batchOp = async (label: string, op: (pkgs: string[]) => Promise<string>, confirm_msg: string) => {
|
||||
if (selected.size === 0) { notify.error('Select packages first'); return }
|
||||
if (!confirm(confirm_msg)) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setOperating(true)
|
||||
const id = notify.loading(`${label} ${selected.size} package(s)...`)
|
||||
try {
|
||||
|
|
@ -120,24 +202,26 @@ export default function ViewDebloater() {
|
|||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 flex-wrap shrink-0 bg-bg-surface">
|
||||
<Shield size={14} className="text-accent-green shrink-0" />
|
||||
<span className="text-xs text-text-secondary">
|
||||
{loading ? 'Scanning device...' : `${totalInstalled} of ${DEBLOAT_CATEGORIES.reduce((n,c)=>n+c.packages.length,0)} packages found on device`}
|
||||
{loading
|
||||
? 'Scanning device...'
|
||||
: `${deviceCount} on device · ${cataloguedCount} catalogued · ${uncategorizedCount} uncategorized`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Manufacturer filter */}
|
||||
{/* Manufacturer / category filter */}
|
||||
<select
|
||||
className="input text-xs w-36 py-1"
|
||||
value={mfrFilter}
|
||||
onChange={e => setMfrFilter(e.target.value)}
|
||||
>
|
||||
{manufacturers.map(m => (
|
||||
<option key={m} value={m}>{m === 'all' ? 'All manufacturers' : m}</option>
|
||||
<option key={m} value={m}>{m === 'all' ? 'All categories' : m}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Safety filter */}
|
||||
<div className="flex gap-0.5 bg-bg-raised rounded p-0.5">
|
||||
{(['all', 'safe', 'caution', 'keep'] as const).map(f => (
|
||||
{(['all', 'safe', 'caution', 'keep', 'unknown'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setSafety(f)}
|
||||
|
|
@ -150,15 +234,18 @@ export default function ViewDebloater() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showNotInstalled}
|
||||
onChange={e => setShowNotInstalled(e.target.checked)}
|
||||
className="accent-accent-green"
|
||||
/>
|
||||
Show not installed
|
||||
</label>
|
||||
<select
|
||||
className="input text-xs"
|
||||
value={stateFilter}
|
||||
onChange={e => setStateFilter(e.target.value as typeof stateFilter)}
|
||||
title="Filter by device state"
|
||||
>
|
||||
<option value="installed">On device</option>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="notinstalled">Not installed</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
|
|
@ -177,12 +264,12 @@ export default function ViewDebloater() {
|
|||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-2 bg-warn/5 border-b border-warn/20 px-4 py-2 shrink-0">
|
||||
<DismissibleBanner id="warn-debloater" className="bg-warn/5 border-b border-warn/20 px-4 py-2 shrink-0 text-warn">
|
||||
<AlertTriangle size={13} className="text-warn shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-warn/80">
|
||||
<span className="font-medium">Always prefer Disable over Uninstall.</span> Never remove packages marked <span className="text-danger font-medium">Keep</span> — they will break your device. Source: Universal Android Debloater (UAD-ng), 2157 packages.
|
||||
<span className="font-medium">Always prefer Disable over Uninstall.</span> Packages marked <span className="text-danger font-medium">Keep</span> are device-critical — removing them can break your device. <span className="font-medium">Uncategorized</span> packages aren't in the debloat database; research before removing. Safety data: Universal Android Debloater (UAD-ng).
|
||||
</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
{/* Action bar */}
|
||||
{selected.size > 0 && (
|
||||
|
|
@ -199,12 +286,28 @@ export default function ViewDebloater() {
|
|||
</button>
|
||||
<button
|
||||
onClick={() => batchOp('Uninstalling', UninstallMultiplePackages,
|
||||
`Uninstall ${selected.size} package(s) for current user?\n\nUses pm uninstall -k --user 0. Package stays on system but is removed for your user.\nReversible via re-enable or factory reset.`)}
|
||||
`Uninstall ${selected.size} package(s) for current user?\n\nUses pm uninstall --user 0 (protected system apps fall back to a privileged on-device helper).\nReversible via re-enable or factory reset.`)}
|
||||
disabled={operating}
|
||||
className="btn-danger text-xs"
|
||||
>
|
||||
<Trash2 size={12} /> Uninstall for user ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => batchOp('Disabling + uninstalling', UninstallAndDisableMultiplePackages,
|
||||
`Disable AND uninstall ${selected.size} package(s)?\n\nForce-stops + disables each app (pm disable-user --user 0), then uninstalls it (privileged fallback for protected system apps).\nIf an app can't be removed it is left disabled.\nReversible via re-enable or factory reset.`)}
|
||||
disabled={operating}
|
||||
className="btn-danger text-xs"
|
||||
>
|
||||
<Zap size={12} /> Disable + Uninstall ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => batchOp('Restoring', RestoreMultiplePackages,
|
||||
`Restore ${selected.size} package(s)?\n\nReinstalls for your user (cmd package install-existing --user 0) and re-enables (pm enable --user 0).\nBrings back apps that were disabled or uninstalled-for-user.`)}
|
||||
disabled={operating}
|
||||
className="btn-ghost text-xs text-accent-green"
|
||||
>
|
||||
<RotateCcw size={12} /> Restore ({selected.size})
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="btn-ghost text-xs">
|
||||
Clear
|
||||
</button>
|
||||
|
|
@ -222,14 +325,14 @@ export default function ViewDebloater() {
|
|||
<div className="flex flex-col items-center justify-center h-32 gap-2 text-text-muted">
|
||||
<Shield size={24} className="opacity-30" />
|
||||
<p className="text-sm">No packages match current filters</p>
|
||||
{!showNotInstalled && totalInstalled === 0 && (
|
||||
{stateFilter !== 'notinstalled' && deviceCount === 0 && (
|
||||
<p className="text-xs">Try clicking "Scan" to detect installed packages</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && visibleCategories.map(cat => {
|
||||
const installedCount = cat.packages.filter(p => installed.has(p.pkg)).length
|
||||
const installedCount = cat.packages.filter(p => p.isInstalled).length
|
||||
const isOpen = openCats.has(cat.name)
|
||||
|
||||
return (
|
||||
|
|
@ -255,7 +358,8 @@ export default function ViewDebloater() {
|
|||
|
||||
{/* Packages */}
|
||||
{isOpen && cat.packages.map(p => {
|
||||
const isInst = installed.has(p.pkg)
|
||||
const isInst = p.isInstalled
|
||||
const isDisabled = p.isDisabled
|
||||
const isSel = selected.has(p.pkg)
|
||||
const safety = SAFETY_CONFIG[p.safety]
|
||||
|
||||
|
|
@ -264,15 +368,15 @@ export default function ViewDebloater() {
|
|||
key={p.pkg}
|
||||
className={`
|
||||
flex items-start gap-3 px-4 py-2 border-t border-bg-border/30 transition-colors
|
||||
${isInst ? 'hover:bg-bg-raised cursor-pointer' : 'opacity-40'}
|
||||
hover:bg-bg-raised cursor-pointer
|
||||
${!isInst ? 'opacity-60' : ''}
|
||||
${isSel ? 'bg-accent-green/5' : ''}
|
||||
`}
|
||||
onClick={() => isInst && p.safety !== 'keep' && toggleSelect(p.pkg)}
|
||||
onClick={() => toggleSelect(p.pkg)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSel}
|
||||
disabled={!isInst || p.safety === 'keep'}
|
||||
onChange={() => toggleSelect(p.pkg)}
|
||||
className="accent-accent-green mt-0.5 shrink-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
|
|
@ -284,6 +388,7 @@ export default function ViewDebloater() {
|
|||
{safety.icon} {safety.label}
|
||||
</span>
|
||||
{!isInst && <span className="badge-gray text-xs">not on device</span>}
|
||||
{isInst && isDisabled && <span className="badge-yellow text-xs">disabled</span>}
|
||||
{p.deps && p.deps.length > 0 && (
|
||||
<span className="badge-gray text-xs" title={`Depends on: ${p.deps.join(', ')}`}>has deps</span>
|
||||
)}
|
||||
|
|
@ -304,9 +409,9 @@ export default function ViewDebloater() {
|
|||
|
||||
{/* Status bar */}
|
||||
<div className="border-t border-bg-border px-4 py-1.5 flex items-center justify-between text-xs text-text-muted shrink-0">
|
||||
<span>{totalInstalled} debloat candidates on device · {DEBLOAT_CATEGORIES.reduce((n,c)=>n+c.packages.length,0)} total in database</span>
|
||||
<span>{deviceCount} on device · {cataloguedCount} catalogued · {uncategorizedCount} uncategorized · {DEBLOAT_CATEGORIES.reduce((n,c)=>n+c.packages.length,0)} in database</span>
|
||||
<button onClick={selectAllVisible} className="hover:text-text-secondary transition-colors">
|
||||
{selected.size > 0 ? 'Deselect all' : 'Select all safe+caution'}
|
||||
{selected.size > 0 ? 'Deselect all' : 'Select all visible'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,31 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
FolderOpen, File, ArrowLeft, RefreshCw, Upload,
|
||||
Download, Trash2, FolderPlus, Edit3, Copy
|
||||
FolderOpen, File, ArrowLeft, ArrowRight, ArrowUp, RefreshCw, Upload,
|
||||
Download, Trash2, FolderPlus, Edit3, Copy, FolderInput, Smartphone, Monitor,
|
||||
Image as ImageIcon, X, ChevronLeft, ChevronRight
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
ListFiles, PushFile, PullMultipleFiles, DeleteMultipleFiles,
|
||||
CreateFolder, RenameFile, CopyFile, SelectFileForPush, CancelOperation
|
||||
ListFiles, ListLocalFiles, HomeDir, PushWithProgress, PushPathsWithProgress,
|
||||
PullPathsWithProgress, DeleteMultipleFiles, CreateFolder, RenameFile,
|
||||
SelectFileForPush, CancelOperation
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { CodeView, detectLang } from '../../lib/syntax'
|
||||
import type { FileEntry } from '../../lib/types'
|
||||
|
||||
// Wails runtime is injected on window['runtime'] (same access as ViewLogcat).
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
type Source = 'device' | 'local'
|
||||
interface Transfer { kind: string; label: string; percent: number }
|
||||
interface Menu { x: number; y: number; entry: FileEntry }
|
||||
interface Nav { stack: string[]; idx: number }
|
||||
|
||||
export default function ViewFiles() {
|
||||
const [path, setPath] = useState('/sdcard')
|
||||
const [pathInput, setPathInput] = useState('/sdcard')
|
||||
const [source, setSource] = useState<Source>('device')
|
||||
const [nav, setNav] = useState<Nav>({ stack: ['/sdcard'], idx: 0 })
|
||||
const path = nav.stack[nav.idx]
|
||||
const [pathInput, setPathInput] = useState(path)
|
||||
const [files, setFiles] = useState<FileEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
|
@ -20,12 +33,31 @@ export default function ViewFiles() {
|
|||
const [renameValue, setRenameValue] = useState('')
|
||||
const [newFolder, setNewFolder] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
const [menu, setMenu] = useState<Menu | null>(null)
|
||||
const [moving, setMoving] = useState<FileEntry | null>(null)
|
||||
const [moveDest, setMoveDest] = useState('')
|
||||
const [pushStaged, setPushStaged] = useState<string[] | null>(null)
|
||||
const [transfer, setTransfer] = useState<Transfer | null>(null)
|
||||
const [eta, setEta] = useState('')
|
||||
const [viewer, setViewer] = useState<string | null>(null) // image filename being viewed
|
||||
const [imgLoading, setImgLoading] = useState(false)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const [textView, setTextView] = useState<{ name: string; content: string } | null>(null)
|
||||
const [textLoading, setTextLoading] = useState(false)
|
||||
const progRef = useRef<{ label: string; t0: number } | null>(null)
|
||||
// Remembered path per source + the last device dir (push destination default).
|
||||
const remembered = useRef<Record<Source, string>>({ device: '/sdcard', local: '' })
|
||||
|
||||
const loadFiles = useCallback(async (p: string) => {
|
||||
const fullPath = useCallback(
|
||||
(name: string) => (path.endsWith('/') ? path + name : path + '/' + name),
|
||||
[path]
|
||||
)
|
||||
|
||||
const loadFiles = useCallback(async (p: string, src: Source) => {
|
||||
setLoading(true)
|
||||
setSelected(new Set())
|
||||
try {
|
||||
const result = await ListFiles(p)
|
||||
const result = await (src === 'device' ? ListFiles(p) : ListLocalFiles(p))
|
||||
setFiles(result || [])
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
|
|
@ -35,29 +67,68 @@ export default function ViewFiles() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadFiles(path) }, [path, loadFiles])
|
||||
useEffect(() => { loadFiles(path, source) }, [path, source, loadFiles])
|
||||
useEffect(() => { setPathInput(path) }, [path])
|
||||
|
||||
// Live push/pull progress + ETA, computed from percent over elapsed time.
|
||||
useEffect(() => {
|
||||
const onProgress = (t: Transfer) => {
|
||||
const now = performance.now()
|
||||
if (!progRef.current || progRef.current.label !== t.label || t.percent === 0) {
|
||||
progRef.current = { label: t.label, t0: now }
|
||||
}
|
||||
const elapsed = now - progRef.current.t0
|
||||
if (t.percent > 0 && t.percent < 100) {
|
||||
const total = elapsed / (t.percent / 100)
|
||||
setEta(formatEta(Math.max(0, total - elapsed)))
|
||||
} else {
|
||||
setEta('')
|
||||
}
|
||||
setTransfer(t)
|
||||
}
|
||||
const onDone = () => { setTransfer(null); setEta(''); progRef.current = null }
|
||||
const off1 = rt()?.EventsOn?.('transfer:progress', onProgress)
|
||||
const off2 = rt()?.EventsOn?.('transfer:done', onDone)
|
||||
return () => { off1?.(); off2?.() }
|
||||
}, [])
|
||||
|
||||
// Seed the Computer browser's starting path with the user's home directory.
|
||||
useEffect(() => {
|
||||
HomeDir().then((h: string) => { if (h) remembered.current.local = h }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Navigate to a new path (pushes onto history, truncating any forward entries).
|
||||
const go = (to: string) => {
|
||||
setNav(n => {
|
||||
if (n.stack[n.idx] === to) return n
|
||||
const stack = n.stack.slice(0, n.idx + 1)
|
||||
stack.push(to)
|
||||
return { stack, idx: stack.length - 1 }
|
||||
})
|
||||
}
|
||||
const back = () => setNav(n => (n.idx > 0 ? { ...n, idx: n.idx - 1 } : n))
|
||||
const forward = () => setNav(n => (n.idx < n.stack.length - 1 ? { ...n, idx: n.idx + 1 } : n))
|
||||
|
||||
const switchSource = (s: Source) => {
|
||||
if (s === source) return
|
||||
remembered.current[source] = path
|
||||
const target = remembered.current[s] || (s === 'local' ? '/' : '/sdcard')
|
||||
setSource(s)
|
||||
setNav({ stack: [target], idx: 0 })
|
||||
}
|
||||
|
||||
const navigate = (entry: FileEntry) => {
|
||||
if (entry.type === 'Directory') {
|
||||
const next = path.endsWith('/') ? path + entry.name : path + '/' + entry.name
|
||||
setPath(next)
|
||||
setPathInput(next)
|
||||
}
|
||||
if (entry.type === 'Directory' || entry.type === 'Symlink') go(fullPath(entry.name))
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (parts.length === 0) return
|
||||
parts.pop()
|
||||
const next = '/' + parts.join('/')
|
||||
setPath(next || '/')
|
||||
setPathInput(next || '/')
|
||||
go('/' + parts.join('/') || '/')
|
||||
}
|
||||
|
||||
const navigatePath = () => {
|
||||
setPath(pathInput)
|
||||
loadFiles(pathInput)
|
||||
}
|
||||
const navigatePath = () => go(pathInput)
|
||||
|
||||
const toggleSelect = (name: string) => {
|
||||
setSelected(prev => {
|
||||
|
|
@ -68,71 +139,78 @@ export default function ViewFiles() {
|
|||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (selected.size === files.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(files.map(f => f.name)))
|
||||
}
|
||||
if (selected.size === files.length) setSelected(new Set())
|
||||
else setSelected(new Set(files.map(f => f.name)))
|
||||
}
|
||||
|
||||
// ── Device-mode actions ──
|
||||
const handlePush = async () => {
|
||||
const local = await SelectFileForPush()
|
||||
if (!local) return
|
||||
const id = notify.loading('Pushing file...')
|
||||
try {
|
||||
const out = await PushFile(local, path)
|
||||
notify.dismiss(id)
|
||||
const out = await PushWithProgress(local, path)
|
||||
notify.success(out || 'File pushed')
|
||||
loadFiles(path)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePull = async () => {
|
||||
if (selected.size === 0) { notify.error('Select files to pull'); return }
|
||||
const paths = [...selected].map(name =>
|
||||
path.endsWith('/') ? path + name : path + '/' + name
|
||||
)
|
||||
const id = notify.loading(`Pulling ${paths.length} item(s)...`)
|
||||
const pull = async (paths: string[]) => {
|
||||
if (paths.length === 0) { notify.error('Select files to pull'); return }
|
||||
try {
|
||||
const out = await PullMultipleFiles(paths)
|
||||
notify.dismiss(id)
|
||||
const out = await PullPathsWithProgress(paths)
|
||||
notify.success(out)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selected.size === 0) { notify.error('Select files to delete'); return }
|
||||
const paths = [...selected].map(name =>
|
||||
path.endsWith('/') ? path + name : path + '/' + name
|
||||
)
|
||||
const del = async (paths: string[]) => {
|
||||
if (paths.length === 0) { notify.error('Select files to delete'); return }
|
||||
if (!confirm(`Delete ${paths.length} item(s)?`)) return
|
||||
const id = notify.loading('Deleting...')
|
||||
try {
|
||||
const out = await DeleteMultipleFiles(paths)
|
||||
notify.dismiss(id)
|
||||
notify.success(out)
|
||||
loadFiles(path)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local→device push: stage the files, then flip to the Device browser so
|
||||
// the user picks the destination folder visually and clicks "Push here". ──
|
||||
const startPush = (localPaths: string[]) => {
|
||||
if (localPaths.length === 0) { notify.error('Select files to push'); return }
|
||||
setPushStaged(localPaths)
|
||||
remembered.current.local = path
|
||||
setSource('device')
|
||||
setNav({ stack: [remembered.current.device || '/sdcard'], idx: 0 })
|
||||
}
|
||||
const handlePushHere = async () => {
|
||||
if (!pushStaged) return
|
||||
const files = pushStaged
|
||||
setPushStaged(null)
|
||||
try {
|
||||
const out = await PushPathsWithProgress(files, path)
|
||||
notify.success(out)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim()) return
|
||||
const fullPath = path.endsWith('/') ? path + newFolderName : path + '/' + newFolderName
|
||||
try {
|
||||
await CreateFolder(fullPath)
|
||||
await CreateFolder(fullPath(newFolderName))
|
||||
notify.success('Folder created')
|
||||
setNewFolder(false)
|
||||
setNewFolderName('')
|
||||
loadFiles(path)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
|
|
@ -148,18 +226,100 @@ export default function ViewFiles() {
|
|||
setRenaming(null)
|
||||
return
|
||||
}
|
||||
const oldPath = path.endsWith('/') ? path + renaming : path + '/' + renaming
|
||||
const newPath = path.endsWith('/') ? path + renameValue : path + '/' + renameValue
|
||||
try {
|
||||
await RenameFile(oldPath, newPath)
|
||||
await RenameFile(fullPath(renaming), fullPath(renameValue))
|
||||
notify.success('Renamed')
|
||||
setRenaming(null)
|
||||
loadFiles(path)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = async () => {
|
||||
if (!moving) return
|
||||
const destDir = moveDest.trim().replace(/\/+$/, '')
|
||||
if (!destDir) { setMoving(null); return }
|
||||
try {
|
||||
await RenameFile(fullPath(moving.name), destDir + '/' + moving.name)
|
||||
notify.success(`Moved to ${destDir}`)
|
||||
setMoving(null)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const copyPath = (entry: FileEntry) => {
|
||||
navigator.clipboard?.writeText(fullPath(entry.name))
|
||||
notify.success('Path copied')
|
||||
}
|
||||
|
||||
// ── Image viewer — streams bytes via the /__file asset-server route (no
|
||||
// base64 size limits). The <img> loads the URL itself. ──
|
||||
const fileURL = useCallback(
|
||||
(name: string) => `/__file?src=${source}&p=${encodeURIComponent(fullPath(name))}`,
|
||||
[source, fullPath]
|
||||
)
|
||||
const openViewer = (name: string) => { setViewer(name); setImgLoading(true); setImgError(false) }
|
||||
|
||||
// Text preview: fetch the file's bytes via the same /__file route the image
|
||||
// viewer uses (works for device and local), cap the size, and show highlighted.
|
||||
const openText = async (name: string) => {
|
||||
setTextView({ name, content: '' })
|
||||
setTextLoading(true)
|
||||
try {
|
||||
const res = await fetch(fileURL(name))
|
||||
let t = await res.text()
|
||||
if (t.length > 400000) t = t.slice(0, 400000) + '\n\n… (truncated at 400 KB)'
|
||||
setTextView({ name, content: t })
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
setTextView(null)
|
||||
} finally {
|
||||
setTextLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc closes the text viewer.
|
||||
useEffect(() => {
|
||||
if (!textView) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setTextView(null) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [textView])
|
||||
|
||||
const stepViewer = useCallback((delta: number) => {
|
||||
setViewer(cur => {
|
||||
if (!cur) return cur
|
||||
const imgs = files.filter(f => isImage(f.name)).map(f => f.name)
|
||||
const i = imgs.indexOf(cur)
|
||||
if (i < 0) return cur
|
||||
setImgLoading(true)
|
||||
setImgError(false)
|
||||
return imgs[(i + delta + imgs.length) % imgs.length]
|
||||
})
|
||||
}, [files])
|
||||
|
||||
// Esc to close, arrows to step through images while the viewer is open.
|
||||
useEffect(() => {
|
||||
if (!viewer) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setViewer(null)
|
||||
if (e.key === 'ArrowRight') stepViewer(1)
|
||||
if (e.key === 'ArrowLeft') stepViewer(-1)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [viewer, stepViewer])
|
||||
|
||||
// Double-click / Open: directories navigate, images open the viewer.
|
||||
const open = (entry: FileEntry) => {
|
||||
if (entry.type === 'Directory' || entry.type === 'Symlink') navigate(entry)
|
||||
else if (isImage(entry.name)) openViewer(entry.name)
|
||||
else if (isText(entry.name)) openText(entry.name)
|
||||
}
|
||||
|
||||
const formatSize = (size: string) => {
|
||||
const n = parseInt(size)
|
||||
if (isNaN(n)) return size
|
||||
|
|
@ -168,43 +328,90 @@ export default function ViewFiles() {
|
|||
return `${(n / 1048576).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const isDevice = source === 'device'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full" onClick={() => menu && setMenu(null)}>
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 shrink-0">
|
||||
<button onClick={goUp} className="btn-ghost p-1.5" title="Go up">
|
||||
{/* Source toggle */}
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => switchSource('device')}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium flex items-center gap-1 transition-colors ${
|
||||
isDevice ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Smartphone size={12} /> Device
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchSource('local')}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium flex items-center gap-1 transition-colors ${
|
||||
!isDevice ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Monitor size={12} /> Computer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={back} disabled={nav.idx === 0} className="btn-ghost p-1.5" title="Back">
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<button onClick={forward} disabled={nav.idx === nav.stack.length - 1} className="btn-ghost p-1.5" title="Forward">
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
<button onClick={goUp} disabled={path === '/'} className="btn-ghost p-1.5" title="Up">
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<input
|
||||
className="input flex-1 text-xs mono"
|
||||
value={pathInput}
|
||||
onChange={e => setPathInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && navigatePath()}
|
||||
placeholder="/sdcard"
|
||||
placeholder={isDevice ? '/sdcard' : '/home'}
|
||||
/>
|
||||
<button onClick={() => loadFiles(path)} disabled={loading} className="btn-ghost p-1.5">
|
||||
<button onClick={() => loadFiles(path, source)} disabled={loading} className="btn-ghost p-1.5">
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-bg-border mx-1" />
|
||||
|
||||
<button onClick={handlePush} className="btn-ghost text-xs">
|
||||
<Upload size={13} /> Push
|
||||
</button>
|
||||
<button onClick={handlePull} disabled={selected.size === 0} className="btn-ghost text-xs">
|
||||
<Download size={13} /> Pull {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
<button onClick={() => setNewFolder(true)} className="btn-ghost text-xs">
|
||||
<FolderPlus size={13} /> New Folder
|
||||
</button>
|
||||
<button onClick={handleDelete} disabled={selected.size === 0} className="btn-danger text-xs">
|
||||
<Trash2 size={13} /> Delete {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
{loading && (
|
||||
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
|
||||
{isDevice ? (
|
||||
<>
|
||||
<button onClick={handlePush} className="btn-ghost text-xs">
|
||||
<Upload size={13} /> Push
|
||||
</button>
|
||||
<button onClick={() => pull([...selected].map(fullPath))} disabled={selected.size === 0} className="btn-ghost text-xs">
|
||||
<Download size={13} /> Pull {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
<button onClick={() => setNewFolder(true)} className="btn-ghost text-xs">
|
||||
<FolderPlus size={13} /> New Folder
|
||||
</button>
|
||||
<button onClick={() => del([...selected].map(fullPath))} disabled={selected.size === 0} className="btn-danger text-xs">
|
||||
<Trash2 size={13} /> Delete {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => startPush([...selected].map(fullPath))} disabled={selected.size === 0} className="btn-ghost text-xs">
|
||||
<Upload size={13} /> Push to device {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transfer progress bar */}
|
||||
{transfer && (
|
||||
<div className="border-b border-bg-border px-4 py-2 bg-bg-raised flex items-center gap-3 shrink-0">
|
||||
{transfer.kind === 'pull' ? <Download size={13} className="text-accent-green shrink-0" /> : <Upload size={13} className="text-accent-green shrink-0" />}
|
||||
<span className="text-xs text-text-secondary truncate max-w-[200px]" title={transfer.label}>{transfer.label}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-bg-border overflow-hidden">
|
||||
<div className="h-full bg-accent-green transition-all duration-200" style={{ width: `${transfer.percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-muted mono w-10 text-right">{transfer.percent}%</span>
|
||||
{eta && <span className="text-xs text-text-muted w-20 text-right">~{eta} left</span>}
|
||||
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New folder input */}
|
||||
{newFolder && (
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-raised">
|
||||
|
|
@ -225,6 +432,40 @@ export default function ViewFiles() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Move dialog (device) */}
|
||||
{moving && (
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-raised">
|
||||
<FolderInput size={13} className="text-accent-green" />
|
||||
<span className="text-xs text-text-muted shrink-0">Move <span className="text-text-secondary">{moving.name}</span> to:</span>
|
||||
<input
|
||||
autoFocus
|
||||
className="input flex-1 text-xs mono"
|
||||
placeholder="/sdcard/Destination"
|
||||
value={moveDest}
|
||||
onChange={e => setMoveDest(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleMove()
|
||||
if (e.key === 'Escape') setMoving(null)
|
||||
}}
|
||||
/>
|
||||
<button onClick={handleMove} className="btn-primary text-xs">Move</button>
|
||||
<button onClick={() => setMoving(null)} className="btn-ghost text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Push destination picker — shown after staging local files for push */}
|
||||
{pushStaged && isDevice && (
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 bg-accent-green/10">
|
||||
<Upload size={13} className="text-accent-green shrink-0" />
|
||||
<span className="text-xs text-text-secondary flex-1">
|
||||
Pushing {pushStaged.length} item(s) — browse to a destination folder, then push.
|
||||
</span>
|
||||
<span className="text-xs text-text-muted mono truncate max-w-[260px]">→ {path}</span>
|
||||
<button onClick={handlePushHere} className="btn-primary text-xs">Push here</button>
|
||||
<button onClick={() => setPushStaged(null)} className="btn-ghost text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list header */}
|
||||
<div className="grid grid-cols-[24px_1fr_80px_100px_120px] gap-2 px-4 py-1.5 border-b border-bg-border text-text-muted text-xs">
|
||||
<input
|
||||
|
|
@ -254,6 +495,7 @@ export default function ViewFiles() {
|
|||
{files.map(f => (
|
||||
<div
|
||||
key={f.name}
|
||||
onContextMenu={e => { e.preventDefault(); setMenu({ x: e.clientX, y: e.clientY, entry: f }) }}
|
||||
className={`
|
||||
grid grid-cols-[24px_1fr_80px_100px_120px] gap-2 px-4 py-1.5
|
||||
text-xs border-b border-bg-border/50 items-center
|
||||
|
|
@ -272,7 +514,9 @@ export default function ViewFiles() {
|
|||
<div className="flex items-center gap-2 min-w-0">
|
||||
{f.type === 'Directory'
|
||||
? <FolderOpen size={13} className="text-accent-green shrink-0" />
|
||||
: <File size={13} className="text-text-muted shrink-0" />
|
||||
: isImage(f.name)
|
||||
? <ImageIcon size={13} className="text-accent-green/70 shrink-0" />
|
||||
: <File size={13} className="text-text-muted shrink-0" />
|
||||
}
|
||||
{renaming === f.name ? (
|
||||
<input
|
||||
|
|
@ -288,19 +532,22 @@ export default function ViewFiles() {
|
|||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`truncate cursor-pointer ${f.type === 'Directory' ? 'text-text-primary' : 'text-text-secondary'}`}
|
||||
onDoubleClick={() => navigate(f)}
|
||||
className={`truncate cursor-pointer select-none hover:underline ${f.type === 'Directory' ? 'text-text-primary' : 'text-text-secondary'}`}
|
||||
onClick={() => open(f)}
|
||||
title={f.type === 'Directory' || f.type === 'Symlink' ? 'Open' : (isImage(f.name) ? 'Preview' : undefined)}
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => startRename(f.name)}
|
||||
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-secondary ml-auto shrink-0"
|
||||
title="Rename"
|
||||
>
|
||||
<Edit3 size={11} />
|
||||
</button>
|
||||
{isDevice && (
|
||||
<button
|
||||
onClick={() => startRename(f.name)}
|
||||
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-secondary ml-auto shrink-0"
|
||||
title="Rename"
|
||||
>
|
||||
<Edit3 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-right text-text-muted mono">
|
||||
|
|
@ -312,11 +559,143 @@ export default function ViewFiles() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Right-click context menu */}
|
||||
{menu && (
|
||||
<div
|
||||
className="fixed z-50 min-w-[170px] py-1 rounded-md border border-bg-border bg-bg-surface shadow-lg text-xs"
|
||||
style={{ top: menu.y, left: menu.x }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{(menu.entry.type === 'Directory' || menu.entry.type === 'Symlink') && (
|
||||
<MenuItem icon={<FolderOpen size={13} />} label="Open" onClick={() => { navigate(menu.entry); setMenu(null) }} />
|
||||
)}
|
||||
{isImage(menu.entry.name) && (
|
||||
<MenuItem icon={<ImageIcon size={13} />} label="Preview" onClick={() => { openViewer(menu.entry.name); setMenu(null) }} />
|
||||
)}
|
||||
{isDevice ? (
|
||||
<>
|
||||
<MenuItem icon={<Download size={13} />} label="Pull to folder…" onClick={() => { pull([fullPath(menu.entry.name)]); setMenu(null) }} />
|
||||
<MenuItem icon={<Edit3 size={13} />} label="Rename" onClick={() => { startRename(menu.entry.name); setMenu(null) }} />
|
||||
<MenuItem icon={<FolderInput size={13} />} label="Move to…" onClick={() => { setMoving(menu.entry); setMoveDest(path); setMenu(null) }} />
|
||||
<MenuItem icon={<Copy size={13} />} label="Copy path" onClick={() => { copyPath(menu.entry); setMenu(null) }} />
|
||||
<div className="my-1 h-px bg-bg-border" />
|
||||
<MenuItem icon={<Trash2 size={13} />} label="Delete" danger onClick={() => { del([fullPath(menu.entry.name)]); setMenu(null) }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{menu.entry.type === 'File' && (
|
||||
<MenuItem icon={<Upload size={13} />} label="Push to device…" onClick={() => { startPush([fullPath(menu.entry.name)]); setMenu(null) }} />
|
||||
)}
|
||||
<MenuItem icon={<Copy size={13} />} label="Copy path" onClick={() => { copyPath(menu.entry); setMenu(null) }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image viewer — click anywhere (except the image or buttons) to close */}
|
||||
{viewer && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/85 backdrop-blur-sm"
|
||||
onClick={() => setViewer(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs text-text-secondary shrink-0">
|
||||
<span className="mono truncate">{viewer}</span>
|
||||
<button onClick={e => { e.stopPropagation(); setViewer(null) }} className="btn-ghost p-1.5" title="Close (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center gap-3 overflow-hidden px-2 pb-4">
|
||||
<button onClick={e => { e.stopPropagation(); stepViewer(-1) }} className="btn-ghost p-2 shrink-0" title="Previous (←)">
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="relative flex-1 h-full flex items-center justify-center overflow-hidden">
|
||||
{imgLoading && !imgError && (
|
||||
<div className="absolute w-6 h-6 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{imgError ? (
|
||||
<p className="text-text-muted text-sm">Couldn't load this image</p>
|
||||
) : (
|
||||
<img
|
||||
src={fileURL(viewer)}
|
||||
alt={viewer}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onLoad={() => setImgLoading(false)}
|
||||
onError={() => { setImgLoading(false); setImgError(true) }}
|
||||
className="max-h-full max-w-full object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={e => { e.stopPropagation(); stepViewer(1) }} className="btn-ghost p-2 shrink-0" title="Next (→)">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text viewer — highlighted preview for text/code/config files */}
|
||||
{textView && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-black/85 backdrop-blur-sm" onClick={() => setTextView(null)}>
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs text-text-secondary shrink-0">
|
||||
<span className="mono truncate">{textView.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); setTextView(null) }} className="btn-ghost p-1.5" title="Close (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden px-4 pb-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="h-full overflow-auto bg-bg-surface border border-bg-border rounded">
|
||||
{textLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<CodeView
|
||||
code={textView.content}
|
||||
lang={detectLang(textView.name, textView.content)}
|
||||
className="mono text-xs text-text-secondary whitespace-pre-wrap break-words leading-relaxed p-3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="border-t border-bg-border px-4 py-1.5 flex items-center justify-between text-xs text-text-muted">
|
||||
<span className="mono">{path}</span>
|
||||
<span className="mono flex items-center gap-1.5">
|
||||
{isDevice ? <Smartphone size={12} /> : <Monitor size={12} />}
|
||||
{path}
|
||||
</span>
|
||||
<span>{files.length} items{selected.size > 0 ? `, ${selected.size} selected` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: {
|
||||
icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-bg-raised ${
|
||||
danger ? 'text-danger' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function formatEta(ms: number): string {
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
||||
}
|
||||
|
||||
function isImage(name: string): boolean {
|
||||
return /\.(png|jpe?g|gif|webp|bmp|ico)$/i.test(name)
|
||||
}
|
||||
|
||||
function isText(name: string): boolean {
|
||||
return /\.(txt|xml|json|prop|conf|cfg|ini|env|log|sh|bash|rc|smali|java|kt|gradle|ya?ml|md|csv|html?|css|js|ts|toml|properties|list)$/i.test(name)
|
||||
}
|
||||
|
|
|
|||
309
frontend/src/components/views/ViewFirmware.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Download, Search, ShieldCheck, X, PackageOpen, FileCheck2 } from 'lucide-react'
|
||||
import { ListFirmware, DownloadFirmware, CancelOperation, SelectFileForFlash, ListPayloadPartitions, ExtractPayloadPartition, SelectAnyFile, HashFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
interface Firmware { version: string; url: string; sha256: string }
|
||||
interface PayloadPartition { name: string; sizeMB: number }
|
||||
interface FileHashes { sha256: string; sha1: string; sizeBytes: number }
|
||||
|
||||
// Pixel device codenames (newest first). Value = codename used by Google's images.
|
||||
const PIXEL_DEVICES: { name: string; cn: string }[] = [
|
||||
{ name: 'Pixel 10 Pro Fold', cn: 'rango' },
|
||||
{ name: 'Pixel 10 Pro XL', cn: 'mustang' },
|
||||
{ name: 'Pixel 10 Pro', cn: 'blazer' },
|
||||
{ name: 'Pixel 10', cn: 'frankel' },
|
||||
{ name: 'Pixel 9a', cn: 'tegu' },
|
||||
{ name: 'Pixel 9 Pro Fold', cn: 'comet' },
|
||||
{ name: 'Pixel 9 Pro XL', cn: 'komodo' },
|
||||
{ name: 'Pixel 9 Pro', cn: 'caiman' },
|
||||
{ name: 'Pixel 9', cn: 'tokay' },
|
||||
{ name: 'Pixel 8a', cn: 'akita' },
|
||||
{ name: 'Pixel 8 Pro', cn: 'husky' },
|
||||
{ name: 'Pixel 8', cn: 'shiba' },
|
||||
{ name: 'Pixel Fold', cn: 'felix' },
|
||||
{ name: 'Pixel Tablet', cn: 'tangorpro' },
|
||||
{ name: 'Pixel 7a', cn: 'lynx' },
|
||||
{ name: 'Pixel 7 Pro', cn: 'cheetah' },
|
||||
{ name: 'Pixel 7', cn: 'panther' },
|
||||
{ name: 'Pixel 6a', cn: 'bluejay' },
|
||||
{ name: 'Pixel 6 Pro', cn: 'raven' },
|
||||
{ name: 'Pixel 6', cn: 'oriole' },
|
||||
{ name: 'Pixel 5a', cn: 'barbet' },
|
||||
{ name: 'Pixel 5', cn: 'redfin' },
|
||||
{ name: 'Pixel 4a 5G', cn: 'bramble' },
|
||||
{ name: 'Pixel 4a', cn: 'sunfish' },
|
||||
{ name: 'Pixel 4 XL', cn: 'coral' },
|
||||
{ name: 'Pixel 4', cn: 'flame' },
|
||||
{ name: 'Pixel 3a XL', cn: 'bonito' },
|
||||
{ name: 'Pixel 3a', cn: 'sargo' },
|
||||
{ name: 'Pixel 3 XL', cn: 'crosshatch' },
|
||||
{ name: 'Pixel 3', cn: 'blueline' },
|
||||
]
|
||||
|
||||
export default function ViewFirmware({ codename }: { codename?: string }) {
|
||||
const [cn, setCn] = useState(codename || 'husky')
|
||||
const [custom, setCustom] = useState(false)
|
||||
const [kind, setKind] = useState<'factory' | 'ota'>('factory')
|
||||
const [list, setList] = useState<Firmware[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [percent, setPercent] = useState(0)
|
||||
const [eta, setEta] = useState('')
|
||||
const t0 = useRef(0)
|
||||
// payload.bin extraction
|
||||
const [otaZip, setOtaZip] = useState('')
|
||||
const [parts, setParts] = useState<PayloadPartition[] | null>(null)
|
||||
const [partsBusy, setPartsBusy] = useState(false)
|
||||
const [extracting, setExtracting] = useState('')
|
||||
const [extractPct, setExtractPct] = useState(0)
|
||||
// verify a file
|
||||
const [vName, setVName] = useState('')
|
||||
const [vHashes, setVHashes] = useState<FileHashes | null>(null)
|
||||
const [vExpected, setVExpected] = useState('')
|
||||
const [vBusy, setVBusy] = useState(false)
|
||||
|
||||
useEffect(() => { if (codename) setCn(codename) }, [codename]) // prefill from connected device
|
||||
|
||||
// If the detected codename isn't a known Pixel, still offer it in the list.
|
||||
const known = PIXEL_DEVICES.some(d => d.cn === cn)
|
||||
|
||||
useEffect(() => {
|
||||
const onProg = (p: { percent: number }) => {
|
||||
setPercent(p.percent)
|
||||
const now = performance.now()
|
||||
if (p.percent <= 1 || !t0.current) t0.current = now
|
||||
const elapsed = now - t0.current
|
||||
if (p.percent > 1 && p.percent < 100) {
|
||||
const total = elapsed / (p.percent / 100)
|
||||
const s = Math.round((total - elapsed) / 1000)
|
||||
setEta(s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`)
|
||||
} else setEta('')
|
||||
}
|
||||
const onDone = () => { setDownloading(false); setPercent(0); setEta(''); t0.current = 0 }
|
||||
const off1 = rt()?.EventsOn?.('firmware:progress', onProg)
|
||||
const off2 = rt()?.EventsOn?.('firmware:done', onDone)
|
||||
const off3 = rt()?.EventsOn?.('payload:progress', (p: { percent: number }) => setExtractPct(p.percent))
|
||||
const off4 = rt()?.EventsOn?.('payload:done', () => { setExtracting(''); setExtractPct(0) })
|
||||
return () => { off1?.(); off2?.(); off3?.(); off4?.() }
|
||||
}, [])
|
||||
|
||||
const pickOta = async () => {
|
||||
const z = await SelectFileForFlash()
|
||||
if (!z) return
|
||||
setOtaZip(z)
|
||||
setParts(null)
|
||||
setPartsBusy(true)
|
||||
try {
|
||||
setParts(await ListPayloadPartitions(z) || [])
|
||||
} catch (e: any) { notify.error(e); setParts([]) }
|
||||
finally { setPartsBusy(false) }
|
||||
}
|
||||
|
||||
const verifyFile = async () => {
|
||||
const f = await SelectAnyFile()
|
||||
if (!f) return
|
||||
setVName(f.split('/').pop() || f)
|
||||
setVHashes(null)
|
||||
setVBusy(true)
|
||||
try { setVHashes(await HashFile(f)) }
|
||||
catch (e: any) { notify.error(e) }
|
||||
finally { setVBusy(false) }
|
||||
}
|
||||
|
||||
const extractPart = async (name: string) => {
|
||||
setExtracting(name)
|
||||
setExtractPct(0)
|
||||
try {
|
||||
notify.success(await ExtractPayloadPartition(otaZip, name))
|
||||
} catch (e: any) { notify.error(e) }
|
||||
finally { setExtracting('') }
|
||||
}
|
||||
|
||||
const search = async () => {
|
||||
setLoading(true)
|
||||
setList([])
|
||||
try {
|
||||
setList(await ListFirmware(cn, kind) || [])
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const download = async (fw: Firmware) => {
|
||||
setDownloading(true)
|
||||
setPercent(0)
|
||||
t0.current = 0
|
||||
try {
|
||||
const out = await DownloadFirmware(fw.url, fw.sha256)
|
||||
notify.success(out)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Download Firmware</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Official Google Pixel images. Pick your device (auto-selected from the connected phone when possible). Files are large (2–3 GB) and verified by SHA-256 automatically after download.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5 shrink-0">
|
||||
{(['factory', 'ota'] as const).map(k => (
|
||||
<button key={k} onClick={() => setKind(k)}
|
||||
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${kind === k ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'}`}>
|
||||
{k === 'factory' ? 'Factory' : 'OTA'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select className="input text-xs flex-1" value={custom ? '__other__' : cn}
|
||||
onChange={e => {
|
||||
if (e.target.value === '__other__') { setCustom(true); setCn('') }
|
||||
else { setCustom(false); setCn(e.target.value) }
|
||||
}}>
|
||||
{!known && cn && !custom && <option value={cn}>{cn} (detected)</option>}
|
||||
{PIXEL_DEVICES.map(d => (
|
||||
<option key={d.cn} value={d.cn}>{d.name} ({d.cn})</option>
|
||||
))}
|
||||
<option value="__other__">Other (type codename)…</option>
|
||||
</select>
|
||||
{custom && (
|
||||
<input className="input text-xs w-32 mono shrink-0" value={cn} placeholder="codename" autoFocus
|
||||
onChange={e => setCn(e.target.value.trim())} onKeyDown={e => e.key === 'Enter' && search()} />
|
||||
)}
|
||||
<button onClick={search} disabled={loading} className="btn-ghost text-xs shrink-0">
|
||||
<Search size={13} /> {loading ? 'Searching…' : 'List builds'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{downloading && (
|
||||
<div className="card p-3 flex items-center gap-3">
|
||||
<Download size={14} className="text-accent-green shrink-0" />
|
||||
<div className="flex-1 h-1.5 rounded-full bg-bg-border overflow-hidden">
|
||||
<div className="h-full bg-accent-green transition-all duration-200" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-muted mono w-10 text-right">{percent}%</span>
|
||||
{eta && <span className="text-xs text-text-muted w-20 text-right">~{eta} left</span>}
|
||||
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list.length > 0 && (
|
||||
<div className="card divide-y divide-bg-border/50">
|
||||
{list.map(fw => (
|
||||
<div key={fw.url} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-text-primary leading-snug break-words">{fw.version}</p>
|
||||
{fw.sha256 ? (
|
||||
<>
|
||||
<p className="text-[10px] text-accent-green flex items-center gap-1 mt-0.5">
|
||||
<ShieldCheck size={10} className="shrink-0" /> verified on download
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted mono break-all leading-snug">{fw.sha256}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[10px] text-text-muted mt-0.5">no checksum listed</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => download(fw)} disabled={downloading} className="btn-ghost text-xs shrink-0">
|
||||
<Download size={13} /> Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && list.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-text-muted gap-2">
|
||||
<X size={24} className="opacity-20" />
|
||||
<p className="text-sm">Pick a device and list builds.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extract from an existing OTA (payload.bin) */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<PackageOpen size={14} className="text-accent-green" />
|
||||
<p className="section-title">Extract from OTA (payload.bin)</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Pull individual partition images (e.g. <span className="mono">init_boot</span>, <span className="mono">boot</span>, <span className="mono">system</span>) out of an A/B OTA zip — for patching, reverting, or analysis. Full OTAs only.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input className="input text-xs flex-1 mono" value={otaZip} readOnly placeholder="Select an OTA .zip..." />
|
||||
<button onClick={pickOta} disabled={partsBusy} className="btn-ghost text-xs shrink-0">
|
||||
{partsBusy ? 'Reading…' : 'Select OTA zip'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{extracting && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-text-secondary shrink-0 mono">{extracting}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-bg-border overflow-hidden">
|
||||
<div className="h-full bg-accent-green transition-all duration-200" style={{ width: `${extractPct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-muted mono w-10 text-right">{extractPct}%</span>
|
||||
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parts !== null && parts.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
||||
{parts.map(p => (
|
||||
<button
|
||||
key={p.name}
|
||||
onClick={() => extractPart(p.name)}
|
||||
disabled={!!extracting}
|
||||
className="flex items-center justify-between gap-2 rounded border border-bg-border px-2 py-1.5 text-xs hover:bg-bg-raised disabled:opacity-50"
|
||||
>
|
||||
<span className="mono text-text-secondary truncate">{p.name}</span>
|
||||
<span className="text-text-muted shrink-0">{p.sizeMB ? `${p.sizeMB}M` : ''}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{parts !== null && parts.length === 0 && (
|
||||
<p className="text-xs text-text-muted">No partitions found — not an A/B OTA, or it's an incremental update.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Verify a file (SHA-256) */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCheck2 size={14} className="text-accent-green" />
|
||||
<p className="section-title">Verify a File (SHA-256)</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input className="input text-xs flex-1 mono" value={vName} readOnly placeholder="Select any file to hash..." />
|
||||
<button onClick={verifyFile} disabled={vBusy} className="btn-ghost text-xs shrink-0">{vBusy ? 'Hashing…' : 'Select file'}</button>
|
||||
</div>
|
||||
{vHashes && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-text-muted mono break-all">SHA-256 {vHashes.sha256}</p>
|
||||
<p className="text-[10px] text-text-muted mono break-all">SHA-1 {vHashes.sha1}</p>
|
||||
<input
|
||||
className="input text-xs w-full mono"
|
||||
placeholder="Paste expected SHA-256 to compare…"
|
||||
value={vExpected}
|
||||
onChange={e => setVExpected(e.target.value)}
|
||||
/>
|
||||
{vExpected.trim() && (
|
||||
vHashes.sha256.toLowerCase() === vExpected.trim().toLowerCase()
|
||||
? <p className="text-xs text-accent-green flex items-center gap-1"><ShieldCheck size={12} /> Match — file is authentic</p>
|
||||
: <p className="text-xs text-danger flex items-center gap-1"><X size={12} /> Mismatch — checksums differ</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +1,131 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { Zap, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { GetFastbootDevices, FlashPartition, FastbootGetVar, SelectFileForFlash, SideloadPackage, SelectFileForInstall } from '../../lib/wails'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Zap, RefreshCw, AlertTriangle, Power, Unlock, Lock, Rocket, HardDrive, KeyRound, Download, Boxes, Trash2, FileSearch
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
GetFastbootDevices, FlashPartition, FastbootGetVar, SelectFileForFlash,
|
||||
SideloadPackage, SelectFileForInstall, FastbootBoot, FlashBootImage,
|
||||
FastbootFlashing, FastbootReboot, FlasherDeviceInfo, Reboot,
|
||||
MagiskInstalled, InstallMagisk, ExtractBootImages, PushImageToDevice, OpenMagisk, PullPatchedBoot,
|
||||
ListMagiskModules, ToggleMagiskModule, RemoveMagiskModule, AnalyzeBootImage
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import { getRootTools } from '../../lib/featureflags'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import ViewPixelFlasher from './ViewPixelFlasher'
|
||||
import ViewFirmware from './ViewFirmware'
|
||||
import type { Device } from '../../lib/types'
|
||||
|
||||
interface BootImages { boot: string; initBoot: string; source: string }
|
||||
interface MagiskModule { id: string; name: string; version: string; author: string; description: string; enabled: boolean }
|
||||
interface BootInfo { valid: boolean; type: string; headerVersion: number; androidVersion: string; securityPatch: string; pageSize: number; kernelKB: number; ramdiskKB: number; sizeMB: number; sha1: string; sha256: string; root: string }
|
||||
|
||||
const PARTITIONS = [
|
||||
'boot', 'recovery', 'system', 'vendor', 'userdata',
|
||||
'boot', 'init_boot', 'recovery', 'system', 'vendor', 'userdata',
|
||||
'dtbo', 'vbmeta', 'super', 'product', 'odm', 'radio'
|
||||
]
|
||||
const BOOT_PARTITIONS = ['boot', 'init_boot', 'vendor_boot', 'recovery', 'dtbo', 'vbmeta']
|
||||
|
||||
interface FlasherInfo {
|
||||
connection: string
|
||||
serial: string
|
||||
slot: string
|
||||
bootloader: string
|
||||
fingerprint: string
|
||||
androidVer: string
|
||||
codename: string
|
||||
lockState: string
|
||||
verifiedBoot: string
|
||||
root: string
|
||||
}
|
||||
|
||||
// Tabbed container: all flash-related tools live here (Manual fastboot/sideload
|
||||
// + Pixel factory-image flashing) to keep the sidebar uncluttered.
|
||||
export default function ViewFlasher() {
|
||||
const [tab, setTab] = useState<'manual' | 'pixel' | 'download'>('manual')
|
||||
const [info, setInfo] = useState<FlasherInfo | null>(null)
|
||||
const [loadingInfo, setLoadingInfo] = useState(false)
|
||||
|
||||
const refreshInfo = useCallback(async () => {
|
||||
setLoadingInfo(true)
|
||||
try {
|
||||
setInfo(await FlasherDeviceInfo())
|
||||
} catch {
|
||||
setInfo(null)
|
||||
} finally {
|
||||
setLoadingInfo(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refreshInfo() }, [refreshInfo])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-3 shrink-0">
|
||||
<Zap size={15} className="text-accent-green" />
|
||||
<span className="text-sm font-medium text-text-primary">Flasher</span>
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5 ml-1">
|
||||
{([['manual', 'Manual'], ['pixel', 'Pixel Factory'], ['download', 'Download']] as const).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
tab === id ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeviceBar info={info} loading={loadingInfo} onRefresh={refreshInfo} />
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{tab === 'manual' && <ManualFlash info={info} refresh={refreshInfo} />}
|
||||
{tab === 'pixel' && <ViewPixelFlasher />}
|
||||
{tab === 'download' && <ViewFirmware codename={info?.codename} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ label, value, tone }: { label: string; value?: string; tone?: 'green' | 'red' | 'amber' }) {
|
||||
const color = tone === 'green' ? 'text-accent-green' : tone === 'red' ? 'text-danger' : tone === 'amber' ? 'text-warn' : 'text-text-secondary'
|
||||
return (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span className={`mono ${color}`}>{value || '—'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceBar({ info, loading, onRefresh }: { info: FlasherInfo | null; loading: boolean; onRefresh: () => void }) {
|
||||
const conn = info?.connection ?? 'none'
|
||||
const connTone = conn === 'none' ? 'red' : 'green'
|
||||
return (
|
||||
<div className="border-b border-bg-border bg-bg-surface px-4 py-1.5 flex items-center gap-4 text-xs overflow-x-auto shrink-0">
|
||||
<Chip label="Mode" value={conn} tone={connTone as any} />
|
||||
{conn !== 'none' && <>
|
||||
<Chip label="Serial" value={info?.serial} />
|
||||
<Chip label="Slot" value={info?.slot ? info.slot : undefined} />
|
||||
<Chip label="Bootloader" value={info?.bootloader} />
|
||||
<Chip label="Lock" value={info?.lockState} tone={info?.lockState === 'unlocked' ? 'amber' : info?.lockState === 'locked' ? 'green' : undefined} />
|
||||
{info?.codename && <Chip label="Device" value={info?.codename} />}
|
||||
{info?.androidVer && <Chip label="Android" value={info?.androidVer} />}
|
||||
{info?.root && <Chip label="Root" value={info.root} tone={info.root !== 'none' ? 'amber' : undefined} />}
|
||||
</>}
|
||||
<button onClick={onRefresh} disabled={loading} className="btn-ghost text-xs ml-auto shrink-0" title="Refresh device info">
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ManualProps { info: FlasherInfo | null; refresh: () => void }
|
||||
|
||||
function ManualFlash({ info, refresh }: ManualProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loadingDevices, setLoadingDevices] = useState(false)
|
||||
const [partition, setPartition] = useState('boot')
|
||||
|
|
@ -17,6 +133,24 @@ export default function ViewFlasher() {
|
|||
const [flashing, setFlashing] = useState(false)
|
||||
const [getvarKey, setGetvarKey] = useState('all')
|
||||
const [getvarResult, setGetvarResult] = useState('')
|
||||
// Live-boot / boot-image flashing
|
||||
const [bootFile, setBootFile] = useState('')
|
||||
const [bootPartition, setBootPartition] = useState('boot')
|
||||
const [slot, setSlot] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
// Magisk rooting flow (gated by Settings → Advanced)
|
||||
const rootTools = getRootTools()
|
||||
const [magiskBusy, setMagiskBusy] = useState('')
|
||||
const [extracted, setExtracted] = useState<BootImages | null>(null)
|
||||
const [patchTarget, setPatchTarget] = useState<'boot' | 'initBoot'>('initBoot')
|
||||
const [magiskPkg, setMagiskPkg] = useState('')
|
||||
const [modules, setModules] = useState<MagiskModule[] | null>(null)
|
||||
const [modulesBusy, setModulesBusy] = useState(false)
|
||||
const [dryRun, setDryRun] = useState(false)
|
||||
const [force, setForce] = useState(false)
|
||||
const [bootInfo, setBootInfo] = useState<BootInfo | null>(null)
|
||||
|
||||
const inFastboot = info?.connection === 'fastboot'
|
||||
|
||||
const refreshDevices = useCallback(async () => {
|
||||
setLoadingDevices(true)
|
||||
|
|
@ -38,72 +172,420 @@ export default function ViewFlasher() {
|
|||
|
||||
const handleFlash = async () => {
|
||||
if (!selectedFile) { notify.error('Select an image file first'); return }
|
||||
if (!confirm(`Flash ${selectedFile} to ${partition}?\n\nThis will overwrite the ${partition} partition. Make sure you know what you're doing.`)) return
|
||||
|
||||
if (dryRun) { notify.info(`[dry run] fastboot ${force ? '--force ' : ''}flash ${partition} ${selectedFile}`); return }
|
||||
if (!confirm(`Flash ${selectedFile} to ${partition}?${force ? '\n\n⚠ --force is ON (skips safety checks).' : ''}\n\nThis overwrites the ${partition} partition.`)) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setFlashing(true)
|
||||
const id = notify.loading(`Flashing ${partition}...`)
|
||||
try {
|
||||
const out = await FlashPartition(partition, selectedFile)
|
||||
notify.dismiss(id)
|
||||
notify.success(out || `${partition} flashed successfully`)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setFlashing(false)
|
||||
}
|
||||
const out = await FlashPartition(partition, selectedFile, force)
|
||||
notify.dismiss(id); notify.success(out || `${partition} flashed`)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setFlashing(false) }
|
||||
}
|
||||
|
||||
const handleGetvar = async () => {
|
||||
try {
|
||||
const out = await FastbootGetVar(getvarKey)
|
||||
setGetvarResult(out)
|
||||
} catch (e: any) {
|
||||
setGetvarResult(String(e))
|
||||
}
|
||||
try { setGetvarResult(await FastbootGetVar(getvarKey)) }
|
||||
catch (e: any) { setGetvarResult(String(e)) }
|
||||
}
|
||||
|
||||
const handleSideload = async () => {
|
||||
const path = await SelectFileForInstall()
|
||||
if (!path) return
|
||||
if (!confirm('Sideload requires device to be in sideload mode (adb sideload). Continue?')) return
|
||||
if (!confirm('Sideload requires the device in sideload mode (recovery → Apply update from ADB). Continue?')) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
const id = notify.loading('Sideloading...')
|
||||
try {
|
||||
const out = await SideloadPackage(path)
|
||||
notify.dismiss(id); notify.success(out || 'Sideload complete')
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
}
|
||||
|
||||
const reboot = async (target: string) => {
|
||||
setBusy('reboot')
|
||||
try {
|
||||
if (inFastboot) await FastbootReboot(target)
|
||||
else await Reboot(target) // adb: '', bootloader, recovery, fastboot, sideload
|
||||
notify.success(`Reboot ${target || 'system'} sent`)
|
||||
setTimeout(refresh, 3500)
|
||||
} catch (e: any) { notify.error(e) }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
const flashing2 = async (action: 'unlock' | 'lock') => {
|
||||
if (!confirm(`fastboot flashing ${action}\n\n${action === 'unlock'
|
||||
? 'Unlocking ERASES ALL DATA and requires confirmation on the device screen.'
|
||||
: 'Locking ERASES ALL DATA. Only lock with fully stock partitions or you may brick the device.'}\n\nContinue?`)) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setBusy(action)
|
||||
try {
|
||||
const out = await FastbootFlashing(action)
|
||||
notify.success(out)
|
||||
setTimeout(refresh, 1500)
|
||||
} catch (e: any) { notify.error(e) }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
const selectBootFile = async () => {
|
||||
const path = await SelectFileForFlash()
|
||||
if (path) setBootFile(path)
|
||||
}
|
||||
|
||||
const liveBoot = async () => {
|
||||
if (!bootFile) { notify.error('Select an image first'); return }
|
||||
if (dryRun) { notify.info(`[dry run] fastboot boot ${bootFile}`); return }
|
||||
setBusy('liveboot')
|
||||
const id = notify.loading('Live-booting image...')
|
||||
try {
|
||||
await FastbootBoot(bootFile)
|
||||
notify.dismiss(id); notify.success('Booting image — watch the device')
|
||||
setTimeout(refresh, 4000)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
// ── Magisk assisted patch flow ──
|
||||
const checkMagisk = useCallback(async () => {
|
||||
try { setMagiskPkg(await MagiskInstalled()) } catch { setMagiskPkg('') }
|
||||
}, [])
|
||||
useEffect(() => { if (rootTools) checkMagisk() }, [rootTools, checkMagisk])
|
||||
|
||||
const installMagisk = async () => {
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setMagiskBusy('install')
|
||||
const id = notify.loading('Downloading & installing Magisk (may take a moment)...')
|
||||
try {
|
||||
const out = await InstallMagisk()
|
||||
notify.dismiss(id); notify.success(out)
|
||||
checkMagisk()
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setMagiskBusy('') }
|
||||
}
|
||||
|
||||
const loadModules = async () => {
|
||||
setModulesBusy(true)
|
||||
try {
|
||||
setModules(await ListMagiskModules() || [])
|
||||
} catch (e: any) { notify.error(e); setModules([]) }
|
||||
finally { setModulesBusy(false) }
|
||||
}
|
||||
const toggleModule = async (m: MagiskModule) => {
|
||||
try {
|
||||
const out = await ToggleMagiskModule(m.id, !m.enabled)
|
||||
notify.success(out)
|
||||
setModules(mods => mods?.map(x => x.id === m.id ? { ...x, enabled: !x.enabled } : x) || null)
|
||||
} catch (e: any) { notify.error(e) }
|
||||
}
|
||||
const removeModule = async (m: MagiskModule) => {
|
||||
if (!confirm(`Flag "${m.name}" for removal on next reboot?`)) return
|
||||
try { notify.success(await RemoveMagiskModule(m.id)) }
|
||||
catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const magiskExtract = async () => {
|
||||
const zip = await SelectFileForFlash()
|
||||
if (!zip) return
|
||||
setMagiskBusy('extract')
|
||||
const id = notify.loading('Extracting boot images from factory zip...')
|
||||
try {
|
||||
const imgs: BootImages = await ExtractBootImages(zip)
|
||||
setExtracted(imgs)
|
||||
setPatchTarget(imgs.initBoot ? 'initBoot' : 'boot')
|
||||
notify.dismiss(id)
|
||||
notify.success(out || 'Sideload complete')
|
||||
} catch (e: any) {
|
||||
notify.success(`Found ${[imgs.boot && 'boot.img', imgs.initBoot && 'init_boot.img'].filter(Boolean).join(' + ')}`)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setMagiskBusy('') }
|
||||
}
|
||||
|
||||
const magiskPushOpen = async () => {
|
||||
if (!extracted) return
|
||||
const local = patchTarget === 'initBoot' ? extracted.initBoot : extracted.boot
|
||||
if (!local) { notify.error('That image is not present in the zip'); return }
|
||||
setMagiskBusy('push')
|
||||
const id = notify.loading('Pushing image and opening Magisk...')
|
||||
try {
|
||||
await MagiskInstalled() // surfaces a clear error if Magisk isn't installed
|
||||
await PushImageToDevice(local)
|
||||
await OpenMagisk()
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
}
|
||||
notify.success('Pushed to /sdcard/Download. In Magisk: Install → Select and Patch a File → pick it → Let\'s Go.')
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setMagiskBusy('') }
|
||||
}
|
||||
|
||||
const magiskPull = async () => {
|
||||
setMagiskBusy('pull')
|
||||
const id = notify.loading('Pulling patched image...')
|
||||
try {
|
||||
const path = await PullPatchedBoot()
|
||||
notify.dismiss(id)
|
||||
setBootFile(path)
|
||||
setBootPartition(patchTarget === 'initBoot' ? 'init_boot' : 'boot')
|
||||
notify.success('Patched image loaded into "Boot Image" below — Live boot to test, or Flash to make root permanent.')
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setMagiskBusy('') }
|
||||
}
|
||||
|
||||
const flashBoot = async () => {
|
||||
if (!bootFile) { notify.error('Select an image first'); return }
|
||||
if (dryRun) { notify.info(`[dry run] fastboot ${force ? '--force ' : ''}${slot ? '--slot ' + slot + ' ' : ''}flash ${bootPartition} ${bootFile}`); return }
|
||||
const where = slot ? ` (slot ${slot})` : ''
|
||||
if (!confirm(`Flash ${bootFile}\n→ ${bootPartition}${where}?${force ? '\n\n⚠ --force is ON.' : ''}`)) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setBusy('flashboot')
|
||||
const id = notify.loading(`Flashing ${bootPartition}...`)
|
||||
try {
|
||||
const out = await FlashBootImage(bootPartition, bootFile, slot, force)
|
||||
notify.dismiss(id); notify.success(out || `${bootPartition} flashed`)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
const analyzeBoot = async () => {
|
||||
const f = await SelectFileForFlash()
|
||||
if (!f) return
|
||||
setBootInfo(null)
|
||||
try { setBootInfo(await AnalyzeBootImage(f)) }
|
||||
catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 h-full overflow-auto">
|
||||
<h1 className="text-base font-medium text-text-primary">Flasher</h1>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-3 bg-warn/5 border border-warn/20 rounded-lg px-4 py-3">
|
||||
<div className="p-4 space-y-4">
|
||||
<DismissibleBanner id="warn-flasher" className="bg-warn/5 border border-warn/20 rounded-lg px-4 py-3 text-warn">
|
||||
<AlertTriangle size={16} className="text-warn shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-warn/90">
|
||||
<p className="font-medium mb-1">Fastboot operations are destructive and irreversible.</p>
|
||||
<p className="text-warn/70">Wrong partition or wrong image = bricked device. Only partition names in the safe list are permitted. Make sure your device bootloader is unlocked before flashing.</p>
|
||||
<p className="text-warn/70">Wrong partition or wrong image = bricked device. Make sure the bootloader is unlocked before flashing.</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">Dry run</p>
|
||||
<p className="text-[11px] text-text-muted">Preview the exact fastboot command instead of running it (flash / live-boot).</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDryRun(v => !v)}
|
||||
role="switch"
|
||||
aria-checked={dryRun}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${dryRun ? 'bg-warn' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${dryRun ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">Force (<span className="mono">--force</span>)</p>
|
||||
<p className="text-[11px] text-text-muted">Adds <span className="mono">--force</span> to flash commands (e.g. bootloader/radio). Skips safety checks — use only when you know it's needed.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setForce(v => !v)}
|
||||
role="switch"
|
||||
aria-checked={force}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${force ? 'bg-danger' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${force ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{/* Reboot */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Reboot</p>
|
||||
<p className="text-xs text-text-muted">{inFastboot ? 'Device in fastboot — uses fastboot reboot.' : 'Device in adb — uses adb reboot.'}</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={() => reboot('')} disabled={!!busy} className="btn-ghost text-xs"><Power size={12} /> System</button>
|
||||
<button onClick={() => reboot('bootloader')} disabled={!!busy} className="btn-ghost text-xs">Bootloader</button>
|
||||
<button onClick={() => reboot('fastboot')} disabled={!!busy} className="btn-ghost text-xs">Fastbootd</button>
|
||||
<button onClick={() => reboot('recovery')} disabled={!!busy} className="btn-ghost text-xs">Recovery</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bootloader */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Bootloader Lock</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{inFastboot ? `Current: ${info?.lockState ?? 'unknown'}. Both actions wipe the device.` : 'Connect a device in fastboot mode to lock/unlock.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => flashing2('unlock')} disabled={!inFastboot || !!busy} className="btn-warn text-xs flex-1 justify-center"><Unlock size={13} /> Unlock</button>
|
||||
<button onClick={() => flashing2('lock')} disabled={!inFastboot || !!busy} className="btn-ghost text-xs flex-1 justify-center"><Lock size={13} /> Lock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magisk rooting flow (gated by Settings → Advanced) */}
|
||||
{rootTools && (
|
||||
<div className="card p-4 space-y-3 xl:col-span-2 border border-warn/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-warn" />
|
||||
<p className="section-title">Root with Magisk</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Patches the factory boot image with the Magisk app on your phone, then loads it below to Live boot (temporary root) or Flash (permanent). ATK doesn't bundle Magisk — it uses the app on your device (install it below if missing). Requires an unlocked bootloader.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 text-xs border-b border-bg-border pb-3">
|
||||
<span className={magiskPkg ? 'text-accent-green' : 'text-text-muted'}>
|
||||
{magiskPkg ? `Magisk detected: ${magiskPkg}` : 'Magisk not detected on device'}
|
||||
</span>
|
||||
<button onClick={installMagisk} disabled={!!magiskBusy} className="btn-ghost text-xs shrink-0">
|
||||
<Download size={12} /> {magiskBusy === 'install' ? 'Installing…' : 'Download & install Magisk'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button onClick={magiskExtract} disabled={!!magiskBusy} className="btn-ghost text-xs justify-center">
|
||||
1. {magiskBusy === 'extract' ? 'Extracting…' : 'Extract boot from zip'}
|
||||
</button>
|
||||
<button onClick={magiskPushOpen} disabled={!extracted || !!magiskBusy} className="btn-ghost text-xs justify-center">
|
||||
2. {magiskBusy === 'push' ? 'Pushing…' : 'Push + open Magisk'}
|
||||
</button>
|
||||
<button onClick={magiskPull} disabled={!!magiskBusy} className="btn-ghost text-xs justify-center">
|
||||
3. {magiskBusy === 'pull' ? 'Pulling…' : 'Pull patched image'}
|
||||
</button>
|
||||
</div>
|
||||
{extracted && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-muted">Patch:</span>
|
||||
{(['initBoot', 'boot'] as const).map(t => {
|
||||
const has = t === 'initBoot' ? extracted.initBoot : extracted.boot
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
disabled={!has}
|
||||
onClick={() => setPatchTarget(t)}
|
||||
className={`px-2 py-0.5 rounded border text-xs ${
|
||||
patchTarget === t ? 'border-accent-green text-accent-green bg-accent-green/10' : 'border-bg-border text-text-muted'
|
||||
} ${!has ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{t === 'initBoot' ? 'init_boot.img' : 'boot.img'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<span className="text-text-muted ml-1">(init_boot for Pixel 7+/8+, boot for older)</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-text-muted">Step 2 opens Magisk on the phone — tap <span className="text-text-secondary">Install → Select and Patch a File</span>, choose the pushed image in Download, then <span className="text-text-secondary">Let's Go</span>. Then run step 3.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Magisk module management (gated, requires root) */}
|
||||
{rootTools && (
|
||||
<div className="card p-4 space-y-3 xl:col-span-2 border border-warn/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes size={14} className="text-warn" />
|
||||
<p className="section-title">Magisk Modules</p>
|
||||
</div>
|
||||
<button onClick={loadModules} disabled={modulesBusy} className="btn-ghost text-xs">
|
||||
<RefreshCw size={12} className={modulesBusy ? 'animate-spin' : ''} /> {modules === null ? 'Load' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Enable/disable or remove installed modules. Requires root (grant shell root in Magisk if prompted); changes apply on reboot.</p>
|
||||
{modules !== null && (
|
||||
modules.length === 0 ? (
|
||||
<p className="text-xs text-text-muted text-center py-3">No modules installed (or device not rooted).</p>
|
||||
) : (
|
||||
<div className="divide-y divide-bg-border/50">
|
||||
{modules.map(m => (
|
||||
<div key={m.id} className="flex items-center gap-3 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-text-primary truncate">{m.name} <span className="text-text-muted">{m.version}</span></p>
|
||||
<p className="text-[10px] text-text-muted truncate">{m.author || m.id}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleModule(m)}
|
||||
role="switch"
|
||||
aria-checked={m.enabled}
|
||||
title={m.enabled ? 'Enabled' : 'Disabled'}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${m.enabled ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${m.enabled ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
<button onClick={() => removeModule(m)} title="Remove on reboot" className="text-text-muted hover:text-danger shrink-0">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live boot + boot image flashing */}
|
||||
<div className="card p-4 space-y-3 xl:col-span-2">
|
||||
<p className="section-title">Boot Image — Live Boot & Flash</p>
|
||||
<div className="flex gap-2">
|
||||
<input className="input text-xs flex-1 mono" value={bootFile} readOnly placeholder="Select a boot / init_boot image (.img)" />
|
||||
<button onClick={selectBootFile} className="btn-ghost text-xs shrink-0">Browse</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">Partition</span>
|
||||
<select className="input text-xs mt-1" value={bootPartition} onChange={e => setBootPartition(e.target.value)}>
|
||||
{BOOT_PARTITIONS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">Slot</span>
|
||||
<select className="input text-xs mt-1" value={slot} onChange={e => setSlot(e.target.value)}>
|
||||
<option value="">current</option>
|
||||
<option value="a">a</option>
|
||||
<option value="b">b</option>
|
||||
<option value="all">both</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button onClick={liveBoot} disabled={!inFastboot || !bootFile || !!busy} className="btn-ghost text-sm" title="Boot the image without flashing">
|
||||
<Rocket size={14} /> {busy === 'liveboot' ? 'Booting…' : 'Live boot'}
|
||||
</button>
|
||||
<button onClick={flashBoot} disabled={!inFastboot || !bootFile || !!busy} className="btn-danger text-sm">
|
||||
<HardDrive size={14} /> {busy === 'flashboot' ? 'Flashing…' : `Flash ${bootPartition}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!inFastboot && <p className="text-xs text-text-muted">Connect a device in fastboot mode to live-boot or flash.</p>}
|
||||
</div>
|
||||
|
||||
{/* Boot image analyzer (local file — no device needed) */}
|
||||
<div className="card p-4 space-y-3 xl:col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSearch size={14} className="text-accent-green" />
|
||||
<p className="section-title">Boot Image Analyzer</p>
|
||||
</div>
|
||||
<button onClick={analyzeBoot} className="btn-ghost text-xs">Analyze a .img…</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Inspect a boot / init_boot image: type, header, Android version + security patch, sizes, hashes, and whether it looks rooted. Local file only — no device needed.</p>
|
||||
{bootInfo && (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
|
||||
<Chip label="Type" value={bootInfo.type} tone={bootInfo.valid ? 'green' : 'red'} />
|
||||
<Chip label="Header" value={bootInfo.headerVersion ? `v${bootInfo.headerVersion}` : undefined} />
|
||||
<Chip label="Android" value={bootInfo.androidVersion} />
|
||||
<Chip label="Patch" value={bootInfo.securityPatch} />
|
||||
<Chip label="Kernel" value={bootInfo.kernelKB ? `${bootInfo.kernelKB} KB` : undefined} />
|
||||
<Chip label="Ramdisk" value={bootInfo.ramdiskKB ? `${bootInfo.ramdiskKB} KB` : undefined} />
|
||||
<Chip label="Root" value={bootInfo.root} tone={bootInfo.root.includes('none') ? undefined : 'amber'} />
|
||||
<Chip label="Size" value={`${bootInfo.sizeMB} MB`} />
|
||||
<div className="col-span-2 mt-1">
|
||||
<p className="text-[10px] text-text-muted mono break-all">SHA-256 {bootInfo.sha256}</p>
|
||||
<p className="text-[10px] text-text-muted mono break-all">SHA-1 {bootInfo.sha1}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fastboot devices */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-title">Fastboot Devices</p>
|
||||
<button onClick={refreshDevices} disabled={loadingDevices} className="btn-ghost text-xs">
|
||||
<RefreshCw size={12} className={loadingDevices ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
<RefreshCw size={12} className={loadingDevices ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-text-muted text-sm text-center py-4">
|
||||
No fastboot devices. Boot device to bootloader with:<br />
|
||||
No fastboot devices. Boot to bootloader:<br />
|
||||
<span className="mono text-xs text-text-secondary">adb reboot bootloader</span>
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -124,39 +606,20 @@ export default function ViewFlasher() {
|
|||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Partition</label>
|
||||
<select
|
||||
className="input text-xs"
|
||||
value={partition}
|
||||
onChange={e => setPartition(e.target.value)}
|
||||
>
|
||||
{PARTITIONS.map(p => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
<select className="input text-xs" value={partition} onChange={e => setPartition(e.target.value)}>
|
||||
{PARTITIONS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Image file</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1 mono"
|
||||
value={selectedFile}
|
||||
readOnly
|
||||
placeholder="No file selected"
|
||||
/>
|
||||
<input className="input text-xs flex-1 mono" value={selectedFile} readOnly placeholder="No file selected" />
|
||||
<button onClick={handleSelectFile} className="btn-ghost text-xs shrink-0">Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFlash}
|
||||
disabled={flashing || !selectedFile || devices.length === 0}
|
||||
className="btn-danger w-full justify-center"
|
||||
>
|
||||
<Zap size={14} />
|
||||
{flashing ? 'Flashing...' : `Flash ${partition}`}
|
||||
<button onClick={handleFlash} disabled={flashing || !selectedFile} className="btn-danger w-full justify-center">
|
||||
<Zap size={14} /> {flashing ? 'Flashing...' : `Flash ${partition}`}
|
||||
</button>
|
||||
{devices.length === 0 && (
|
||||
<p className="text-text-muted text-xs text-center">Connect a device in fastboot mode to flash</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -164,27 +627,18 @@ export default function ViewFlasher() {
|
|||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Fastboot Getvar</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1"
|
||||
value={getvarKey}
|
||||
onChange={e => setGetvarKey(e.target.value)}
|
||||
placeholder="all"
|
||||
/>
|
||||
<input className="input text-xs flex-1" value={getvarKey} onChange={e => setGetvarKey(e.target.value)} placeholder="all" />
|
||||
<button onClick={handleGetvar} className="btn-ghost text-xs">Query</button>
|
||||
</div>
|
||||
{getvarResult && (
|
||||
<pre className="bg-bg-raised rounded p-3 text-xs mono text-text-secondary whitespace-pre-wrap max-h-48 overflow-auto">
|
||||
{getvarResult}
|
||||
</pre>
|
||||
<pre className="bg-bg-raised rounded p-3 text-xs mono text-text-secondary whitespace-pre-wrap max-h-48 overflow-auto">{getvarResult}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sideload */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">ADB Sideload</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Sideload a ZIP (OTA update) to a device in sideload mode. Boot to recovery then select "Apply update from ADB".
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">Sideload a ZIP (OTA update) to a device in sideload mode (recovery → Apply update from ADB).</p>
|
||||
<button onClick={handleSideload} className="btn-ghost w-full justify-center text-xs">
|
||||
<Zap size={13} /> Select ZIP and Sideload
|
||||
</button>
|
||||
|
|
|
|||
256
frontend/src/components/views/ViewGsiLoader.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { HardDriveDownload, Boxes, Zap, RefreshCw, Check, X, AlertTriangle, FileUp, Play, Power, Trash2, RotateCcw } from 'lucide-react'
|
||||
import {
|
||||
GsiCompat, GsiDsuStatus, InstallDsu, DsuEnable, DsuDisable, DsuWipe,
|
||||
FlashGsiSystem, SelectFileForFlash, Reboot,
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import type { GsiCompat as GsiCompatT } from '../../lib/types'
|
||||
|
||||
const GIB = 1073741824
|
||||
|
||||
export default function ViewGsiLoader() {
|
||||
const [tab, setTab] = useState<'dsu' | 'flash'>('dsu')
|
||||
const [compat, setCompat] = useState<GsiCompatT | null>(null)
|
||||
const [compatLoading, setCompatLoading] = useState(false)
|
||||
|
||||
// DSU
|
||||
const [dsuImage, setDsuImage] = useState('')
|
||||
const [systemSize, setSystemSize] = useState(0) // bytes; 0 = auto for raw .img
|
||||
const [userdataGiB, setUserdataGiB] = useState(8)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [pushPct, setPushPct] = useState(-1)
|
||||
const [dsuStatus, setDsuStatus] = useState('')
|
||||
|
||||
// Flash
|
||||
const [flashImage, setFlashImage] = useState('')
|
||||
const [vbmeta, setVbmeta] = useState('')
|
||||
const [opts, setOpts] = useState({ fastbootd: true, wipeData: true, disableVerity: false, deleteProduct: false, slot: '' })
|
||||
const [dryRun, setDryRun] = useState('')
|
||||
const [flashing, setFlashing] = useState(false)
|
||||
const [flashOut, setFlashOut] = useState('')
|
||||
|
||||
const loadCompat = async () => {
|
||||
setCompatLoading(true)
|
||||
try { setCompat(await GsiCompat()) } catch (e: any) { notify.error(e) } finally { setCompatLoading(false) }
|
||||
}
|
||||
useEffect(() => { loadCompat() }, [])
|
||||
|
||||
// Push progress for the DSU image upload.
|
||||
useEffect(() => {
|
||||
const rt = () => (window as any)['runtime']
|
||||
const onProg = (t: any) => { if (t?.label?.includes('atk-dsu') || t?.kind === 'push') setPushPct(t.percent) }
|
||||
const onDone = () => setPushPct(-1)
|
||||
const off1 = rt()?.EventsOn?.('transfer:progress', onProg)
|
||||
const off2 = rt()?.EventsOn?.('transfer:done', onDone)
|
||||
return () => { rt()?.EventsOff?.('transfer:progress'); rt()?.EventsOff?.('transfer:done'); off1?.(); off2?.() }
|
||||
}, [])
|
||||
|
||||
const pickImage = async (setter: (p: string) => void) => {
|
||||
try { const p = await SelectFileForFlash(); if (p) setter(p) } catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const refreshStatus = async () => {
|
||||
try { setDsuStatus((await GsiDsuStatus()) || '(no status)') } catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (!dsuImage) { notify.error('Select a GSI image first'); return }
|
||||
setInstalling(true); setPushPct(0)
|
||||
const id = notify.loading('Preparing & pushing GSI (this can take a while)...')
|
||||
try {
|
||||
const out = await InstallDsu(dsuImage, systemSize, userdataGiB * GIB)
|
||||
notify.dismiss(id); notify.success('DSU install launched'); setDsuStatus(out)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) } finally { setInstalling(false); setPushPct(-1) }
|
||||
}
|
||||
|
||||
const gsiTool = async (fn: () => Promise<string>, label: string) => {
|
||||
try { const out = await fn(); notify.success(`${label}: ${out || 'ok'}`); refreshStatus() } catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const previewFlash = async () => {
|
||||
if (!flashImage) { notify.error('Select a GSI system image first'); return }
|
||||
try { setDryRun(await FlashGsiSystem(flashImage, { ...opts, vbmetaPath: vbmeta, dryRun: true })) } catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const doFlash = async () => {
|
||||
if (!flashImage) { notify.error('Select a GSI system image first'); return }
|
||||
if (!confirm('Permanently flash this GSI to the system partition?\n\nThis ERASES system, wipes userdata, and requires an unlocked bootloader. If the GSI is incompatible the device may not boot. Continue?')) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setFlashing(true)
|
||||
const id = notify.loading('Flashing GSI via fastboot...')
|
||||
try {
|
||||
const out = await FlashGsiSystem(flashImage, { ...opts, vbmetaPath: vbmeta, dryRun: false })
|
||||
notify.dismiss(id); notify.success('GSI flashed'); setFlashOut(out)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e); setFlashOut(String(e?.message || e)) } finally { setFlashing(false) }
|
||||
}
|
||||
|
||||
const trebleOk = compat?.trebleEnabled
|
||||
const baseName = (p: string) => p.split('/').pop() || p
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header + tabs */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 shrink-0">
|
||||
<Boxes size={15} className="text-accent-green" />
|
||||
<span className="text-sm font-medium text-text-primary">GSI Loader</span>
|
||||
<div className="flex rounded overflow-hidden border border-bg-border ml-2">
|
||||
<button onClick={() => setTab('dsu')} className={`px-3 py-1 text-xs flex items-center gap-1 ${tab === 'dsu' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}>
|
||||
<HardDriveDownload size={12} /> DSU (Temporary)
|
||||
</button>
|
||||
<button onClick={() => setTab('flash')} className={`px-3 py-1 text-xs flex items-center gap-1 ${tab === 'flash' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}>
|
||||
<Zap size={12} /> GSI Flasher (Permanent)
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button onClick={loadCompat} disabled={compatLoading} className="btn-ghost text-xs">
|
||||
<RefreshCw size={12} className={compatLoading ? 'animate-spin' : ''} /> Recheck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compatibility panel */}
|
||||
<div className="border-b border-bg-border px-4 py-2 shrink-0 bg-bg-surface flex items-center gap-4 flex-wrap text-xs">
|
||||
<span className="section-title">Compatibility</span>
|
||||
{!compat && <span className="text-text-muted">{compatLoading ? 'Checking…' : 'No device / unknown'}</span>}
|
||||
{compat && (
|
||||
<>
|
||||
<span className={`flex items-center gap-1 ${trebleOk ? 'text-accent-green' : 'text-danger'}`}>
|
||||
{trebleOk ? <Check size={12} /> : <X size={12} />} Treble {trebleOk ? 'enabled' : 'NOT enabled'}
|
||||
</span>
|
||||
<span className="text-text-secondary">ABI: <span className="mono text-text-primary">{compat.abi || '?'}</span> → use <span className="mono text-accent-green">{compat.gsiArch || '?'}</span> GSI</span>
|
||||
<span className="text-text-secondary">Android {compat.androidRelease || '?'} (SDK {compat.sdk || '?'})</span>
|
||||
<span className={compat.vndkIsolated ? 'text-accent-green' : 'text-warn'}>
|
||||
{compat.vndkIsolated ? 'VNDK isolated — any newer GSI' : 'not VNDK-isolated — same-version GSI only'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{compat && !trebleOk && (
|
||||
<span className="flex items-center gap-1 text-danger"><AlertTriangle size={12} /> Device may not support GSIs</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{tab === 'dsu' ? (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<p className="text-xs text-text-muted leading-relaxed">
|
||||
Installs a GSI as a temporary <span className="text-text-secondary">guest OS</span> via Dynamic System Updates — no unlock,
|
||||
no data wipe. Pick a <span className="text-text-secondary">raw</span> (unsparsed) GSI <span className="mono">system.img</span> or a
|
||||
<span className="mono"> .gz</span> you made from one. After install, tap <span className="text-accent-green">Restart</span> in the device notification to boot it.
|
||||
</p>
|
||||
|
||||
{/* Image picker */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => pickImage(setDsuImage)} className="btn-ghost text-xs"><FileUp size={12} /> Select GSI image</button>
|
||||
<span className="mono text-xs text-text-secondary truncate">{dsuImage ? baseName(dsuImage) : 'no file selected'}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1">Userdata size (GiB)</p>
|
||||
<input type="number" min={1} className="input text-xs w-full" value={userdataGiB} onChange={e => setUserdataGiB(Math.max(1, Number(e.target.value)))} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1">System size (bytes) — auto for raw .img, <span className="text-warn">required for .gz</span></p>
|
||||
<input type="number" min={0} className="input text-xs w-full" value={systemSize} onChange={e => setSystemSize(Math.max(0, Number(e.target.value)))} placeholder="0 = auto (raw .img)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pushPct >= 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-bg-border rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent-green transition-all duration-200" style={{ width: `${pushPct}%` }} />
|
||||
</div>
|
||||
<span className="mono text-xs text-text-muted w-10 text-right">{pushPct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={install} disabled={installing || !dsuImage} className="btn-primary text-xs">
|
||||
<Play size={12} /> {installing ? 'Installing…' : 'Install DSU'}
|
||||
</button>
|
||||
<button onClick={() => Reboot('')} className="btn-ghost text-xs" title="Cold reboot — boots the GSI if just installed, or back to the host OS"><RotateCcw size={12} /> Reboot</button>
|
||||
</div>
|
||||
|
||||
{/* gsi_tool management */}
|
||||
<div className="border-t border-bg-border pt-3 space-y-2">
|
||||
<p className="section-title">DSU management (gsi_tool)</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={refreshStatus} className="btn-ghost text-xs"><RefreshCw size={12} /> Status</button>
|
||||
<button onClick={() => gsiTool(DsuEnable, 'enabled (sticky)')} className="btn-ghost text-xs"><Power size={12} /> Enable sticky</button>
|
||||
<button onClick={() => gsiTool(DsuDisable, 'disabled')} className="btn-ghost text-xs"><Power size={12} /> Disable</button>
|
||||
<button onClick={() => gsiTool(DsuWipe, 'wiped')} className="btn-danger text-xs"><Trash2 size={12} /> Wipe DSU</button>
|
||||
</div>
|
||||
{dsuStatus && <pre className="mono text-[11px] text-text-secondary whitespace-pre-wrap bg-bg-raised rounded p-2 border border-bg-border max-h-40 overflow-auto">{dsuStatus}</pre>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-start gap-2 rounded border border-danger/30 bg-danger/5 p-3">
|
||||
<AlertTriangle size={14} className="text-danger shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-danger/90">
|
||||
<span className="font-medium">Destructive & permanent.</span> Erases the system partition, wipes userdata, and needs an
|
||||
<span className="font-medium"> unlocked bootloader</span>. An incompatible GSI can leave the device unbootable — keep the stock factory image to recover. GSIs don't support rollback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => pickImage(setFlashImage)} className="btn-ghost text-xs"><FileUp size={12} /> Select GSI system.img</button>
|
||||
<span className="mono text-xs text-text-secondary truncate">{flashImage ? baseName(flashImage) : 'no file selected'}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
['fastbootd', 'Reboot to fastbootd first (dynamic partitions)'],
|
||||
['wipeData', 'Wipe userdata (fastboot -w)'],
|
||||
['disableVerity', 'Disable Verified Boot (flash vbmeta)'],
|
||||
['deleteProduct', 'Delete product partition (free space)'],
|
||||
] as const).map(([key, label]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" checked={(opts as any)[key]} onChange={e => setOpts(o => ({ ...o, [key]: e.target.checked }))} className="accent-accent-green" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(opts.disableVerity) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => pickImage(setVbmeta)} className="btn-ghost text-xs"><FileUp size={12} /> Select vbmeta.img</button>
|
||||
<span className="mono text-xs text-text-secondary truncate">{vbmeta ? baseName(vbmeta) : 'required for disable-verity'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(opts.deleteProduct) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">Active slot suffix:</span>
|
||||
<select className="input text-xs w-24" value={opts.slot} onChange={e => setOpts(o => ({ ...o, slot: e.target.value }))}>
|
||||
<option value="">(none)</option>
|
||||
<option value="a">a</option>
|
||||
<option value="b">b</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={previewFlash} disabled={!flashImage} className="btn-ghost text-xs">Dry run (preview commands)</button>
|
||||
<button onClick={doFlash} disabled={flashing || !flashImage} className="btn-danger text-xs"><Zap size={12} /> {flashing ? 'Flashing…' : 'Flash GSI'}</button>
|
||||
</div>
|
||||
|
||||
{dryRun && (
|
||||
<div>
|
||||
<p className="section-title mb-1">Command preview</p>
|
||||
<pre className="mono text-[11px] text-accent-green whitespace-pre-wrap bg-bg-raised rounded p-2 border border-bg-border">{dryRun}</pre>
|
||||
</div>
|
||||
)}
|
||||
{flashOut && (
|
||||
<div>
|
||||
<p className="section-title mb-1">Output</p>
|
||||
<pre className="mono text-[11px] text-text-secondary whitespace-pre-wrap bg-bg-raised rounded p-2 border border-bg-border max-h-60 overflow-auto">{flashOut}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
frontend/src/components/views/ViewIntentLab.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Search, Rocket, Play, Terminal, Package } from 'lucide-react'
|
||||
import { ListActivities, StartActivity, StartIntentAction, ListPackages } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { IntentActivity, PackageInfo } from '../../lib/types'
|
||||
|
||||
export default function ViewIntentLab() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [packages, setPackages] = useState<PackageInfo[]>([])
|
||||
const [pkgsLoaded, setPkgsLoaded] = useState(false)
|
||||
const [selected, setSelected] = useState('')
|
||||
const [activities, setActivities] = useState<IntentActivity[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actFilter, setActFilter] = useState('')
|
||||
const [lastResult, setLastResult] = useState('')
|
||||
|
||||
// Free-form implicit-intent launcher
|
||||
const [action, setAction] = useState('android.intent.action.VIEW')
|
||||
const [data, setData] = useState('')
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (pkgsLoaded) return
|
||||
try {
|
||||
const pkgs = await ListPackages('all')
|
||||
setPackages(pkgs || [])
|
||||
setPkgsLoaded(true)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Populate the picker on open so it isn't empty until the search box is focused.
|
||||
useEffect(() => { loadPackages() }, [])
|
||||
|
||||
const loadActivities = async (pkg: string) => {
|
||||
if (!pkg.trim()) return
|
||||
setSelected(pkg.trim())
|
||||
setLoading(true)
|
||||
setActivities([])
|
||||
setLastResult('')
|
||||
try {
|
||||
const acts = await ListActivities(pkg.trim())
|
||||
setActivities(acts || [])
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const launch = async (component: string) => {
|
||||
const id = notify.loading(`Launching ${component}...`)
|
||||
try {
|
||||
const out = await StartActivity(component)
|
||||
notify.dismiss(id)
|
||||
notify.success(out || 'Started')
|
||||
setLastResult(`✓ ${component}\n${out}`)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
setLastResult(`✗ ${component}\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
const launchIntent = async () => {
|
||||
if (!action.trim()) { notify.error('Enter an action'); return }
|
||||
const id = notify.loading('Launching intent...')
|
||||
try {
|
||||
const out = await StartIntentAction(action.trim(), data.trim())
|
||||
notify.dismiss(id)
|
||||
notify.success(out || 'Started')
|
||||
setLastResult(`✓ ${action}${data ? ' ' + data : ''}\n${out}`)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
setLastResult(`✗ ${action}\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPkgs = packages.filter(p => p.packageName.toLowerCase().includes(search.toLowerCase()))
|
||||
const filteredActs = activities.filter(a =>
|
||||
a.name.toLowerCase().includes(actFilter.toLowerCase()) ||
|
||||
a.component.toLowerCase().includes(actFilter.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Left: package picker */}
|
||||
<div className="w-72 shrink-0 border-r border-bg-border flex flex-col overflow-hidden">
|
||||
<div className="p-3 border-b border-bg-border space-y-2 shrink-0">
|
||||
<p className="section-title">Intent Lab</p>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-7 text-xs w-full"
|
||||
placeholder="Package name..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onFocus={loadPackages}
|
||||
onKeyDown={e => e.key === 'Enter' && loadActivities(search)}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => loadActivities(search)} disabled={!search || loading} className="btn-primary text-xs w-full justify-center">
|
||||
{loading ? 'Loading...' : 'List activities'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filteredPkgs.map(p => (
|
||||
<button
|
||||
key={p.packageName}
|
||||
onClick={() => { setSearch(p.packageName); loadActivities(p.packageName) }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-bg-raised transition-colors border-b border-bg-border/30 ${
|
||||
selected === p.packageName ? 'bg-accent-green/5 text-text-primary' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<p className="truncate mono">{p.packageName}</p>
|
||||
</button>
|
||||
))}
|
||||
{!pkgsLoaded && (
|
||||
<p className="text-text-muted text-xs text-center p-4">Focus the box to load the package list</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: launcher */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{!selected && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-muted">
|
||||
<Rocket size={32} className="opacity-20" />
|
||||
<p className="text-sm">Pick an app to see its launchable activities</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selected || loading) && (
|
||||
<>
|
||||
{/* Free-form implicit-intent launcher */}
|
||||
<div className="border-b border-bg-border p-3 shrink-0 space-y-2 bg-bg-surface">
|
||||
<p className="section-title flex items-center gap-1.5"><Terminal size={12} /> Implicit intent (action + data)</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1"
|
||||
placeholder="action, e.g. android.intent.action.VIEW"
|
||||
value={action}
|
||||
onChange={e => setAction(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input text-xs flex-1"
|
||||
placeholder="data URI (optional), e.g. https://example.com"
|
||||
value={data}
|
||||
onChange={e => setData(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && launchIntent()}
|
||||
/>
|
||||
<button onClick={launchIntent} className="btn-primary text-xs shrink-0">
|
||||
<Play size={12} /> Fire
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activities */}
|
||||
<div className="border-b border-bg-border px-3 py-2 shrink-0 flex items-center gap-2">
|
||||
<Package size={13} className="text-accent-green shrink-0" />
|
||||
<span className="mono text-xs text-text-primary truncate">{selected}</span>
|
||||
<span className="text-xs text-text-muted">· {activities.length} launchable</span>
|
||||
<div className="flex-1" />
|
||||
<div className="relative">
|
||||
<Search size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-6 text-xs w-44 py-1"
|
||||
placeholder="Filter activities..."
|
||||
value={actFilter}
|
||||
onChange={e => setActFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && filteredActs.length === 0 && (
|
||||
<p className="text-text-muted text-xs text-center p-6">
|
||||
No launchable activities{activities.length > 0 ? ' match the filter' : ' — this app exports none, or requires root to reach its internal screens'}.
|
||||
</p>
|
||||
)}
|
||||
{!loading && filteredActs.map(act => (
|
||||
<div
|
||||
key={act.component}
|
||||
className="flex items-center gap-3 px-3 py-2 border-b border-bg-border/30 hover:bg-bg-raised transition-colors group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="mono text-xs text-text-primary truncate">{act.name}</p>
|
||||
<p className="mono text-[10px] text-text-muted truncate">{act.component}</p>
|
||||
</div>
|
||||
{act.exported && <span className="badge-green text-xs shrink-0">exported</span>}
|
||||
<button
|
||||
onClick={() => launch(act.component)}
|
||||
className="btn-ghost text-xs shrink-0 opacity-60 group-hover:opacity-100"
|
||||
>
|
||||
<Play size={12} /> Launch
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Last result */}
|
||||
{lastResult && (
|
||||
<div className="border-t border-bg-border px-3 py-2 shrink-0 bg-bg-surface">
|
||||
<pre className="mono text-[11px] whitespace-pre-wrap text-text-secondary max-h-24 overflow-auto">{lastResult}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Play, Square, Trash2, Download, Filter, ChevronDown } from 'lucide-react'
|
||||
import { StartLogcat, StopLogcat, ClearLogcat } from '../../lib/wails'
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Play, Square, Trash2, Download, Filter, ChevronDown, List, Share2, Highlighter, Plus, X } from 'lucide-react'
|
||||
import { StartLogcat, StopLogcat, ClearLogcat, SaveTextFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import LogcatMap from './LogcatMap'
|
||||
import type { LogcatLine } from '../../lib/types'
|
||||
import {
|
||||
loadHighlightRules, saveHighlightRules, compileRules, scrubSensitive,
|
||||
HI_SWATCH, type HighlightRule, type HiColor,
|
||||
} from '../../lib/logcat_tools'
|
||||
|
||||
const HI_COLORS: HiColor[] = ['red', 'amber', 'green', 'blue', 'purple', 'pink']
|
||||
|
||||
// @ts-ignore
|
||||
const { EventsOn, EventsOff } = window['runtime'] || {}
|
||||
|
|
@ -23,8 +30,9 @@ const LEVEL_BG: Record<string, string> = {
|
|||
W: 'bg-warn/5',
|
||||
}
|
||||
|
||||
const BUFFERS = ['main', 'radio', 'events', 'crash', 'all']
|
||||
const MAX_LINES = 5000
|
||||
const BUFFERS = ['main', 'system', 'radio', 'events', 'crash', 'default', 'all']
|
||||
const REFRESH_OPTS: [number, string][] = [[0, 'Live'], [250, '250ms'], [500, '500ms'], [1000, '1s'], [2000, '2s']]
|
||||
const MAX_LINE_OPTS = [1000, 5000, 20000, 100000]
|
||||
|
||||
export default function ViewLogcat() {
|
||||
const [lines, setLines] = useState<LogcatLine[]>([])
|
||||
|
|
@ -33,12 +41,31 @@ export default function ViewLogcat() {
|
|||
const [tagFilter, setTagFilter] = useState('')
|
||||
const [levelFilter, setLevelFilter] = useState<string[]>([])
|
||||
const [buffer, setBuffer] = useState('main')
|
||||
const [refreshMs, setRefreshMs] = useState(0)
|
||||
const [maxLines, setMaxLines] = useState(5000)
|
||||
const pendingRef = useRef<LogcatLine[]>([])
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'text' | 'map'>('text')
|
||||
const [showHighlights, setShowHighlights] = useState(false)
|
||||
const [hiRules, setHiRules] = useState<HighlightRule[]>(() => loadHighlightRules())
|
||||
const [newPattern, setNewPattern] = useState('')
|
||||
const [newMode, setNewMode] = useState<'contains' | 'regex'>('contains')
|
||||
const [newColor, setNewColor] = useState<HiColor>('red')
|
||||
const [scrubExport, setScrubExport] = useState(true)
|
||||
const mapSinkRef = useRef<((l: LogcatLine) => void) | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// The map subscribes to the same stream via this sink (registered on mount).
|
||||
const registerMapSink = useCallback((fn: ((l: LogcatLine) => void) | null) => { mapSinkRef.current = fn }, [])
|
||||
const inspectEntity = useCallback((e: { kind: 'pid' | 'tag'; value: string; label: string }) => {
|
||||
if (e.kind === 'tag') { setTagFilter(e.value); setSearch('') }
|
||||
else { setSearch(e.value); setTagFilter('') }
|
||||
setViewMode('text'); setShowFilters(true)
|
||||
}, [])
|
||||
|
||||
// Wails runtime event bridge
|
||||
const useWailsEvent = (event: string, handler: (data: any) => void) => {
|
||||
useEffect(() => {
|
||||
|
|
@ -53,11 +80,28 @@ export default function ViewLogcat() {
|
|||
}
|
||||
|
||||
const handleLine = useCallback((line: LogcatLine) => {
|
||||
mapSinkRef.current?.(line) // always feed the visual map at full rate
|
||||
if (refreshMs > 0) { pendingRef.current.push(line); return } // batched flush below
|
||||
setLines(prev => {
|
||||
const next = [...prev, line]
|
||||
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next
|
||||
return next.length > maxLines ? next.slice(next.length - maxLines) : next
|
||||
})
|
||||
}, [])
|
||||
}, [refreshMs, maxLines])
|
||||
|
||||
// Batched render: flush queued lines on the chosen interval instead of per-line.
|
||||
useEffect(() => {
|
||||
if (refreshMs <= 0) return
|
||||
const id = setInterval(() => {
|
||||
if (pendingRef.current.length === 0) return
|
||||
const batch = pendingRef.current
|
||||
pendingRef.current = []
|
||||
setLines(prev => {
|
||||
const next = prev.concat(batch)
|
||||
return next.length > maxLines ? next.slice(next.length - maxLines) : next
|
||||
})
|
||||
}, refreshMs)
|
||||
return () => clearInterval(id)
|
||||
}, [refreshMs, maxLines])
|
||||
|
||||
const handleStopped = useCallback(() => {
|
||||
setRunning(false)
|
||||
|
|
@ -114,15 +158,40 @@ export default function ViewLogcat() {
|
|||
}
|
||||
}
|
||||
|
||||
const saveLog = () => {
|
||||
const text = lines.map(l => l.raw).join('\n')
|
||||
const blob = new Blob([text], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `logcat_${Date.now()}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
const saveLog = async () => {
|
||||
// Export the currently-visible (filtered) lines, optionally scrubbing
|
||||
// sensitive identifiers (IMEIs, phone numbers, SIM serials, MACs, emails).
|
||||
let text = filteredLines.map(l => l.raw).join('\n')
|
||||
if (scrubExport) text = scrubSensitive(text)
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
||||
const name = `logcat_${stamp}${scrubExport ? '_scrubbed' : ''}.txt`
|
||||
try {
|
||||
const saved = await SaveTextFile(name, text)
|
||||
if (saved) notify.success(`Saved ${scrubExport ? '(scrubbed) ' : ''}to ${saved}`)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Compiled highlight matchers (recompiled only when rules change).
|
||||
const compiled = useMemo(() => compileRules(hiRules), [hiRules])
|
||||
const highlightFor = useCallback((raw: string): string => {
|
||||
for (const c of compiled) if (c.test(raw)) return c.style
|
||||
return ''
|
||||
}, [compiled])
|
||||
|
||||
const addRule = () => {
|
||||
if (!newPattern.trim()) return
|
||||
const rule: HighlightRule = {
|
||||
id: `${Date.now()}-${Math.round(Math.random() * 1e6)}`,
|
||||
pattern: newPattern.trim(), mode: newMode, color: newColor,
|
||||
}
|
||||
const next = [...hiRules, rule]
|
||||
setHiRules(next); saveHighlightRules(next); setNewPattern('')
|
||||
}
|
||||
const removeRule = (id: string) => {
|
||||
const next = hiRules.filter(r => r.id !== id)
|
||||
setHiRules(next); saveHighlightRules(next)
|
||||
}
|
||||
|
||||
const filteredLines = lines.filter(line => {
|
||||
|
|
@ -149,11 +218,22 @@ export default function ViewLogcat() {
|
|||
value={buffer}
|
||||
onChange={e => setBuffer(e.target.value)}
|
||||
disabled={running}
|
||||
title="Log buffer"
|
||||
>
|
||||
{BUFFERS.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Refresh rate (UI flush interval) */}
|
||||
<select
|
||||
className="input text-xs w-20 py-1"
|
||||
value={refreshMs}
|
||||
onChange={e => setRefreshMs(Number(e.target.value))}
|
||||
title="Refresh rate — how often the view updates"
|
||||
>
|
||||
{REFRESH_OPTS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Start/Stop */}
|
||||
{!running ? (
|
||||
<button onClick={start} className="btn-primary text-xs">
|
||||
|
|
@ -169,9 +249,31 @@ export default function ViewLogcat() {
|
|||
<Trash2 size={12} /> Clear
|
||||
</button>
|
||||
|
||||
<button onClick={saveLog} disabled={lines.length === 0} className="btn-ghost text-xs">
|
||||
<Download size={12} /> Save
|
||||
<button onClick={saveLog} disabled={filteredLines.length === 0} className="btn-ghost text-xs" title={scrubExport ? 'Save visible lines to .txt (sensitive IDs scrubbed)' : 'Save visible lines to .txt'}>
|
||||
<Download size={12} /> Save .txt
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs text-text-muted cursor-pointer" title="Redact IMEIs, phone numbers, SIM serials, MACs and emails from the exported file">
|
||||
<input type="checkbox" checked={scrubExport} onChange={e => setScrubExport(e.target.checked)} className="accent-accent-green" />
|
||||
Scrub
|
||||
</label>
|
||||
|
||||
{/* Text / Map view toggle */}
|
||||
<div className="flex rounded overflow-hidden border border-bg-border ml-1">
|
||||
<button
|
||||
onClick={() => setViewMode('text')}
|
||||
className={`px-2 py-1 text-xs flex items-center gap-1 ${viewMode === 'text' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}
|
||||
title="Text log"
|
||||
>
|
||||
<List size={12} /> Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={`px-2 py-1 text-xs flex items-center gap-1 ${viewMode === 'map' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}
|
||||
title="Live visual map"
|
||||
>
|
||||
<Share2 size={12} /> Map
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-bg-border" />
|
||||
|
||||
|
|
@ -191,6 +293,15 @@ export default function ViewLogcat() {
|
|||
<ChevronDown size={10} className={showFilters ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowHighlights(v => !v)}
|
||||
className={`btn-ghost text-xs ${showHighlights ? 'text-accent-green' : ''}`}
|
||||
title="Highlight rules — colour lines that match a pattern"
|
||||
>
|
||||
<Highlighter size={12} /> Highlight{hiRules.length > 0 ? ` (${hiRules.length})` : ''}
|
||||
<ChevronDown size={10} className={showHighlights ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Status */}
|
||||
|
|
@ -209,11 +320,12 @@ export default function ViewLogcat() {
|
|||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-4 bg-bg-raised shrink-0 flex-wrap">
|
||||
{/* Level filter */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-text-muted">Level:</span>
|
||||
{['V', 'D', 'I', 'W', 'E', 'F'].map(level => (
|
||||
<span className="text-xs text-text-muted cursor-help" title="Android log severity: V=Verbose, D=Debug, I=Info, W=Warning, E=Error, F=Fatal. Click letters to filter.">Level:</span>
|
||||
{([['V', 'Verbose'], ['D', 'Debug'], ['I', 'Info'], ['W', 'Warning'], ['E', 'Error'], ['F', 'Fatal']] as const).map(([level, name]) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => toggleLevel(level)}
|
||||
title={`${name}${levelFilter.includes(level) ? ' (filtering)' : ''} — click to ${levelFilter.includes(level) ? 'remove' : 'show only'} this level`}
|
||||
className={`w-6 h-6 rounded text-xs font-mono font-bold transition-colors ${
|
||||
levelFilter.includes(level)
|
||||
? 'bg-accent-green/20 text-accent-green'
|
||||
|
|
@ -236,6 +348,14 @@ export default function ViewLogcat() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Max lines kept in memory */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">Max lines:</span>
|
||||
<select className="input text-xs w-24" value={maxLines} onChange={e => setMaxLines(Number(e.target.value))}>
|
||||
{MAX_LINE_OPTS.map(n => <option key={n} value={n}>{n.toLocaleString()}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* ADB filter string */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">ADB filter:</span>
|
||||
|
|
@ -259,33 +379,92 @@ export default function ViewLogcat() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlight rules panel */}
|
||||
{showHighlights && (
|
||||
<div className="border-b border-bg-border px-4 py-2 bg-bg-raised shrink-0 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-text-muted">Add rule:</span>
|
||||
<input
|
||||
className="input text-xs w-56"
|
||||
placeholder="Text or /regex/ to match, e.g. FATAL"
|
||||
value={newPattern}
|
||||
onChange={e => setNewPattern(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addRule()}
|
||||
/>
|
||||
<select className="input text-xs w-24 py-1" value={newMode} onChange={e => setNewMode(e.target.value as 'contains' | 'regex')}>
|
||||
<option value="contains">contains</option>
|
||||
<option value="regex">regex</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1">
|
||||
{HI_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewColor(c)}
|
||||
title={c}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-transform ${newColor === c ? 'border-text-primary scale-110' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: HI_SWATCH[c] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addRule} disabled={!newPattern.trim()} className="btn-ghost text-xs">
|
||||
<Plus size={12} /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hiRules.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">
|
||||
No highlight rules. Add one to colour matching lines (e.g. "FATAL" → red). Rules are saved and applied live.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hiRules.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-1.5 rounded border border-bg-border px-2 py-1" style={{ backgroundColor: `${HI_SWATCH[r.color]}22` }}>
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: HI_SWATCH[r.color] }} />
|
||||
<span className="mono text-xs text-text-primary">{r.pattern}</span>
|
||||
<span className="text-[10px] text-text-muted">{r.mode}</span>
|
||||
<button onClick={() => removeRule(r.id)} className="text-text-muted hover:text-danger" title="Remove rule">
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual map — kept mounted so it keeps ingesting the stream; hidden in text mode */}
|
||||
<LogcatMap running={running} registerSink={registerMapSink} onInspectEntity={inspectEntity} hidden={viewMode !== 'map'} search={search} />
|
||||
|
||||
{/* Log output */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-auto bg-bg-base p-2 font-mono text-xs"
|
||||
className={`flex-1 overflow-auto bg-bg-base p-2 font-mono text-xs ${viewMode === 'map' ? 'hidden' : ''}`}
|
||||
>
|
||||
{filteredLines.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-text-muted">
|
||||
{running ? 'Waiting for log output...' : 'Press Start to begin streaming logcat'}
|
||||
</div>
|
||||
)}
|
||||
{filteredLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-2 px-1 py-0.5 rounded leading-relaxed hover:bg-bg-raised ${LEVEL_BG[line.level] || ''}`}
|
||||
>
|
||||
<span className="text-text-muted shrink-0 w-20 truncate">{line.time}</span>
|
||||
<span className="text-text-muted shrink-0 w-10 truncate">{line.pid}</span>
|
||||
<span className={`shrink-0 w-4 font-bold ${LEVEL_COLORS[line.level] || 'text-text-muted'}`}>
|
||||
{line.level}
|
||||
</span>
|
||||
<span className="text-warn shrink-0 w-32 truncate">{line.tag}</span>
|
||||
<span className={`flex-1 break-all ${LEVEL_COLORS[line.level] || 'text-text-secondary'}`}>
|
||||
{line.message || line.raw}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{filteredLines.map((line, i) => {
|
||||
const hi = highlightFor(line.raw)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-2 px-1 py-0.5 rounded leading-relaxed hover:bg-bg-raised ${hi || LEVEL_BG[line.level] || ''}`}
|
||||
>
|
||||
<span className="text-text-muted shrink-0 w-20 truncate">{line.time}</span>
|
||||
<span className="text-text-muted shrink-0 w-10 truncate">{line.pid}</span>
|
||||
<span className={`shrink-0 w-4 font-bold ${LEVEL_COLORS[line.level] || 'text-text-muted'}`}>
|
||||
{line.level}
|
||||
</span>
|
||||
<span className="text-warn shrink-0 w-32 truncate">{line.tag}</span>
|
||||
<span className={`flex-1 break-all ${hi ? '' : LEVEL_COLORS[line.level] || 'text-text-secondary'}`}>
|
||||
{line.message || line.raw}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
SelectFileForInstall, InstallPackage
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import type { PackageInfo } from '../../lib/types'
|
||||
|
||||
type Filter = 'all' | 'user' | 'system'
|
||||
|
|
@ -57,6 +58,7 @@ export default function ViewPackages() {
|
|||
|
||||
const batchOp = async (label: string, op: (pkgs: string[]) => Promise<string>) => {
|
||||
if (selected.size === 0) { notify.error('Select packages first'); return }
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
const id = notify.loading(`${label} ${selected.size} package(s)...`)
|
||||
try {
|
||||
const out = await op([...selected])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Zap, AlertTriangle, FolderOpen, Check, X, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { GetFastbootDevices, Reboot } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import type { Device } from '../../lib/types'
|
||||
|
||||
type StepStatus = 'waiting' | 'running' | 'done' | 'error' | 'skipped'
|
||||
|
|
@ -143,36 +144,56 @@ export default function ViewPixelFlasher() {
|
|||
}
|
||||
}
|
||||
|
||||
const loadZip = async (path: string) => {
|
||||
if (!path) return
|
||||
if (!path.toLowerCase().endsWith('.zip')) {
|
||||
notify.error('Please choose a Pixel factory image .zip')
|
||||
return
|
||||
}
|
||||
setFactoryZip(path)
|
||||
setSteps([])
|
||||
setParsedSteps([])
|
||||
setLog([])
|
||||
setDone(false)
|
||||
// Read flash-all.sh from inside the zip using Go backend
|
||||
try {
|
||||
// @ts-ignore
|
||||
const content: string = await window['go']['main']['App']['ReadFileFromZip'](path, 'flash-all.sh')
|
||||
if (content) {
|
||||
const parsed = parseFlashAllSh(content)
|
||||
setParsedSteps(parsed)
|
||||
setSteps(buildSteps(parsed, opts))
|
||||
addLog(`Parsed flash-all.sh: ${parsed.length} steps found`)
|
||||
} else {
|
||||
addLog('Warning: flash-all.sh not found in zip — is this a valid Pixel factory image?')
|
||||
}
|
||||
} catch {
|
||||
addLog('Could not read flash-all.sh from zip. Make sure this is an extracted factory image folder or valid zip.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectZip = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const path: string = await window['go']['main']['App']['SelectFileForFlash']()
|
||||
if (!path) return
|
||||
setFactoryZip(path)
|
||||
setSteps([])
|
||||
setParsedSteps([])
|
||||
setLog([])
|
||||
setDone(false)
|
||||
// Read flash-all.sh from inside the zip using Go backend
|
||||
try {
|
||||
// @ts-ignore
|
||||
const content: string = await window['go']['main']['App']['ReadFileFromZip'](path, 'flash-all.sh')
|
||||
if (content) {
|
||||
const parsed = parseFlashAllSh(content)
|
||||
setParsedSteps(parsed)
|
||||
setSteps(buildSteps(parsed, opts))
|
||||
addLog(`Parsed flash-all.sh: ${parsed.length} steps found`)
|
||||
} else {
|
||||
addLog('Warning: flash-all.sh not found in zip — is this a valid Pixel factory image?')
|
||||
}
|
||||
} catch {
|
||||
addLog('Could not read flash-all.sh from zip. Make sure this is an extracted factory image folder or valid zip.')
|
||||
}
|
||||
await loadZip(path)
|
||||
} catch (e: any) {
|
||||
notify.error('Could not open file dialog')
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop a factory .zip onto the drop target below to auto-load it.
|
||||
useEffect(() => {
|
||||
const rt = (window as any)['runtime']
|
||||
rt?.OnFileDrop?.((_x: number, _y: number, paths: string[]) => {
|
||||
const zip = (paths || []).find(p => p.toLowerCase().endsWith('.zip'))
|
||||
if (zip) loadZip(zip)
|
||||
else if (paths?.length) notify.error('Drop a Pixel factory image .zip')
|
||||
}, true)
|
||||
return () => rt?.OnFileDropOff?.()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opts])
|
||||
|
||||
const updateOpts = (newOpts: FlashOptions) => {
|
||||
setOpts(newOpts)
|
||||
if (parsedSteps.length > 0) {
|
||||
|
|
@ -342,7 +363,7 @@ export default function ViewPixelFlasher() {
|
|||
<h1 className="text-base font-medium text-text-primary">Pixel Factory Flash</h1>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-3 bg-danger/5 border border-danger/20 rounded-lg px-4 py-3 shrink-0">
|
||||
<DismissibleBanner id="warn-pixelflasher" className="bg-danger/5 border border-danger/20 rounded-lg px-4 py-3 shrink-0 text-danger">
|
||||
<AlertTriangle size={16} className="text-danger shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-danger/90 space-y-1">
|
||||
<p className="font-medium">This will completely overwrite your device firmware.</p>
|
||||
|
|
@ -354,7 +375,7 @@ export default function ViewPixelFlasher() {
|
|||
Bootloader must be unlocked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{/* Left: config */}
|
||||
|
|
@ -379,17 +400,17 @@ export default function ViewPixelFlasher() {
|
|||
</div>
|
||||
|
||||
{/* Factory image */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="card p-4 space-y-3" style={{ '--wails-drop-target': 'drop' } as React.CSSProperties}>
|
||||
<p className="section-title">Factory Image Zip</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Extract the outer zip from Google, then select the inner <span className="mono">device-build-factory-*.zip</span>
|
||||
Extract the outer zip from Google, then select the inner <span className="mono">device-build-factory-*.zip</span> — or <span className="text-text-secondary">drag & drop a .zip anywhere on this panel</span>.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1 mono"
|
||||
value={factoryZip}
|
||||
readOnly
|
||||
placeholder="Select factory image zip..."
|
||||
placeholder="Select or drop a factory image zip..."
|
||||
/>
|
||||
<button onClick={handleSelectZip} className="btn-ghost text-xs shrink-0">
|
||||
<FolderOpen size={13} /> Browse
|
||||
|
|
|
|||
281
frontend/src/components/views/ViewScreenMirror.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { MonitorSmartphone, Play, Square, Check, AlertTriangle, Camera } from 'lucide-react'
|
||||
import { ScrcpyAvailable, ScrcpyRunning, StartScrcpy, StopScrcpy, CaptureScreenshot } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
|
||||
// Wails runtime is injected on window['runtime'] (same access as ViewLogcat).
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
interface Options {
|
||||
maxSize: number
|
||||
bitRateMbps: number
|
||||
maxFps: number
|
||||
stayAwake: boolean
|
||||
turnScreenOff: boolean
|
||||
showTouches: boolean
|
||||
alwaysOnTop: boolean
|
||||
fullscreen: boolean
|
||||
borderless: boolean
|
||||
record: boolean
|
||||
detached: boolean
|
||||
noAudio: boolean
|
||||
viewOnly: boolean
|
||||
videoCodec: string
|
||||
orientation: string
|
||||
}
|
||||
|
||||
const DEFAULTS: Options = {
|
||||
maxSize: 0, bitRateMbps: 8, maxFps: 60,
|
||||
stayAwake: true, turnScreenOff: false, showTouches: false,
|
||||
alwaysOnTop: false, fullscreen: false, borderless: false, record: false, detached: false,
|
||||
noAudio: false, viewOnly: false, videoCodec: '', orientation: '',
|
||||
}
|
||||
|
||||
export default function ViewScreenMirror() {
|
||||
const [available, setAvailable] = useState<string | null>(null)
|
||||
const [missing, setMissing] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [opts, setOpts] = useState<Options>(DEFAULTS)
|
||||
|
||||
useEffect(() => {
|
||||
ScrcpyAvailable().then(setAvailable).catch((e: any) => setMissing(String(e)))
|
||||
ScrcpyRunning().then(setRunning).catch(() => {})
|
||||
const off = rt()?.EventsOn?.('scrcpy:stopped', () => setRunning(false))
|
||||
return () => off?.()
|
||||
}, [])
|
||||
|
||||
const set = <K extends keyof Options>(k: K, v: Options[K]) => setOpts(o => ({ ...o, [k]: v }))
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true)
|
||||
try {
|
||||
await StartScrcpy(opts)
|
||||
setRunning(true)
|
||||
notify.success('Mirror started — the window opens separately and can be moved anywhere')
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
try {
|
||||
await StopScrcpy()
|
||||
setRunning(false)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const screenshot = async () => {
|
||||
try {
|
||||
const path = await CaptureScreenshot()
|
||||
if (path) notify.success(`Saved ${path}`)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 h-full overflow-auto max-w-2xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorSmartphone size={18} className="text-accent-green" />
|
||||
<h1 className="text-base font-medium text-text-primary">Screen Mirror</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted leading-relaxed">
|
||||
Mirror and control your phone on your computer. The mirror opens in its own
|
||||
window you can move, resize, and snap anywhere — drive the phone with your
|
||||
mouse and keyboard. Powered by scrcpy.
|
||||
</p>
|
||||
|
||||
{/* Availability */}
|
||||
{available && (
|
||||
<div className="card p-3 flex items-center gap-2 text-xs">
|
||||
<Check size={14} className="text-accent-green shrink-0" />
|
||||
<span className="text-text-secondary">{available} detected</span>
|
||||
</div>
|
||||
)}
|
||||
{missing && (
|
||||
<div className="card p-3 flex items-start gap-2 text-xs border-warn/30">
|
||||
<AlertTriangle size={14} className="text-warn shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-text-secondary">scrcpy isn't installed.</p>
|
||||
<p className="text-text-muted mt-1">Install it with <span className="mono">sudo apt install scrcpy</span>, then reopen this view.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Options</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Select label="Max resolution" value={opts.maxSize} onChange={v => set('maxSize', v)}
|
||||
options={[[0, 'Original'], [1920, '1920'], [1280, '1280'], [1024, '1024'], [800, '800']]} />
|
||||
<Select label="Bitrate (Mbps)" value={opts.bitRateMbps} onChange={v => set('bitRateMbps', v)}
|
||||
options={[[2, '2'], [4, '4'], [8, '8'], [16, '16'], [32, '32']]} />
|
||||
<Select label="Max FPS" value={opts.maxFps} onChange={v => set('maxFps', v)}
|
||||
options={[[0, 'Unlimited'], [30, '30'], [60, '60'], [120, '120']]} />
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">Video codec</span>
|
||||
<select className="input text-xs w-full mt-1" value={opts.videoCodec} onChange={e => set('videoCodec', e.target.value)}>
|
||||
{[['', 'Auto'], ['h264', 'H.264'], ['h265', 'H.265'], ['av1', 'AV1']].map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">Orientation</span>
|
||||
<select className="input text-xs w-full mt-1" value={opts.orientation} onChange={e => set('orientation', e.target.value)}>
|
||||
{[['', 'Auto'], ['0', '0°'], ['90', '90°'], ['180', '180°'], ['270', '270°']].map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 pt-1">
|
||||
<Toggle label="Keep phone awake" on={opts.stayAwake} onChange={v => set('stayAwake', v)} />
|
||||
<Toggle label="Turn phone screen off" on={opts.turnScreenOff} onChange={v => set('turnScreenOff', v)} />
|
||||
<Toggle label="Show touches on phone" on={opts.showTouches} onChange={v => set('showTouches', v)} />
|
||||
<Toggle label="Always on top" on={opts.alwaysOnTop} onChange={v => set('alwaysOnTop', v)} />
|
||||
<Toggle label="Borderless (no title bar)" on={opts.borderless} onChange={v => set('borderless', v)} />
|
||||
<Toggle label="Start fullscreen" on={opts.fullscreen} onChange={v => set('fullscreen', v)} />
|
||||
<Toggle label="Mute audio" on={opts.noAudio} onChange={v => set('noAudio', v)} />
|
||||
<Toggle label="View only (no control)" on={opts.viewOnly} onChange={v => set('viewOnly', v)} />
|
||||
<Toggle label="Record to file" on={opts.record} onChange={v => set('record', v)} />
|
||||
</div>
|
||||
|
||||
<div className="pt-2 mt-1 border-t border-bg-border flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Keep running after ATK closes</p>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">Detaches the mirror — quitting ATK won't close it. It'll show up here again next time you open ATK.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => set('detached', !opts.detached)}
|
||||
role="switch"
|
||||
aria-checked={opts.detached}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${opts.detached ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${opts.detached ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capture */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Capture</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={screenshot} className="btn-ghost text-sm shrink-0">
|
||||
<Camera size={14} /> Screenshot
|
||||
</button>
|
||||
<span className="text-xs text-text-muted">Saves the phone's current screen as a PNG. Works anytime a device is connected — no mirror needed.</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted leading-relaxed border-t border-bg-border pt-2">
|
||||
<span className="text-text-secondary">Screen recording:</span> enable “Record to file” above, then Start — a save dialog asks <span className="text-text-secondary">where to save the .mp4</span> (pick any folder/name). It records the whole session and finalizes the file when you Stop the mirror (or close its window). Perfect for repro clips.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{running ? (
|
||||
<button onClick={stop} className="btn-danger text-sm">
|
||||
<Square size={14} /> Stop mirror
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={start} disabled={starting || !!missing} className="btn-primary text-sm">
|
||||
<Play size={14} /> {starting ? 'Starting…' : 'Start mirror'}
|
||||
</button>
|
||||
)}
|
||||
{running && <span className="text-xs text-accent-green">● Mirroring — check the separate scrcpy window</span>}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-text-muted leading-relaxed">
|
||||
Borderless hides the window's title bar for a clean look. Move it with
|
||||
<span className="text-text-secondary"> Super + drag</span>, and close it with
|
||||
<span className="text-text-secondary"> Stop mirror</span> above (the phone's own
|
||||
title bar can't be themed by ATK — it's drawn by your window manager).
|
||||
</p>
|
||||
|
||||
{/* Shortcut cheat-sheet */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Controls & shortcuts</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
<span className="text-text-secondary">MOD</span> = <Kbd>Left Alt</Kbd> or <Kbd>Super</Kbd> (⊞ / ⌘ key) — use these when a laptop has no middle-click.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5">
|
||||
{SHORTCUTS.map(s => (
|
||||
<div key={s.action} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-text-secondary">{s.action}</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<Kbd>{s.keys}</Kbd>
|
||||
{s.alt && <><span className="text-text-muted text-[10px]">or</span><Kbd>{s.alt}</Kbd></>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SHORTCUTS: { action: string; keys: string; alt?: string }[] = [
|
||||
{ action: 'Home', keys: 'MOD+H', alt: 'Middle-click' },
|
||||
{ action: 'Back', keys: 'MOD+B', alt: 'Right-click' },
|
||||
{ action: 'Tap', keys: 'Left-click' },
|
||||
{ action: 'Long-press / select', keys: 'Click + hold' },
|
||||
{ action: 'Recent apps', keys: 'MOD+S' },
|
||||
{ action: 'App menu', keys: 'MOD+M' },
|
||||
{ action: 'Notifications', keys: 'MOD+N' },
|
||||
{ action: 'Power', keys: 'MOD+P' },
|
||||
{ action: 'Volume up', keys: 'MOD+↑' },
|
||||
{ action: 'Volume down', keys: 'MOD+↓' },
|
||||
{ action: 'Rotate screen', keys: 'MOD+← / →' },
|
||||
{ action: 'Fullscreen', keys: 'MOD+F' },
|
||||
{ action: 'Phone screen off', keys: 'MOD+O' },
|
||||
{ action: 'Phone screen on', keys: 'MOD+⇧+O' },
|
||||
{ action: 'Copy to computer', keys: 'MOD+C' },
|
||||
{ action: 'Paste to phone', keys: 'MOD+V' },
|
||||
{ action: 'Swipe / gesture', keys: 'Click + drag' },
|
||||
{ action: 'Pinch to zoom', keys: 'Ctrl + drag' },
|
||||
]
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-bg-raised border border-bg-border mono text-[10px] text-text-secondary whitespace-nowrap">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
function Select({ label, value, onChange, options }: {
|
||||
label: string; value: number; onChange: (v: number) => void; options: [number, string][]
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">{label}</span>
|
||||
<select
|
||||
className="input text-xs w-full mt-1"
|
||||
value={value}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
>
|
||||
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-text-secondary">{label}</span>
|
||||
<button
|
||||
onClick={() => onChange(!on)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${on ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${on ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,89 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Shield, RefreshCw, Check, AlertTriangle } from 'lucide-react'
|
||||
import { GetBinaryInfo, SetAdbPath, SetFastbootPath } from '../../lib/wails'
|
||||
import { Shield, RefreshCw, Check, AlertTriangle, Palette, Lock } from 'lucide-react'
|
||||
import { GetBinaryInfo, SetAdbPath, SetFastbootPath, AppLockStatus, SetAppPassword, DisableAppLock, SetRequireForDanger } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { refreshAppLockStatus } from '../../lib/applock'
|
||||
import { applyTheme, getTheme, THEMES, type Theme } from '../../lib/theme'
|
||||
import { getCustomAccent, setCustomAccent, getCustomFont, setCustomFont, FONT_OPTIONS } from '../../lib/appearance'
|
||||
import { getSidebarPosition, setSidebarPosition, SIDEBAR_POSITIONS, getSidebarLabels, setSidebarLabels, type SidebarPosition } from '../../lib/layout'
|
||||
import { getRootTools, setRootTools, getHiddenViews, setHiddenViews, TOGGLEABLE_VIEWS, getMuteNoDevice, setMuteNoDevice } from '../../lib/featureflags'
|
||||
import { resetDismissed } from '../../lib/dismissible'
|
||||
|
||||
export default function ViewSettings() {
|
||||
const [binaryInfo, setBinaryInfo] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adbPath, setAdbPath] = useState('')
|
||||
const [fastbootPath, setFastbootPath] = useState('')
|
||||
const [theme, setTheme] = useState<Theme>(getTheme())
|
||||
const [sidebarPos, setSidebarPos] = useState<SidebarPosition>(getSidebarPosition())
|
||||
const [sidebarLabels, setSidebarLabelsState] = useState<boolean>(getSidebarLabels())
|
||||
const [rootTools, setRootToolsState] = useState<boolean>(getRootTools())
|
||||
|
||||
const [customAccent, setCustomAccentState] = useState<string>(getCustomAccent())
|
||||
const [customFont, setCustomFontState] = useState<string>(getCustomFont())
|
||||
|
||||
const changeTheme = (t: Theme) => { setTheme(t); applyTheme(t) }
|
||||
const changeAccent = (hex: string | null) => { setCustomAccentState(hex || ''); setCustomAccent(hex) }
|
||||
const changeFont = (id: string) => { setCustomFontState(id); setCustomFont(id || null) }
|
||||
const changeSidebarPos = (p: SidebarPosition) => { setSidebarPos(p); setSidebarPosition(p) }
|
||||
const changeSidebarLabels = (on: boolean) => { setSidebarLabelsState(on); setSidebarLabels(on) }
|
||||
const changeRootTools = (on: boolean) => { setRootToolsState(on); setRootTools(on) }
|
||||
|
||||
// App lock
|
||||
const [lock, setLock] = useState({ enabled: false, requireForDanger: false })
|
||||
const [pwCurrent, setPwCurrent] = useState('')
|
||||
const [pwNew, setPwNew] = useState('')
|
||||
const [pwConfirm, setPwConfirm] = useState('')
|
||||
const [lockBusy, setLockBusy] = useState(false)
|
||||
|
||||
useEffect(() => { AppLockStatus().then(setLock).catch(() => {}) }, [])
|
||||
|
||||
const reloadLock = async () => {
|
||||
try { setLock(await AppLockStatus()) } catch {}
|
||||
await refreshAppLockStatus() // keep the live danger-gate cache in sync
|
||||
}
|
||||
|
||||
const savePassword = async () => {
|
||||
if (pwNew.length < 4) { notify.error('Password must be at least 4 characters'); return }
|
||||
if (pwNew !== pwConfirm) { notify.error('Passwords do not match'); return }
|
||||
setLockBusy(true)
|
||||
try {
|
||||
await SetAppPassword(lock.enabled ? pwCurrent : '', pwNew)
|
||||
notify.success(lock.enabled ? 'Password changed' : 'App lock enabled')
|
||||
setPwCurrent(''); setPwNew(''); setPwConfirm('')
|
||||
await reloadLock()
|
||||
} catch (e: any) { notify.error(e) } finally { setLockBusy(false) }
|
||||
}
|
||||
|
||||
const removeLock = async () => {
|
||||
if (!confirm('Remove the app password? ATK will open without prompting.')) return
|
||||
setLockBusy(true)
|
||||
try {
|
||||
await DisableAppLock(pwCurrent)
|
||||
notify.success('App lock removed')
|
||||
setPwCurrent(''); setPwNew(''); setPwConfirm('')
|
||||
await reloadLock()
|
||||
} catch (e: any) { notify.error(e) } finally { setLockBusy(false) }
|
||||
}
|
||||
|
||||
const toggleDanger = async (on: boolean) => {
|
||||
if (!pwCurrent) { notify.error('Enter your current password above to change this'); return }
|
||||
setLockBusy(true)
|
||||
try {
|
||||
await SetRequireForDanger(pwCurrent, on)
|
||||
notify.success(on ? 'Destructive actions now require the password' : 'Re-auth on destructive actions turned off')
|
||||
setPwCurrent('')
|
||||
await reloadLock()
|
||||
} catch (e: any) { notify.error(e) } finally { setLockBusy(false) }
|
||||
}
|
||||
|
||||
const [hidden, setHiddenState] = useState<string[]>(getHiddenViews())
|
||||
const [muteND, setMuteND] = useState<boolean>(getMuteNoDevice())
|
||||
const changeMuteND = (on: boolean) => { setMuteND(on); setMuteNoDevice(on) }
|
||||
const toggleFeature = (view: string) => {
|
||||
const next = hidden.includes(view) ? hidden.filter(v => v !== view) : [...hidden, view]
|
||||
setHiddenState(next); setHiddenViews(next)
|
||||
}
|
||||
|
||||
const loadBinaryInfo = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -47,6 +123,269 @@ export default function ViewSettings() {
|
|||
<div className="p-4 space-y-4 h-full overflow-auto max-w-2xl">
|
||||
<h1 className="text-base font-medium text-text-primary">Settings</h1>
|
||||
|
||||
{/* Appearance / theme */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-accent-green" />
|
||||
<p className="section-title">Appearance</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Choose a colour theme. Applies instantly and is remembered.</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{THEMES.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => changeTheme(t.id)}
|
||||
className={`text-left rounded border p-3 transition-colors ${
|
||||
theme === t.id
|
||||
? 'border-accent-green bg-accent-green/10'
|
||||
: 'border-bg-border hover:bg-bg-raised'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-primary">{t.label}</span>
|
||||
{theme === t.id && <Check size={12} className="text-accent-green" />}
|
||||
</div>
|
||||
{/* Swatch preview: base · surface · accent · text */}
|
||||
<div className="flex gap-1 mt-2" aria-hidden="true">
|
||||
{t.swatch.map((c, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-4 flex-1 rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1.5 leading-snug">{t.hint}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom accent colour + font — system-wide overrides on top of the theme */}
|
||||
<div className="pt-1 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1.5">Custom accent colour (overrides the theme accent everywhere)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customAccent || '#a6d189'}
|
||||
onChange={e => changeAccent(e.target.value)}
|
||||
className="h-8 w-12 rounded border border-bg-border bg-bg-raised cursor-pointer p-0.5"
|
||||
title="Pick a custom accent colour"
|
||||
/>
|
||||
<span className="mono text-xs text-text-secondary">{customAccent || 'theme default'}</span>
|
||||
{customAccent && (
|
||||
<button onClick={() => changeAccent(null)} className="btn-ghost text-xs ml-auto">Reset</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1.5">Font (applied app-wide)</p>
|
||||
<select
|
||||
className="input text-xs w-full"
|
||||
value={customFont}
|
||||
onChange={e => changeFont(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map(f => <option key={f.id} value={f.id}>{f.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted pt-1">Sidebar position. Applies instantly and is remembered.</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SIDEBAR_POSITIONS.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => changeSidebarPos(p.id)}
|
||||
className={`text-left rounded border p-3 transition-colors ${
|
||||
sidebarPos === p.id
|
||||
? 'border-accent-green bg-accent-green/10'
|
||||
: 'border-bg-border hover:bg-bg-raised'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-primary">{p.label}</span>
|
||||
{sidebarPos === p.id && <Check size={12} className="text-accent-green" />}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1 leading-snug">{p.hint}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="pr-3">
|
||||
<p className="text-xs font-medium text-text-primary">Show navigation labels</p>
|
||||
<p className="text-xs text-text-muted">Display the name under each sidebar icon (e.g. Dashboard, Files).</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeSidebarLabels(!sidebarLabels)}
|
||||
role="switch"
|
||||
aria-checked={sidebarLabels}
|
||||
title="Toggle navigation labels"
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${
|
||||
sidebarLabels ? 'bg-accent-green' : 'bg-bg-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${
|
||||
sidebarLabels ? 'left-[18px]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="pr-3">
|
||||
<p className="text-xs font-medium text-text-primary">Mute "no device" pop-ups</p>
|
||||
<p className="text-xs text-text-muted">Hide error toasts about a missing / offline / unauthorized device while browsing.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeMuteND(!muteND)}
|
||||
role="switch"
|
||||
aria-checked={muteND}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${muteND ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${muteND ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<p className="text-xs text-text-muted">Restore warnings you've hidden with the ✕ button.</p>
|
||||
<button
|
||||
onClick={() => { resetDismissed(); notify.success('Hidden warnings restored — reopen views to see them') }}
|
||||
className="btn-ghost text-xs shrink-0"
|
||||
>
|
||||
Show hidden warnings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar features kill-switch */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-accent-green" />
|
||||
<p className="section-title">Sidebar Features</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Turn off the tools you don't use to declutter the sidebar. Settings always stays.</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{TOGGLEABLE_VIEWS.map(f => {
|
||||
const on = !hidden.includes(f.view)
|
||||
return (
|
||||
<div key={f.view} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-text-secondary">{f.label}</span>
|
||||
<button
|
||||
onClick={() => toggleFeature(f.view)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${on ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${on ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App lock / security */}
|
||||
<div className="card p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock size={14} className="text-accent-green" />
|
||||
<p className="section-title">App Lock</p>
|
||||
{lock.enabled && <span className="badge-green text-xs">enabled</span>}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Require a password to open ATK. Stored only as a salted scrypt hash — never the password itself.
|
||||
<br />
|
||||
<span className="text-warn">Note:</span> this gates the ATK app so it can't be driven into flashing
|
||||
or uninstalling without the password. It can't stop a compromised computer from running{' '}
|
||||
<span className="mono">adb</span>/<span className="mono">fastboot</span> directly, outside ATK — nothing
|
||||
running as your user can.
|
||||
</p>
|
||||
|
||||
{/* Current password (needed to change/remove or toggle re-auth when a lock exists) */}
|
||||
{lock.enabled && (
|
||||
<input
|
||||
type="password"
|
||||
className="input text-xs w-full"
|
||||
placeholder="Current password"
|
||||
value={pwCurrent}
|
||||
onChange={e => setPwCurrent(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Set / change password */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="password"
|
||||
className="input text-xs w-full"
|
||||
placeholder={lock.enabled ? 'New password' : 'Password'}
|
||||
value={pwNew}
|
||||
onChange={e => setPwNew(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input text-xs w-full"
|
||||
placeholder="Confirm password"
|
||||
value={pwConfirm}
|
||||
onChange={e => setPwConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={savePassword} disabled={lockBusy} className="btn-primary text-xs">
|
||||
{lock.enabled ? 'Change password' : 'Enable app lock'}
|
||||
</button>
|
||||
{lock.enabled && (
|
||||
<button onClick={removeLock} disabled={lockBusy} className="btn-ghost text-xs text-danger">
|
||||
Remove app lock
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional: re-auth before destructive actions */}
|
||||
{lock.enabled && (
|
||||
<div className="flex items-center justify-between gap-3 pt-2 border-t border-bg-border/50">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">Require password for destructive actions</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Re-prompt before flashing, uninstalling/debloating, and Magisk installs. Enter your current
|
||||
password above first. Stays unlocked for a few minutes after each confirmation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleDanger(!lock.requireForDanger)}
|
||||
role="switch"
|
||||
aria-checked={lock.requireForDanger}
|
||||
disabled={lockBusy}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${lock.requireForDanger ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${lock.requireForDanger ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced / root tools */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-warn" />
|
||||
<p className="section-title">Advanced</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">Enable rooting tools (Magisk patching)</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">Adds a Magisk boot-patching panel to the Flasher for rooting. Off by default — these operations can wipe or brick a device if misused.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeRootTools(!rootTools)}
|
||||
role="switch"
|
||||
aria-checked={rootTools}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${rootTools ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${rootTools ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Binary trust section */}
|
||||
<div className="card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Terminal, Trash2, ChevronRight } from 'lucide-react'
|
||||
import { RunShellCommand, RunAdbHostCommand } from '../../lib/wails'
|
||||
import { Terminal, Trash2, ChevronRight, ChevronDown, Library, Search, Copy, Save } from 'lucide-react'
|
||||
import { RunShellCommand, RunAdbHostCommand, SaveTextFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { CodeView, detectLang } from '../../lib/syntax'
|
||||
import { CATEGORIES, type Command } from './ViewUtilities'
|
||||
|
||||
interface HistoryEntry {
|
||||
cmd: string
|
||||
|
|
@ -11,13 +14,16 @@ interface HistoryEntry {
|
|||
|
||||
export default function ViewShell() {
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([
|
||||
{ cmd: '', output: 'ADBKit Shell — commands run via adb shell (no pipes/redirects — args are split directly, no shell injection)\nSwitch to "adb" mode to run adb host commands (e.g. adb devices, adb logcat)', mode: 'shell' }
|
||||
{ cmd: '', output: 'Commands run via adb shell (no pipes/redirects — args are split directly, no shell injection)\nSwitch to "adb" mode to run adb host commands (e.g. adb devices, adb logcat)\nClick "Commands" to browse the command library and drop one into the prompt.', mode: 'shell' }
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [mode, setMode] = useState<'shell' | 'adb'>('shell')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cmdHistory, setCmdHistory] = useState<string[]>([])
|
||||
const [historyIdx, setHistoryIdx] = useState(-1)
|
||||
const [showLib, setShowLib] = useState(false)
|
||||
const [libSearch, setLibSearch] = useState('')
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
|
|
@ -68,82 +74,206 @@ export default function ViewShell() {
|
|||
}
|
||||
}
|
||||
|
||||
// Library commands are stored as adb host args (e.g. "shell getprop ..."),
|
||||
// so dropping one into the prompt = adb host mode + the full string. That way
|
||||
// the user never has to pick shell-vs-host; it's set for them.
|
||||
const pickCommand = (cmd: Command) => {
|
||||
setMode('adb')
|
||||
setInput(cmd.cmd)
|
||||
setHistoryIdx(-1)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const toggleCat = (name: string) => {
|
||||
setOpenCats(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(name) ? next.delete(name) : next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Whole-session transcript: each command and its output, blank-line separated.
|
||||
const transcript = () =>
|
||||
history.map(e => (e.cmd ? `[${e.mode}]$ ${e.cmd}\n` : '') + e.output).join('\n\n').trim()
|
||||
|
||||
const hasSession = history.some(e => e.cmd)
|
||||
|
||||
const copyAll = async () => {
|
||||
await navigator.clipboard?.writeText(transcript())
|
||||
notify.success('Session copied to clipboard')
|
||||
}
|
||||
|
||||
const exportSession = async () => {
|
||||
try {
|
||||
const path = await SaveTextFile('atk-shell-session.txt', transcript())
|
||||
if (path) notify.success(`Saved to ${path}`)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const q = libSearch.toLowerCase()
|
||||
const filteredCats = CATEGORIES.map(cat => ({
|
||||
...cat,
|
||||
commands: q
|
||||
? cat.commands.filter(c => c.label.toLowerCase().includes(q) || c.cmd.toLowerCase().includes(q))
|
||||
: cat.commands,
|
||||
})).filter(cat => cat.commands.length > 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-3 shrink-0">
|
||||
<Terminal size={14} className="text-accent-green" />
|
||||
<span className="text-xs text-text-muted">Mode:</span>
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5">
|
||||
{(['shell', 'adb'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
mode === m ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{m === 'shell' ? 'adb shell' : 'adb host'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setHistory([{ cmd: '', output: 'Terminal cleared.', mode }])}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Trash2 size={12} /> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div
|
||||
className="flex-1 overflow-auto p-4 font-mono text-xs space-y-3 bg-bg-base cursor-text"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{history.map((entry, i) => (
|
||||
<div key={i}>
|
||||
{entry.cmd && (
|
||||
<div className="flex items-center gap-2 text-accent-green mb-1">
|
||||
<span className="text-text-muted">[{entry.mode}]$</span>
|
||||
<span>{entry.cmd}</span>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Command library panel */}
|
||||
{showLib && (
|
||||
<div className="w-72 shrink-0 border-r border-bg-border flex flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-bg-border shrink-0 space-y-2">
|
||||
<p className="section-title">Command Library</p>
|
||||
<p className="text-text-muted text-xs">Click to drop into the prompt (sets adb-host mode). Fill any <span className="badge-yellow text-xs">args</span> tokens, then Enter.</p>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input text-xs w-full pl-6"
|
||||
placeholder="Search commands..."
|
||||
value={libSearch}
|
||||
onChange={e => { setLibSearch(e.target.value); if (e.target.value) setOpenCats(new Set(CATEGORIES.map(c => c.name))) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filteredCats.map(cat => (
|
||||
<div key={cat.name} className="border-b border-bg-border/40">
|
||||
<button
|
||||
onClick={() => toggleCat(cat.name)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-bg-raised transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{openCats.has(cat.name)
|
||||
? <ChevronDown size={12} className="text-accent-green shrink-0" />
|
||||
: <ChevronRight size={12} className="text-text-muted shrink-0" />}
|
||||
<span className="text-xs font-medium text-text-primary">{cat.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{cat.commands.length}</span>
|
||||
</button>
|
||||
{openCats.has(cat.name) && (
|
||||
<div className="pb-1">
|
||||
{cat.commands.map(cmd => (
|
||||
<div
|
||||
key={cmd.label}
|
||||
onClick={() => pickCommand(cmd)}
|
||||
className="flex items-start gap-1 mx-2 rounded px-2 py-1.5 hover:bg-bg-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs text-text-secondary truncate">{cmd.label}</p>
|
||||
{cmd.needsInput && <span className="badge-yellow shrink-0 text-xs">args</span>}
|
||||
</div>
|
||||
<p className="text-xs mono text-text-muted truncate leading-tight mt-0.5">{cmd.cmd}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
className={`whitespace-pre-wrap break-words leading-relaxed ${
|
||||
entry.error ? 'text-danger' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{entry.output}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="animate-pulse">▌</span>
|
||||
<span>Running...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-surface shrink-0">
|
||||
<span className="text-accent-green font-mono text-xs shrink-0">[{mode}]$</span>
|
||||
<ChevronRight size={12} className="text-text-muted shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-text-primary font-mono text-xs focus:outline-none placeholder:text-text-muted"
|
||||
placeholder={mode === 'shell' ? 'ls /sdcard' : 'devices'}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-3 h-3 border border-accent-green border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
)}
|
||||
{/* Terminal */}
|
||||
<div className="flex flex-col h-full flex-1 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-3 shrink-0">
|
||||
<Terminal size={14} className="text-accent-green" />
|
||||
<span className="text-xs text-text-muted">Mode:</span>
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5">
|
||||
{(['shell', 'adb'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
mode === m ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{m === 'shell' ? 'adb shell' : 'adb host'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setShowLib(s => !s)}
|
||||
className={`btn-ghost text-xs ${showLib ? 'text-accent-green' : ''}`}
|
||||
>
|
||||
<Library size={12} /> Commands
|
||||
</button>
|
||||
<button onClick={copyAll} disabled={!hasSession} className="btn-ghost text-xs">
|
||||
<Copy size={12} /> Copy all
|
||||
</button>
|
||||
<button onClick={exportSession} disabled={!hasSession} className="btn-ghost text-xs">
|
||||
<Save size={12} /> Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHistory([{ cmd: '', output: 'Terminal cleared.', mode }])}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Trash2 size={12} /> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div
|
||||
className="flex-1 overflow-auto p-4 font-mono text-xs space-y-3 bg-bg-base cursor-text"
|
||||
// Only refocus the prompt on a bare click — if the user has selected
|
||||
// output text, stealing focus would collapse the highlight (and leave
|
||||
// the right-click menu with nothing to copy).
|
||||
onClick={() => { if (!window.getSelection()?.toString()) inputRef.current?.focus() }}
|
||||
>
|
||||
{history.map((entry, i) => (
|
||||
<div key={i}>
|
||||
{entry.cmd && (
|
||||
<div className="flex items-center gap-2 text-accent-green mb-1">
|
||||
<span className="text-text-muted">[{entry.mode}]$</span>
|
||||
<span>{entry.cmd}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.error ? (
|
||||
<pre className="whitespace-pre-wrap break-words leading-relaxed text-danger">
|
||||
{entry.output}
|
||||
</pre>
|
||||
) : (
|
||||
<CodeView
|
||||
code={entry.output}
|
||||
lang={detectLang('', entry.output) === 'text' ? 'log' : detectLang('', entry.output)}
|
||||
className="whitespace-pre-wrap break-words leading-relaxed text-text-secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="animate-pulse">▌</span>
|
||||
<span>Running...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-surface shrink-0">
|
||||
<span className="text-accent-green font-mono text-xs shrink-0">[{mode}]$</span>
|
||||
<ChevronRight size={12} className="text-text-muted shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-text-primary font-mono text-xs focus:outline-none placeholder:text-text-muted"
|
||||
placeholder={mode === 'shell' ? 'ls /sdcard' : 'devices'}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-3 h-3 border border-accent-green border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import { FileText, Wrench, ChevronDown, ChevronRight, Play, Copy, Check } from '
|
|||
import { Reboot, RunAdbHostCommand } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
|
||||
interface Command {
|
||||
export interface Command {
|
||||
label: string
|
||||
cmd: string
|
||||
needsInput?: { placeholder: string; token: string }[]
|
||||
}
|
||||
|
||||
interface Category {
|
||||
export interface Category {
|
||||
name: string
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
export const CATEGORIES: Category[] = [
|
||||
// ─────────────────────────────────────────────
|
||||
{
|
||||
name: 'Device Info',
|
||||
|
|
@ -796,13 +796,371 @@ const CATEGORIES: Category[] = [
|
|||
{ label: 'Remount system (root)', cmd: 'remount' },
|
||||
],
|
||||
},
|
||||
|
||||
// ════════════════ EXPANDED CATEGORIES ════════════════
|
||||
{
|
||||
name: 'App Ops & Privacy',
|
||||
commands: [
|
||||
{ label: 'All app-ops for a package', cmd: 'shell appops get <package>',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
{ label: 'Dump full appops service', cmd: 'shell dumpsys appops' },
|
||||
{ label: 'Apps allowed a given op', cmd: 'shell appops query-op <op> allow',
|
||||
needsInput: [{ placeholder: 'CAMERA', token: '<op>' }] },
|
||||
{ label: 'Set op → allow', cmd: 'shell appops set <package> <op> allow',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }, { placeholder: 'CAMERA', token: '<op>' }] },
|
||||
{ label: 'Set op → deny', cmd: 'shell appops set <package> <op> deny',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }, { placeholder: 'CAMERA', token: '<op>' }] },
|
||||
{ label: 'Set op → ignore', cmd: 'shell appops set <package> <op> ignore',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }, { placeholder: 'CAMERA', token: '<op>' }] },
|
||||
{ label: 'Reset all ops for a package', cmd: 'shell appops reset <package>',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
{ label: 'Background run access', cmd: 'shell appops get <package> RUN_ANY_IN_BACKGROUND',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Display & Screen',
|
||||
commands: [
|
||||
{ label: 'Current resolution', cmd: 'shell wm size' },
|
||||
{ label: 'Override resolution', cmd: 'shell wm size <WxH>',
|
||||
needsInput: [{ placeholder: '1080x2400', token: '<WxH>' }] },
|
||||
{ label: 'Reset resolution', cmd: 'shell wm size reset' },
|
||||
{ label: 'Current density (DPI)', cmd: 'shell wm density' },
|
||||
{ label: 'Override density', cmd: 'shell wm density <dpi>',
|
||||
needsInput: [{ placeholder: '420', token: '<dpi>' }] },
|
||||
{ label: 'Reset density', cmd: 'shell wm density reset' },
|
||||
{ label: 'Displays (dumpsys display)', cmd: 'shell dumpsys display' },
|
||||
{ label: 'SurfaceFlinger state', cmd: 'shell dumpsys SurfaceFlinger' },
|
||||
{ label: 'Force rotation (0-3)', cmd: 'shell settings put system user_rotation <0-3>',
|
||||
needsInput: [{ placeholder: '0', token: '<0-3>' }] },
|
||||
{ label: 'Disable auto-rotate', cmd: 'shell settings put system accelerometer_rotation 0' },
|
||||
{ label: 'Enable auto-rotate', cmd: 'shell settings put system accelerometer_rotation 1' },
|
||||
{ label: 'Screen-off timeout (ms)', cmd: 'shell settings put system screen_off_timeout <ms>',
|
||||
needsInput: [{ placeholder: '600000', token: '<ms>' }] },
|
||||
{ label: 'Wake screen', cmd: 'shell input keyevent KEYCODE_WAKEUP' },
|
||||
{ label: 'Sleep screen', cmd: 'shell input keyevent KEYCODE_SLEEP' },
|
||||
{ label: 'Stay awake while charging', cmd: 'shell settings put global stay_on_while_plugged_in 3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Screen Capture & Recording',
|
||||
commands: [
|
||||
{ label: 'Screenshot to /sdcard', cmd: 'shell screencap -p /sdcard/atk_screen.png' },
|
||||
{ label: 'Record screen 10s to /sdcard', cmd: 'shell screenrecord --time-limit 10 /sdcard/atk_rec.mp4' },
|
||||
{ label: 'Record with bit-rate', cmd: 'shell screenrecord --bit-rate 8000000 --time-limit 10 /sdcard/atk_rec.mp4' },
|
||||
{ label: 'Record at size', cmd: 'shell screenrecord --size <WxH> --time-limit 10 /sdcard/atk_rec.mp4',
|
||||
needsInput: [{ placeholder: '720x1280', token: '<WxH>' }] },
|
||||
{ label: 'List captured files', cmd: 'shell ls -l /sdcard/atk_screen.png /sdcard/atk_rec.mp4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Input & Automation',
|
||||
commands: [
|
||||
{ label: 'Tap at coordinate', cmd: 'shell input tap <x> <y>',
|
||||
needsInput: [{ placeholder: '540', token: '<x>' }, { placeholder: '1200', token: '<y>' }] },
|
||||
{ label: 'Swipe', cmd: 'shell input swipe <x1> <y1> <x2> <y2> 300',
|
||||
needsInput: [{ placeholder: '300', token: '<x1>' }, { placeholder: '1500', token: '<y1>' }, { placeholder: '300', token: '<x2>' }, { placeholder: '500', token: '<y2>' }] },
|
||||
{ label: 'Type text', cmd: 'shell input text <text>',
|
||||
needsInput: [{ placeholder: 'hello', token: '<text>' }] },
|
||||
{ label: 'Key event (code/name)', cmd: 'shell input keyevent <key>',
|
||||
needsInput: [{ placeholder: 'KEYCODE_HOME', token: '<key>' }] },
|
||||
{ label: 'Home', cmd: 'shell input keyevent KEYCODE_HOME' },
|
||||
{ label: 'Back', cmd: 'shell input keyevent KEYCODE_BACK' },
|
||||
{ label: 'App switch (recents)', cmd: 'shell input keyevent KEYCODE_APP_SWITCH' },
|
||||
{ label: 'Power button', cmd: 'shell input keyevent KEYCODE_POWER' },
|
||||
{ label: 'Volume up', cmd: 'shell input keyevent KEYCODE_VOLUME_UP' },
|
||||
{ label: 'Unlock (menu key)', cmd: 'shell input keyevent 82' },
|
||||
{ label: 'Monkey: random events on app', cmd: 'shell monkey -p <package> -v 200',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'WiFi',
|
||||
commands: [
|
||||
{ label: 'WiFi state dump', cmd: 'shell dumpsys wifi' },
|
||||
{ label: 'Connection status', cmd: 'shell cmd wifi status' },
|
||||
{ label: 'Trigger scan', cmd: 'shell cmd wifi start-scan' },
|
||||
{ label: 'Scan results', cmd: 'shell cmd wifi list-scan-results' },
|
||||
{ label: 'Saved networks', cmd: 'shell cmd wifi list-networks' },
|
||||
{ label: 'Enable WiFi', cmd: 'shell svc wifi enable' },
|
||||
{ label: 'Disable WiFi', cmd: 'shell svc wifi disable' },
|
||||
{ label: 'WiFi MAC (factory)', cmd: 'shell cat /sys/class/net/wlan0/address' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Bluetooth',
|
||||
commands: [
|
||||
{ label: 'Bluetooth manager dump', cmd: 'shell dumpsys bluetooth_manager' },
|
||||
{ label: 'Enable Bluetooth', cmd: 'shell cmd bluetooth_manager enable' },
|
||||
{ label: 'Disable Bluetooth', cmd: 'shell cmd bluetooth_manager disable' },
|
||||
{ label: 'Adapter on/off state', cmd: 'shell settings get global bluetooth_on' },
|
||||
{ label: 'Bluetooth MAC address', cmd: 'shell settings get secure bluetooth_address' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Telephony & SIM',
|
||||
commands: [
|
||||
{ label: 'Telephony registry dump', cmd: 'shell dumpsys telephony.registry' },
|
||||
{ label: 'IMEI / device id (svc call)', cmd: 'shell service call iphonesubinfo 1' },
|
||||
{ label: 'SIM operator', cmd: 'shell getprop gsm.sim.operator.alpha' },
|
||||
{ label: 'Network operator', cmd: 'shell getprop gsm.operator.alpha' },
|
||||
{ label: 'SIM state', cmd: 'shell getprop gsm.sim.state' },
|
||||
{ label: 'Data network type', cmd: 'shell getprop gsm.network.type' },
|
||||
{ label: 'Airplane mode state', cmd: 'shell settings get global airplane_mode_on' },
|
||||
{ label: 'Airplane mode on', cmd: 'shell cmd connectivity airplane-mode enable' },
|
||||
{ label: 'Airplane mode off', cmd: 'shell cmd connectivity airplane-mode disable' },
|
||||
{ label: 'Carrier config dump', cmd: 'shell dumpsys carrier_config' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Location & GPS',
|
||||
commands: [
|
||||
{ label: 'Location service dump', cmd: 'shell dumpsys location' },
|
||||
{ label: 'Location mode', cmd: 'shell settings get secure location_mode' },
|
||||
{ label: 'Enable location', cmd: 'shell settings put secure location_mode 3' },
|
||||
{ label: 'Disable location', cmd: 'shell settings put secure location_mode 0' },
|
||||
{ label: 'Providers allowed', cmd: 'shell settings get secure location_providers_allowed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'NFC & Sensors',
|
||||
commands: [
|
||||
{ label: 'NFC service dump', cmd: 'shell dumpsys nfc' },
|
||||
{ label: 'NFC enabled state', cmd: 'shell settings get secure nfc_on' },
|
||||
{ label: 'Sensor service dump', cmd: 'shell dumpsys sensorservice' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Biometrics & Lock',
|
||||
commands: [
|
||||
{ label: 'Fingerprint service dump', cmd: 'shell dumpsys fingerprint' },
|
||||
{ label: 'Face service dump', cmd: 'shell dumpsys face' },
|
||||
{ label: 'Biometric manager dump', cmd: 'shell dumpsys biometric' },
|
||||
{ label: 'Lock settings / keyguard', cmd: 'shell dumpsys lock_settings' },
|
||||
{ label: 'Trust agent state', cmd: 'shell dumpsys trust' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
commands: [
|
||||
{ label: 'Notification service dump', cmd: 'shell dumpsys notification' },
|
||||
{ label: 'Notification listeners', cmd: 'shell settings get secure enabled_notification_listeners' },
|
||||
{ label: 'Do-Not-Disturb state', cmd: 'shell settings get global zen_mode' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Jobs, Alarms & Doze',
|
||||
commands: [
|
||||
{ label: 'JobScheduler dump', cmd: 'shell dumpsys jobscheduler' },
|
||||
{ label: 'Alarm manager dump', cmd: 'shell dumpsys alarm' },
|
||||
{ label: 'Doze / idle state', cmd: 'shell dumpsys deviceidle' },
|
||||
{ label: 'Force into Doze', cmd: 'shell dumpsys deviceidle force-idle' },
|
||||
{ label: 'Exit Doze', cmd: 'shell dumpsys deviceidle unforce' },
|
||||
{ label: 'Doze whitelist', cmd: 'shell dumpsys deviceidle whitelist' },
|
||||
{ label: 'Standby bucket for app', cmd: 'shell am get-standby-bucket <package>',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Users & Profiles',
|
||||
commands: [
|
||||
{ label: 'List users', cmd: 'shell pm list users' },
|
||||
{ label: 'Current user', cmd: 'shell am get-current-user' },
|
||||
{ label: 'Packages for a user', cmd: 'shell pm list packages --user <userId>',
|
||||
needsInput: [{ placeholder: '0', token: '<userId>' }] },
|
||||
{ label: 'Max supported users', cmd: 'shell pm get-max-users' },
|
||||
{ label: 'Work / managed users dump', cmd: 'shell dumpsys user' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Device Policy & MDM',
|
||||
commands: [
|
||||
{ label: 'Device policy dump', cmd: 'shell dumpsys device_policy' },
|
||||
{ label: 'Active device admins', cmd: 'shell dpm list-owners' },
|
||||
{ label: 'Device owner?', cmd: 'shell dumpsys device_policy | grep -i "Device Owner"' },
|
||||
{ label: 'Profile owner?', cmd: 'shell dumpsys device_policy | grep -i "Profile Owner"' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Storage & Disk',
|
||||
commands: [
|
||||
{ label: 'Volume list', cmd: 'shell sm list-volumes' },
|
||||
{ label: 'Disk list', cmd: 'shell sm list-disks' },
|
||||
{ label: 'Filesystem usage', cmd: 'shell df -h' },
|
||||
{ label: 'Storage stats (diskstats)', cmd: 'shell dumpsys diskstats' },
|
||||
{ label: 'storaged dump', cmd: 'shell dumpsys storaged' },
|
||||
{ label: 'Mounted filesystems', cmd: 'shell mount' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Accessibility & IME',
|
||||
commands: [
|
||||
{ label: 'Accessibility service dump', cmd: 'shell dumpsys accessibility' },
|
||||
{ label: 'Enabled a11y services', cmd: 'shell settings get secure enabled_accessibility_services' },
|
||||
{ label: 'List input methods', cmd: 'shell ime list -a' },
|
||||
{ label: 'Enabled IMEs', cmd: 'shell ime list -s' },
|
||||
{ label: 'Current default IME', cmd: 'shell settings get secure default_input_method' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Content Providers',
|
||||
commands: [
|
||||
{ label: 'Query secure settings', cmd: 'shell content query --uri content://settings/secure' },
|
||||
{ label: 'Query global settings', cmd: 'shell content query --uri content://settings/global' },
|
||||
{ label: 'Query system settings', cmd: 'shell content query --uri content://settings/system' },
|
||||
{ label: 'Query custom URI', cmd: 'shell content query --uri <uri>',
|
||||
needsInput: [{ placeholder: 'content://telephony/carriers', token: '<uri>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Window Manager',
|
||||
commands: [
|
||||
{ label: 'Window manager dump', cmd: 'shell dumpsys window' },
|
||||
{ label: 'Focused window / app', cmd: 'shell dumpsys window windows | grep -iE "mCurrentFocus|mFocusedApp"' },
|
||||
{ label: 'Foreground activity', cmd: 'shell dumpsys activity activities | grep -i mResumedActivity' },
|
||||
{ label: 'Recent tasks', cmd: 'shell dumpsys activity recents | grep -i intent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Network — Firewall & Routing',
|
||||
commands: [
|
||||
{ label: 'IP addresses (all ifaces)', cmd: 'shell ip addr' },
|
||||
{ label: 'Routing table', cmd: 'shell ip route' },
|
||||
{ label: 'Routing rules', cmd: 'shell ip rule' },
|
||||
{ label: 'ARP / neighbour table', cmd: 'shell ip neigh' },
|
||||
{ label: 'Open sockets (ss)', cmd: 'shell ss -tunap' },
|
||||
{ label: 'Listening sockets', cmd: 'shell ss -ltnp' },
|
||||
{ label: 'iptables filter (root)', cmd: 'shell iptables -L -n -v' },
|
||||
{ label: 'DNS resolver props', cmd: 'shell getprop | grep -i "net.dns"' },
|
||||
{ label: 'Connectivity dump', cmd: 'shell dumpsys connectivity' },
|
||||
{ label: 'Per-uid net policy', cmd: 'shell dumpsys netpolicy' },
|
||||
{ label: 'TCP connection states', cmd: 'shell cat /proc/net/tcp' },
|
||||
{ label: 'Ping a host', cmd: 'shell ping -c 4 <host>',
|
||||
needsInput: [{ placeholder: '8.8.8.8', token: '<host>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Audio & Camera',
|
||||
commands: [
|
||||
{ label: 'Audio service dump', cmd: 'shell dumpsys audio' },
|
||||
{ label: 'Audio policy / routing', cmd: 'shell dumpsys media.audio_policy' },
|
||||
{ label: 'Media sessions', cmd: 'shell dumpsys media_session' },
|
||||
{ label: 'Play / pause media', cmd: 'shell input keyevent KEYCODE_MEDIA_PLAY_PAUSE' },
|
||||
{ label: 'Camera service dump', cmd: 'shell dumpsys media.camera' },
|
||||
{ label: 'Camera characteristics', cmd: 'shell dumpsys media.camera | grep -iE "Camera [0-9]|Facing"' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Backup Manager (bmgr)',
|
||||
commands: [
|
||||
{ label: 'Backup enabled?', cmd: 'shell bmgr enabled' },
|
||||
{ label: 'List transports', cmd: 'shell bmgr list transports' },
|
||||
{ label: 'Backed-up sets', cmd: 'shell bmgr list sets' },
|
||||
{ label: 'Run backup for app', cmd: 'shell bmgr backupnow <package>',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Security & Integrity',
|
||||
commands: [
|
||||
{ label: 'SELinux mode', cmd: 'shell getenforce' },
|
||||
{ label: 'Verified boot state', cmd: 'shell getprop ro.boot.verifiedbootstate' },
|
||||
{ label: 'Bootloader locked?', cmd: 'shell getprop ro.boot.flash.locked' },
|
||||
{ label: 'dm-verity mode', cmd: 'shell getprop ro.boot.veritymode' },
|
||||
{ label: 'Build tags (test-keys?)', cmd: 'shell getprop ro.build.tags' },
|
||||
{ label: 'Debuggable / secure flags', cmd: 'shell getprop | grep -iE "ro.debuggable|ro.secure"' },
|
||||
{ label: 'su present?', cmd: 'shell which su' },
|
||||
{ label: 'Magisk present?', cmd: 'shell ls -l /data/adb/magisk 2>/dev/null' },
|
||||
{ label: 'Frida ports listening?', cmd: 'shell netstat -tlnp 2>/dev/null | grep -E "27042|27043"' },
|
||||
{ label: 'Running uid', cmd: 'shell id' },
|
||||
{ label: 'Writable (rw) mounts', cmd: 'shell mount | grep -iE " rw,| rw "' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Developer & Debug',
|
||||
commands: [
|
||||
{ label: 'List all global settings', cmd: 'shell settings list global' },
|
||||
{ label: 'Show touches overlay on', cmd: 'shell settings put system show_touches 1' },
|
||||
{ label: 'Show touches overlay off', cmd: 'shell settings put system show_touches 0' },
|
||||
{ label: 'Pointer location overlay on', cmd: 'shell settings put system pointer_location 1' },
|
||||
{ label: 'Disable animations', cmd: 'shell settings put global window_animation_scale 0' },
|
||||
{ label: 'Reset animations', cmd: 'shell settings put global window_animation_scale 1' },
|
||||
{ label: 'GPU overdraw debug', cmd: 'shell setprop debug.hwui.overdraw show' },
|
||||
{ label: 'USB debugging state', cmd: 'shell settings get global adb_enabled' },
|
||||
{ label: 'Wireless debugging state', cmd: 'shell settings get global adb_wifi_enabled' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Fastboot — OEM & Advanced',
|
||||
commands: [
|
||||
{ label: 'All fastboot variables', cmd: 'fastboot getvar all' },
|
||||
{ label: 'Bootloader lock state', cmd: 'fastboot getvar unlocked' },
|
||||
{ label: 'Current slot (A/B)', cmd: 'fastboot getvar current-slot' },
|
||||
{ label: 'Product / device', cmd: 'fastboot getvar product' },
|
||||
{ label: 'Set active slot A', cmd: 'fastboot --set-active=a' },
|
||||
{ label: 'Set active slot B', cmd: 'fastboot --set-active=b' },
|
||||
{ label: 'Erase eSIM (Pixel, oem)', cmd: 'fastboot oem esim_erase' },
|
||||
{ label: 'eSIM info (Pixel, oem)', cmd: 'fastboot oem esim_id' },
|
||||
{ label: 'Device info (oem)', cmd: 'fastboot oem device-info' },
|
||||
{ label: 'Carrier / config (oem)', cmd: 'fastboot oem get_config' },
|
||||
{ label: 'Unlock bootloader', cmd: 'fastboot flashing unlock' },
|
||||
{ label: 'Lock bootloader', cmd: 'fastboot flashing lock' },
|
||||
{ label: 'Unlock critical partitions', cmd: 'fastboot flashing unlock_critical' },
|
||||
{ label: 'Reboot to bootloader', cmd: 'fastboot reboot bootloader' },
|
||||
{ label: 'Reboot to fastbootd (userspace)', cmd: 'fastboot reboot fastboot' },
|
||||
{ label: 'Boot a kernel image (no flash)', cmd: 'fastboot boot <image>',
|
||||
needsInput: [{ placeholder: 'boot.img', token: '<image>' }] },
|
||||
{ label: 'Wipe userdata', cmd: 'fastboot -w' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'UWB (Ultra-Wideband)',
|
||||
commands: [
|
||||
{ label: 'UWB service dump', cmd: 'shell dumpsys uwb' },
|
||||
{ label: 'UWB status', cmd: 'shell cmd uwb status' },
|
||||
{ label: 'UWB device state', cmd: 'shell cmd uwb get-device-state' },
|
||||
{ label: 'UWB country code', cmd: 'shell cmd uwb get-country-code' },
|
||||
{ label: 'UWB enabled (setting)', cmd: 'shell settings get global uwb_enabled' },
|
||||
{ label: 'Enable UWB', cmd: 'shell settings put global uwb_enabled 1' },
|
||||
{ label: 'Disable UWB', cmd: 'shell settings put global uwb_enabled 0' },
|
||||
{ label: 'UWB hardware feature', cmd: 'shell pm list features | grep -i uwb' },
|
||||
{ label: 'UWB related props', cmd: 'shell getprop | grep -i uwb' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Satellite',
|
||||
commands: [
|
||||
{ label: 'Satellite service dump', cmd: 'shell dumpsys satellite' },
|
||||
{ label: 'Satellite controller (usage)', cmd: 'shell cmd satellite_controller' },
|
||||
{ label: 'Satellite in telephony registry', cmd: 'shell dumpsys telephony.registry | grep -i satellite' },
|
||||
{ label: 'Carrier satellite config', cmd: 'shell dumpsys carrier_config | grep -i satellite' },
|
||||
{ label: 'Satellite hardware feature', cmd: 'shell pm list features | grep -i satellite' },
|
||||
{ label: 'Satellite related props', cmd: 'shell getprop | grep -i satellite' },
|
||||
{ label: 'NTN / non-terrestrial props', cmd: 'shell getprop | grep -iE "ntn|non.terrestrial"' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Verified Boot / AVB / PQC',
|
||||
commands: [
|
||||
{ label: 'Verified boot state', cmd: 'shell getprop ro.boot.verifiedbootstate' },
|
||||
{ label: 'vbmeta hash algorithm', cmd: 'shell getprop ro.boot.vbmeta.hash_alg' },
|
||||
{ label: 'vbmeta digest', cmd: 'shell getprop ro.boot.vbmeta.digest' },
|
||||
{ label: 'vbmeta size', cmd: 'shell getprop ro.boot.vbmeta.size' },
|
||||
{ label: 'All vbmeta / AVB props', cmd: 'shell getprop | grep -iE "vbmeta|avb"' },
|
||||
{ label: 'dm-verity mode', cmd: 'shell getprop ro.boot.veritymode' },
|
||||
// Android 17 introduced PQC signatures on system partitions — surfaces any
|
||||
// related props if the device exposes them (names may vary by build).
|
||||
{ label: 'PQC signature props (Android 17)', cmd: 'shell getprop | grep -iE "pqc|dilithium|ml.?dsa|sphincs|falcon"' },
|
||||
{ label: 'Bootloader lock state', cmd: 'shell getprop ro.boot.flash.locked' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function ViewUtilities() {
|
||||
const [output, setOutput] = useState('')
|
||||
const [outputLabel, setOutputLabel] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set(['Device Info']))
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
||||
const [inputs, setInputs] = useState<Record<string, string>>({})
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [activeCmd, setActiveCmd] = useState<Command | null>(null)
|
||||
|
|
|
|||
67
frontend/src/lib/appearance.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// System-wide appearance overrides layered ON TOP of the selected theme:
|
||||
// - a custom accent colour (overrides --accent-green / --accent-dim)
|
||||
// - a custom UI font (overrides the app's sans font)
|
||||
//
|
||||
// Both are applied as INLINE custom properties on <html>, which beats the
|
||||
// per-theme stylesheet rules, and persist in localStorage. Clearing an override
|
||||
// removes the inline prop so the theme's own value shows through again.
|
||||
|
||||
const ACCENT_KEY = 'atk-custom-accent'
|
||||
const FONT_KEY = 'atk-custom-font'
|
||||
|
||||
// Built-in font choices. '' = use the theme/app default. The two @fontsource
|
||||
// families are bundled; the rest are system generics that always resolve.
|
||||
export const FONT_OPTIONS: { id: string; label: string; stack: string }[] = [
|
||||
{ id: '', label: 'Default (IBM Plex Sans)', stack: '' },
|
||||
{ id: 'jetbrains', label: 'JetBrains Mono', stack: "'JetBrains Mono', monospace" },
|
||||
{ id: 'system-sans', label: 'System Sans', stack: 'system-ui, sans-serif' },
|
||||
{ id: 'system-serif', label: 'System Serif', stack: 'Georgia, \'Times New Roman\', serif' },
|
||||
{ id: 'system-mono', label: 'System Monospace', stack: 'ui-monospace, \'Cascadia Code\', \'Courier New\', monospace' },
|
||||
]
|
||||
|
||||
export function hexToChannels(hex: string): string | null {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim())
|
||||
if (!m) return null
|
||||
const n = parseInt(m[1], 16)
|
||||
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`
|
||||
}
|
||||
|
||||
function darkenChannels(ch: string, f: number): string {
|
||||
const [r, g, b] = ch.split(' ').map(Number)
|
||||
return `${Math.round(r * f)} ${Math.round(g * f)} ${Math.round(b * f)}`
|
||||
}
|
||||
|
||||
export function getCustomAccent(): string { return localStorage.getItem(ACCENT_KEY) || '' }
|
||||
export function getCustomFont(): string { return localStorage.getItem(FONT_KEY) || '' }
|
||||
|
||||
export function setCustomAccent(hex: string | null): void {
|
||||
if (hex && hexToChannels(hex)) localStorage.setItem(ACCENT_KEY, hex)
|
||||
else localStorage.removeItem(ACCENT_KEY)
|
||||
applyAppearance()
|
||||
}
|
||||
|
||||
export function setCustomFont(id: string | null): void {
|
||||
if (id) localStorage.setItem(FONT_KEY, id)
|
||||
else localStorage.removeItem(FONT_KEY)
|
||||
applyAppearance()
|
||||
}
|
||||
|
||||
// applyAppearance (re)applies the stored overrides. Call on boot and after a change.
|
||||
export function applyAppearance(): void {
|
||||
const root = document.documentElement
|
||||
|
||||
const accent = getCustomAccent()
|
||||
const ch = accent ? hexToChannels(accent) : null
|
||||
if (ch) {
|
||||
root.style.setProperty('--accent-green', ch)
|
||||
root.style.setProperty('--accent-dim', darkenChannels(ch, 0.82))
|
||||
} else {
|
||||
root.style.removeProperty('--accent-green')
|
||||
root.style.removeProperty('--accent-dim')
|
||||
}
|
||||
|
||||
const fontId = getCustomFont()
|
||||
const stack = FONT_OPTIONS.find(f => f.id === fontId)?.stack
|
||||
if (stack) root.style.setProperty('--app-font', stack)
|
||||
else root.style.removeProperty('--app-font')
|
||||
}
|
||||
68
frontend/src/lib/applock.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// App-lock frontend orchestration.
|
||||
//
|
||||
// Two things live here:
|
||||
// 1. A cached copy of the backend lock status (enabled / requireForDanger) so
|
||||
// destructive handlers can decide whether to prompt without an await round-
|
||||
// trip every time.
|
||||
// 2. ensureDangerUnlocked() — call this at the top of any destructive action.
|
||||
// When "require password for destructive actions" is on and the backend
|
||||
// session window has lapsed, it pops a re-auth modal (hosted by <DangerGate/>
|
||||
// in App.tsx) and resolves true only once UnlockDanger succeeds.
|
||||
//
|
||||
// The backend enforces the gate for real (see backend_applock.go); this is the
|
||||
// UX layer that collects the password and keeps the window warm.
|
||||
|
||||
import { AppLockStatus, UnlockDanger } from './wails'
|
||||
|
||||
export type AppLockState = { enabled: boolean; requireForDanger: boolean }
|
||||
|
||||
let cached: AppLockState = { enabled: false, requireForDanger: false }
|
||||
|
||||
export function appLockState(): AppLockState {
|
||||
return cached
|
||||
}
|
||||
|
||||
export async function refreshAppLockStatus(): Promise<AppLockState> {
|
||||
try {
|
||||
cached = await AppLockStatus()
|
||||
} catch {
|
||||
// backend not reachable yet — keep last known (defaults to unlocked)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
// ----- danger re-auth modal host wiring -----
|
||||
|
||||
export type DangerRequest = { resolve: (ok: boolean) => void }
|
||||
let host: ((req: DangerRequest | null) => void) | null = null
|
||||
|
||||
// Called once by <DangerGate/> to register itself as the modal host.
|
||||
export function _registerDangerHost(fn: (req: DangerRequest | null) => void): () => void {
|
||||
host = fn
|
||||
return () => { if (host === fn) host = null }
|
||||
}
|
||||
|
||||
// Local mirror of the backend's unlock window. Kept slightly shorter so we
|
||||
// re-prompt a touch before the server window actually lapses.
|
||||
const DANGER_WINDOW_MS = 4.5 * 60 * 1000
|
||||
let unlockedUntil = 0
|
||||
|
||||
// Call the backend with the entered password; on success arm the local window.
|
||||
export async function tryUnlockDanger(password: string): Promise<boolean> {
|
||||
const ok = await UnlockDanger(password)
|
||||
if (ok) unlockedUntil = Date.now() + DANGER_WINDOW_MS
|
||||
return ok
|
||||
}
|
||||
|
||||
// Guard for destructive handlers: `if (!(await ensureDangerUnlocked())) return`.
|
||||
export async function ensureDangerUnlocked(): Promise<boolean> {
|
||||
if (!cached.enabled || !cached.requireForDanger) return true
|
||||
if (Date.now() < unlockedUntil) return true
|
||||
if (!host) return true // modal not mounted (shouldn't happen) — backend still gates
|
||||
return new Promise<boolean>(resolve => host!({ resolve }))
|
||||
}
|
||||
|
||||
// Recognise the backend sentinel so callers can surface a friendlier message.
|
||||
export function isDangerLocked(err: unknown): boolean {
|
||||
return String(err).includes('DANGER_LOCKED')
|
||||
}
|
||||
37
frontend/src/lib/dismissible.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Remembers which dismissible banners/warnings the user has hidden.
|
||||
// Each banner has a stable string id; dismissals persist in localStorage.
|
||||
|
||||
const STORAGE_KEY = 'atk-dismissed'
|
||||
|
||||
function load(): Record<string, true> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function save(map: Record<string, true>): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
|
||||
}
|
||||
|
||||
export function isDismissed(id: string): boolean {
|
||||
return load()[id] === true
|
||||
}
|
||||
|
||||
export function dismiss(id: string): void {
|
||||
const map = load()
|
||||
map[id] = true
|
||||
save(map)
|
||||
}
|
||||
|
||||
export function undismiss(id: string): void {
|
||||
const map = load()
|
||||
delete map[id]
|
||||
save(map)
|
||||
}
|
||||
|
||||
/** Clear every remembered dismissal (used by a "show all warnings again" action). */
|
||||
export function resetDismissed(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
81
frontend/src/lib/featureflags.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Opt-in feature flags persisted in localStorage. Read on view mount (no live
|
||||
// event needed — switching views remounts and re-reads).
|
||||
|
||||
const ROOT_TOOLS_KEY = 'atk-root-tools'
|
||||
|
||||
// Rooting / Magisk patching tools in the Flasher. Off by default — these are
|
||||
// advanced, destructive-adjacent operations.
|
||||
export function getRootTools(): boolean {
|
||||
return localStorage.getItem(ROOT_TOOLS_KEY) === '1'
|
||||
}
|
||||
|
||||
export function setRootTools(on: boolean): void {
|
||||
localStorage.setItem(ROOT_TOOLS_KEY, on ? '1' : '0')
|
||||
}
|
||||
|
||||
// Mute error pop-ups that are just "no device / offline / unauthorized".
|
||||
const MUTE_NODEVICE_KEY = 'atk-mute-nodevice'
|
||||
export function getMuteNoDevice(): boolean {
|
||||
return localStorage.getItem(MUTE_NODEVICE_KEY) === '1'
|
||||
}
|
||||
export function setMuteNoDevice(on: boolean): void {
|
||||
localStorage.setItem(MUTE_NODEVICE_KEY, on ? '1' : '0')
|
||||
}
|
||||
|
||||
// ── Sidebar feature kill-switch ──────────────────────────────────────────────
|
||||
// Users can hide nav entries they don't use. Settings is never hideable.
|
||||
const HIDDEN_KEY = 'atk-hidden-views'
|
||||
const HIDDEN_EVENT = 'atk-hidden-views-change'
|
||||
|
||||
export const TOGGLEABLE_VIEWS: { view: string; label: string }[] = [
|
||||
{ view: 'dashboard', label: 'Dashboard' },
|
||||
{ view: 'files', label: 'Files' },
|
||||
{ view: 'mirror', label: 'Screen Mirror' },
|
||||
{ view: 'packages', label: 'Packages' },
|
||||
{ view: 'debloater', label: 'Debloater' },
|
||||
{ view: 'shell', label: 'Shell' },
|
||||
{ view: 'logcat', label: 'Logcat' },
|
||||
{ view: 'appinspect', label: 'App Inspector' },
|
||||
{ view: 'apkaudit', label: 'APK Audit' },
|
||||
{ view: 'certs', label: 'Certificates' },
|
||||
{ view: 'backup', label: 'Backup' },
|
||||
{ view: 'props', label: 'Prop Editor' },
|
||||
{ view: 'utilities', label: 'Utilities' },
|
||||
{ view: 'flasher', label: 'Flasher' },
|
||||
]
|
||||
|
||||
export function getHiddenViews(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HIDDEN_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function setHiddenViews(views: string[]): void {
|
||||
localStorage.setItem(HIDDEN_KEY, JSON.stringify(views))
|
||||
window.dispatchEvent(new CustomEvent(HIDDEN_EVENT, { detail: views }))
|
||||
}
|
||||
|
||||
export function onHiddenViewsChange(cb: (views: string[]) => void): () => void {
|
||||
const handler = (e: Event) => cb((e as CustomEvent).detail as string[])
|
||||
window.addEventListener(HIDDEN_EVENT, handler)
|
||||
return () => window.removeEventListener(HIDDEN_EVENT, handler)
|
||||
}
|
||||
|
||||
// ── Custom sidebar order (drag-to-reorder, dock-style) ───────────────────────
|
||||
const ORDER_KEY = 'atk-nav-order'
|
||||
|
||||
export function getNavOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function setNavOrder(order: string[]): void {
|
||||
localStorage.setItem(ORDER_KEY, JSON.stringify(order))
|
||||
}
|
||||
51
frontend/src/lib/layout.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Sidebar position preference. Mirrors src/lib/theme.ts: persisted in
|
||||
// localStorage, but here we also broadcast a window event so App.tsx can swap
|
||||
// its layout live (the theme just flips a <html> attribute and needs no React
|
||||
// state — the sidebar position changes the React tree, so it does).
|
||||
|
||||
export type SidebarPosition = 'left' | 'top' | 'bottom'
|
||||
|
||||
export const SIDEBAR_POSITIONS: { id: SidebarPosition; label: string; hint: string }[] = [
|
||||
{ id: 'left', label: 'Left', hint: 'Vertical rail on the side' },
|
||||
{ id: 'top', label: 'Top', hint: 'Horizontal bar across the top' },
|
||||
{ id: 'bottom', label: 'Bottom', hint: 'Horizontal bar across the bottom (default)' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'atk-sidebar-position'
|
||||
const EVENT = 'atk-sidebar-position-change'
|
||||
|
||||
export function getSidebarPosition(): SidebarPosition {
|
||||
const p = localStorage.getItem(STORAGE_KEY)
|
||||
return p === 'top' || p === 'bottom' || p === 'left' ? p : 'bottom'
|
||||
}
|
||||
|
||||
export function setSidebarPosition(p: SidebarPosition): void {
|
||||
localStorage.setItem(STORAGE_KEY, p)
|
||||
window.dispatchEvent(new CustomEvent(EVENT, { detail: p }))
|
||||
}
|
||||
|
||||
export function onSidebarPositionChange(cb: (p: SidebarPosition) => void): () => void {
|
||||
const handler = (e: Event) => cb((e as CustomEvent).detail as SidebarPosition)
|
||||
window.addEventListener(EVENT, handler)
|
||||
return () => window.removeEventListener(EVENT, handler)
|
||||
}
|
||||
|
||||
// Whether to show the text label under each sidebar icon. Same live-broadcast
|
||||
// pattern as the position pref above.
|
||||
const LABELS_KEY = 'atk-sidebar-labels'
|
||||
const LABELS_EVENT = 'atk-sidebar-labels-change'
|
||||
|
||||
export function getSidebarLabels(): boolean {
|
||||
return localStorage.getItem(LABELS_KEY) !== '0' // on by default
|
||||
}
|
||||
|
||||
export function setSidebarLabels(on: boolean): void {
|
||||
localStorage.setItem(LABELS_KEY, on ? '1' : '0')
|
||||
window.dispatchEvent(new CustomEvent(LABELS_EVENT, { detail: on }))
|
||||
}
|
||||
|
||||
export function onSidebarLabelsChange(cb: (on: boolean) => void): () => void {
|
||||
const handler = (e: Event) => cb((e as CustomEvent).detail as boolean)
|
||||
window.addEventListener(LABELS_EVENT, handler)
|
||||
return () => window.removeEventListener(LABELS_EVENT, handler)
|
||||
}
|
||||
91
frontend/src/lib/logcat_tools.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Logcat highlight rules + sensitive-data scrubbing for export.
|
||||
|
||||
export type HiColor = 'red' | 'amber' | 'green' | 'blue' | 'purple' | 'pink'
|
||||
|
||||
export interface HighlightRule {
|
||||
id: string
|
||||
pattern: string
|
||||
mode: 'contains' | 'regex'
|
||||
color: HiColor
|
||||
}
|
||||
|
||||
// Row style per colour (background tint + a left accent + readable text).
|
||||
export const HI_STYLES: Record<HiColor, string> = {
|
||||
red: 'bg-danger/25 text-danger',
|
||||
amber: 'bg-warn/25 text-warn',
|
||||
green: 'bg-accent-green/20 text-accent-green',
|
||||
blue: 'bg-blue-500/20 text-blue-300',
|
||||
purple: 'bg-purple-500/20 text-purple-300',
|
||||
pink: 'bg-pink-500/20 text-pink-300',
|
||||
}
|
||||
|
||||
export const HI_SWATCH: Record<HiColor, string> = {
|
||||
red: '#e78284', amber: '#e5c890', green: '#a6d189', blue: '#8caaee', purple: '#ca9ee6', pink: '#f4b8e4',
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'atk-logcat-highlights'
|
||||
|
||||
export function loadHighlightRules(): HighlightRule[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter(r => r && typeof r.pattern === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHighlightRules(rules: HighlightRule[]): void {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)) } catch {}
|
||||
}
|
||||
|
||||
// A compiled matcher for one rule. Regex rules that fail to compile become a
|
||||
// literal substring match so a bad pattern never throws mid-render.
|
||||
export interface CompiledRule {
|
||||
rule: HighlightRule
|
||||
test: (raw: string) => boolean
|
||||
style: string
|
||||
}
|
||||
|
||||
export function compileRules(rules: HighlightRule[]): CompiledRule[] {
|
||||
return rules
|
||||
.filter(r => r.pattern.trim() !== '')
|
||||
.map(r => {
|
||||
let test: (raw: string) => boolean
|
||||
if (r.mode === 'regex') {
|
||||
try {
|
||||
const re = new RegExp(r.pattern, 'i')
|
||||
test = (raw) => re.test(raw)
|
||||
} catch {
|
||||
const needle = r.pattern.toLowerCase()
|
||||
test = (raw) => raw.toLowerCase().includes(needle)
|
||||
}
|
||||
} else {
|
||||
const needle = r.pattern.toLowerCase()
|
||||
test = (raw) => raw.toLowerCase().includes(needle)
|
||||
}
|
||||
return { rule: r, test, style: HI_STYLES[r.color] || HI_STYLES.amber }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensitive-data scrubbing (applied on export).
|
||||
// Order matters: longer / more-specific patterns run before shorter ones so a
|
||||
// digit run isn't half-consumed by a broader rule.
|
||||
// ---------------------------------------------------------------------------
|
||||
const SCRUBBERS: [RegExp, string][] = [
|
||||
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[redacted-email]'],
|
||||
[/\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b/g, '[redacted-mac]'],
|
||||
[/\b\d{19,20}\b/g, '[redacted-iccid]'], // ICCID (SIM serial)
|
||||
[/\b\d{14,16}\b/g, '[redacted-imei]'], // IMEI / IMSI / MEID / long device ids
|
||||
[/\+\d[\d ()\-.]{6,14}\d/g, '[redacted-phone]'], // international phone numbers
|
||||
[/\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b/g, '[redacted-phone]'], // NANP formatted
|
||||
]
|
||||
|
||||
// scrubSensitive removes IMEIs, phone numbers, SIM serials, MACs and emails.
|
||||
export function scrubSensitive(text: string): string {
|
||||
let out = text
|
||||
for (const [re, repl] of SCRUBBERS) out = out.replace(re, repl)
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,9 +1,22 @@
|
|||
// Simple toast state management - used with sonner
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// When the user enables "mute no-device pop-ups" (Settings), swallow error
|
||||
// toasts that are just about a missing/offline/unauthorized device.
|
||||
function mutedNoDevice(msg: string): boolean {
|
||||
if (localStorage.getItem('atk-mute-nodevice') !== '1') return false
|
||||
const s = msg.toLowerCase()
|
||||
return s.includes('no device') || s.includes('no devices/emulators') ||
|
||||
s.includes('offline') || s.includes('unauthorized') || s.includes('device not found')
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
success: (msg: string) => toast.success(msg, { duration: 3000 }),
|
||||
error: (msg: string) => toast.error(msg, { duration: 5000 }),
|
||||
error: (msg: string) => {
|
||||
const s = String(msg)
|
||||
if (mutedNoDevice(s)) return
|
||||
return toast.error(s, { duration: 5000 })
|
||||
},
|
||||
info: (msg: string) => toast(msg, { duration: 3000 }),
|
||||
loading: (msg: string) => toast.loading(msg),
|
||||
dismiss: (id?: string | number) => toast.dismiss(id),
|
||||
|
|
|
|||
107
frontend/src/lib/syntax.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// Dependency-free, theme-aware syntax highlighter.
|
||||
//
|
||||
// tokenize() splits code into typed tokens; <CodeView> renders them as spans
|
||||
// whose colours come from CSS variables (.syn-* classes in global.css), so the
|
||||
// highlighting automatically tracks the active theme. Deliberately lightweight —
|
||||
// ordered sticky-regex rules per language, capped for large inputs — not a full
|
||||
// parser, but good enough for manifests, config, logs and shell output.
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export type SynLang = 'xml' | 'json' | 'shell' | 'log' | 'text'
|
||||
|
||||
interface Rule { re: RegExp; c: string }
|
||||
|
||||
// All patterns use the sticky flag so they only match at the cursor position.
|
||||
const RULES: Record<Exclude<SynLang, 'text'>, Rule[]> = {
|
||||
xml: [
|
||||
{ re: /<!--[\s\S]*?-->/y, c: 'com' },
|
||||
{ re: /<!\[CDATA\[[\s\S]*?\]\]>/y, c: 'str' },
|
||||
{ re: /<[?!][\s\S]*?>/y, c: 'com' },
|
||||
{ re: /<\/?[A-Za-z_][\w:.-]*/y, c: 'tag' },
|
||||
{ re: /"[^"]*"|'[^']*'/y, c: 'str' },
|
||||
{ re: /[A-Za-z_][\w:.-]*(?=\s*=)/y, c: 'attr' },
|
||||
{ re: /\/?>/y, c: 'punc' },
|
||||
{ re: /\b\d[\w.]*\b/y, c: 'num' },
|
||||
],
|
||||
json: [
|
||||
{ re: /"(?:\\.|[^"\\])*"(?=\s*:)/y, c: 'key' },
|
||||
{ re: /"(?:\\.|[^"\\])*"/y, c: 'str' },
|
||||
{ re: /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y, c: 'num' },
|
||||
{ re: /\b(?:true|false|null)\b/y, c: 'bool' },
|
||||
{ re: /[{}\[\],:]/y, c: 'punc' },
|
||||
],
|
||||
shell: [
|
||||
{ re: /#[^\n]*/y, c: 'com' },
|
||||
{ re: /"(?:\\.|[^"\\])*"|'[^']*'/y, c: 'str' },
|
||||
{ re: /--?[A-Za-z][\w-]*/y, c: 'attr' },
|
||||
{ re: /\b0x[0-9a-fA-F]+\b/y, c: 'num' },
|
||||
{ re: /\b\d+\b/y, c: 'num' },
|
||||
{ re: /[|&;<>()]/y, c: 'punc' },
|
||||
],
|
||||
log: [
|
||||
{ re: /"(?:\\.|[^"\\])*"/y, c: 'str' },
|
||||
{ re: /\b(?:true|false|null|enabled|disabled|granted|SYSTEM|DEBUGGABLE|ENABLED|DISABLED)\b/y, c: 'bool' },
|
||||
{ re: /[A-Za-z_][\w.]*(?=\s*=)/y, c: 'attr' },
|
||||
{ re: /\b0x[0-9a-fA-F]+\b/y, c: 'num' },
|
||||
{ re: /\b\d[\d.:]*\b/y, c: 'num' },
|
||||
{ re: /[=:{}\[\]]/y, c: 'punc' },
|
||||
],
|
||||
}
|
||||
|
||||
const MAX_HIGHLIGHT = 400_000 // skip highlighting for very large blobs (perf)
|
||||
|
||||
export interface Tok { t: string; c: string }
|
||||
|
||||
export function tokenize(code: string, lang: SynLang): Tok[] {
|
||||
if (lang === 'text') return [{ t: code, c: '' }]
|
||||
const rules = RULES[lang]
|
||||
const out: Tok[] = []
|
||||
let plain = ''
|
||||
const flush = () => { if (plain) { out.push({ t: plain, c: '' }); plain = '' } }
|
||||
let i = 0
|
||||
const n = code.length
|
||||
while (i < n) {
|
||||
let matched = false
|
||||
for (const { re, c } of rules) {
|
||||
re.lastIndex = i
|
||||
const m = re.exec(code)
|
||||
if (m && m.index === i && m[0].length > 0) {
|
||||
flush()
|
||||
out.push({ t: m[0], c })
|
||||
i += m[0].length
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) { plain += code[i]; i++ }
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// detectLang guesses a language from a filename and/or content sniff.
|
||||
export function detectLang(name: string, content: string): SynLang {
|
||||
const n = (name || '').toLowerCase()
|
||||
if (/\.(xml|htm|html|svg)$/.test(n) || n.endsWith('androidmanifest.xml')) return 'xml'
|
||||
if (/\.(json|arsc\.json)$/.test(n)) return 'json'
|
||||
if (/\.(sh|bash|zsh|rc|prop|conf|cfg|ini|env)$/.test(n)) return 'shell'
|
||||
const head = content.slice(0, 400).trimStart()
|
||||
if (head.startsWith('<?xml') || head.startsWith('<manifest') || /^<[A-Za-z!]/.test(head)) return 'xml'
|
||||
if (head.startsWith('{') || head.startsWith('[')) return 'json'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function CodeView({ code, lang, className }: { code: string; lang: SynLang; className?: string }) {
|
||||
const toks = useMemo(() => {
|
||||
if (!code) return null
|
||||
if (code.length > MAX_HIGHLIGHT || lang === 'text') return null
|
||||
return tokenize(code, lang)
|
||||
}, [code, lang])
|
||||
|
||||
if (!toks) return <pre className={className}>{code}</pre>
|
||||
return (
|
||||
<pre className={className}>
|
||||
{toks.map((t, i) => (t.c ? <span key={i} className={`syn-${t.c}`}>{t.t}</span> : <span key={i}>{t.t}</span>))}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
56
frontend/src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Theme management. Palettes are defined in src/styles/global.css and selected
|
||||
// by the data-theme attribute on <html>. Choice is persisted in localStorage.
|
||||
//
|
||||
// The catalogue below is the single source of truth: adding a palette here
|
||||
// (plus its CSS block in global.css) makes it appear in the Settings picker
|
||||
// automatically. The `swatch` tuple ([base, surface, accent, text]) drives the
|
||||
// preview shown on each theme card.
|
||||
|
||||
export interface ThemeDef {
|
||||
id: string
|
||||
label: string
|
||||
hint: string
|
||||
swatch: [string, string, string, string]
|
||||
}
|
||||
|
||||
export const THEMES: ThemeDef[] = [
|
||||
// ATK originals
|
||||
{ id: 'dark', label: 'Dark', hint: 'Terminal green on black', swatch: ['#0a0a0f', '#111118', '#00ff88', '#e8e8f0'] },
|
||||
{ id: 'frappe', label: 'Frappé', hint: 'Catppuccin — pastels, dark', swatch: ['#303446', '#292c3c', '#a6d189', '#c6d0f5'] },
|
||||
{ id: 'latte', label: 'Latte', hint: 'Catppuccin — pastels, light', swatch: ['#eff1f5', '#e6e9ef', '#40a02b', '#4c4f69'] },
|
||||
// Ported from Notas
|
||||
{ id: 'dracula', label: 'Dracula', hint: 'Purple & pink on charcoal', swatch: ['#282a36', '#343746', '#bd93f9', '#f8f8f2'] },
|
||||
{ id: 'catppuccin-macchiato', label: 'Catppuccin Macchiato', hint: 'Catppuccin — pastels, medium-dark', swatch: ['#24273a', '#363a4f', '#c6a0f6', '#cad3f5'] },
|
||||
{ id: 'catppuccin-mocha', label: 'Catppuccin Mocha', hint: 'Catppuccin — pastels, darkest', swatch: ['#1e1e2e', '#313244', '#cba6f7', '#cdd6f4'] },
|
||||
{ id: 'vintage-light', label: 'Vintage Light', hint: 'Warm sepia paper, light', swatch: ['#f6efe1', '#efe5d0', '#b07d3a', '#46392b'] },
|
||||
{ id: 'neon-tessera', label: 'Neon Tessera', hint: 'Cyan & magenta neon on black', swatch: ['#0a0e14', '#11161f', '#00e5ff', '#d8e6f2'] },
|
||||
{ id: 'adventure-time', label: 'Adventure Time', hint: 'Playful purple & orange', swatch: ['#1f1d45', '#2a2755', '#e7741e', '#f8dcc0'] },
|
||||
{ id: 'borland', label: 'Borland', hint: 'Retro blue IDE', swatch: ['#0000a4', '#0a1ab0', '#ffff4e', '#ffff80'] },
|
||||
{ id: 'c64', label: 'Commodore 64', hint: 'Commodore 64 blues', swatch: ['#40318d', '#4d3ea0', '#bfce72', '#cabdf2'] },
|
||||
{ id: 'fairy-floss-dark', label: 'Fairy Floss Dark', hint: 'Cotton-candy pastels', swatch: ['#3b364c', '#4a4564', '#ffb8d1', '#f8f8f2'] },
|
||||
{ id: 'flat', label: 'Flat', hint: 'Flat-UI slate & blue', swatch: ['#2c3e50', '#34495e', '#3498db', '#ecf0f1'] },
|
||||
{ id: 'gogh', label: 'Gogh — Starry Night', hint: 'Starry Night blues & gold', swatch: ['#0d1b34', '#14264a', '#f4cd3a', '#e8eeff'] },
|
||||
{ id: 'grass', label: 'Grass', hint: 'Green field & amber', swatch: ['#13773d', '#1c8a4a', '#e7b000', '#fff0a5'] },
|
||||
{ id: 'gruvbox-material', label: 'Gruvbox Material', hint: 'Warm retro earth tones', swatch: ['#282828', '#32302f', '#d8a657', '#d4be98'] },
|
||||
{ id: 'homebrew', label: 'Homebrew', hint: 'Green-on-black terminal', swatch: ['#000000', '#0c140c', '#00ff00', '#00d000'] },
|
||||
{ id: 'ocean', label: 'Ocean', hint: 'Muted blue-grey', swatch: ['#2b303b', '#343d46', '#8fa1b3', '#c0c5ce'] },
|
||||
{ id: 'kokuban', label: 'Kokuban', hint: 'Chalkboard green', swatch: ['#1f3526', '#274030', '#f2e9c8', '#f0f0e8'] },
|
||||
{ id: 'mono-cyan', label: 'Mono Cyan', hint: 'Monochrome cyan glow', swatch: ['#081414', '#0e1f1f', '#00d0d0', '#c8f0f0'] },
|
||||
]
|
||||
|
||||
export type Theme = string
|
||||
|
||||
const STORAGE_KEY = 'atk-theme'
|
||||
const VALID_IDS = new Set(THEMES.map(t => t.id))
|
||||
const DEFAULT_THEME: Theme = 'gruvbox-material'
|
||||
|
||||
export function getTheme(): Theme {
|
||||
const t = localStorage.getItem(STORAGE_KEY)
|
||||
return t && VALID_IDS.has(t) ? t : DEFAULT_THEME
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
const t = VALID_IDS.has(theme) ? theme : DEFAULT_THEME
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
}
|
||||
|
|
@ -39,13 +39,127 @@ export interface PackageInfo {
|
|||
isEnabled: boolean
|
||||
}
|
||||
|
||||
// Relationship kinds mined (in Go) from a log line for the visual map.
|
||||
export type RefKind = 'activity' | 'spawn' | 'death' | 'crash' | 'anr' | 'signal' | 'gfx' | 'mention'
|
||||
export interface LogRef {
|
||||
kind: RefKind
|
||||
target: string
|
||||
targetKind: 'package' | 'component' | 'pid'
|
||||
}
|
||||
|
||||
export interface LogcatLine {
|
||||
raw: string
|
||||
level: string
|
||||
tag: string
|
||||
message: string
|
||||
pid: string
|
||||
tid?: string
|
||||
time: string
|
||||
refs?: LogRef[] // relationships mined natively by the Go backend
|
||||
mentions?: LogRef[] // generic package mentions (optional/noisy)
|
||||
}
|
||||
|
||||
export interface APKAuditPermission {
|
||||
name: string
|
||||
dangerous: boolean
|
||||
}
|
||||
|
||||
export interface APKAuditComponent {
|
||||
type: string
|
||||
name: string
|
||||
exported: boolean
|
||||
exportedImplicit: boolean
|
||||
permission: string
|
||||
intentFilters: string[]
|
||||
}
|
||||
|
||||
export interface APKAuditCert {
|
||||
verified: boolean
|
||||
subject: string
|
||||
issuer: string
|
||||
sigAlgo: string
|
||||
serial: string
|
||||
sha256: string
|
||||
sha1: string
|
||||
validFrom: string
|
||||
validTo: string
|
||||
v1: boolean
|
||||
v2: boolean
|
||||
v3: boolean
|
||||
isDebug: boolean
|
||||
expired: boolean
|
||||
weakAlgo: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface APKAuditFindingMatch {
|
||||
file: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface APKAuditFinding {
|
||||
id: string
|
||||
title: string
|
||||
severity: 'critical' | 'high' | 'medium' | 'low' | 'info'
|
||||
category: string
|
||||
description: string
|
||||
cwe: string
|
||||
masvs: string
|
||||
confidence: number
|
||||
matches: APKAuditFindingMatch[]
|
||||
}
|
||||
|
||||
export interface APKAuditTracker {
|
||||
name: string
|
||||
category: string
|
||||
matches: number
|
||||
}
|
||||
|
||||
export interface APKAuditFile {
|
||||
path: string
|
||||
size: number
|
||||
compressed: number
|
||||
}
|
||||
|
||||
export interface APKEntryContent {
|
||||
name: string
|
||||
size: number
|
||||
kind: 'text' | 'image' | 'binary'
|
||||
mime: string
|
||||
text: string
|
||||
base64: string
|
||||
hex: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface APKAudit {
|
||||
source: string
|
||||
path: string
|
||||
localPath: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
sha256: string
|
||||
packageName: string
|
||||
appLabel: string
|
||||
versionName: string
|
||||
versionCode: string
|
||||
minSdk: string
|
||||
targetSdk: string
|
||||
compileSdk: string
|
||||
debuggable: boolean
|
||||
allowBackup: boolean
|
||||
usesCleartext: boolean
|
||||
hasNetworkSecurityConfig: boolean
|
||||
permissions: APKAuditPermission[]
|
||||
components: APKAuditComponent[]
|
||||
cert: APKAuditCert
|
||||
findings: APKAuditFinding[]
|
||||
trackers: APKAuditTracker[]
|
||||
files: APKAuditFile[]
|
||||
manifestXml: string
|
||||
score: number
|
||||
grade: string
|
||||
counts: Record<string, number>
|
||||
}
|
||||
|
||||
export interface AppInspection {
|
||||
|
|
@ -76,6 +190,38 @@ export interface AppInspection {
|
|||
manifestDump: string
|
||||
}
|
||||
|
||||
export interface IntentActivity {
|
||||
name: string
|
||||
component: string
|
||||
exported: boolean
|
||||
}
|
||||
|
||||
export interface GsiCompat {
|
||||
trebleEnabled: boolean
|
||||
abi: string
|
||||
gsiArch: string
|
||||
vndkIsolated: boolean
|
||||
androidRelease: string
|
||||
sdk: string
|
||||
dsuStatus: string
|
||||
}
|
||||
|
||||
export interface PrivacyTracker {
|
||||
name: string
|
||||
category: string
|
||||
matches: number
|
||||
}
|
||||
|
||||
export interface PrivacyReport {
|
||||
packageName: string
|
||||
score: number // 0-100, higher = more private
|
||||
grade: string // A-F
|
||||
trackerCount: number
|
||||
trackers: PrivacyTracker[]
|
||||
dangerousPermissions: string[]
|
||||
apkSize: number
|
||||
}
|
||||
|
||||
export interface CertInfo {
|
||||
filename: string
|
||||
subject: string
|
||||
|
|
@ -103,11 +249,15 @@ export interface BackupOptions {
|
|||
export type View =
|
||||
| 'dashboard'
|
||||
| 'files'
|
||||
| 'mirror'
|
||||
| 'packages'
|
||||
| 'debloater'
|
||||
| 'shell'
|
||||
| 'logcat'
|
||||
| 'appinspect'
|
||||
| 'intentlab'
|
||||
| 'gsiloader'
|
||||
| 'apkaudit'
|
||||
| 'certs'
|
||||
| 'backup'
|
||||
| 'props'
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export const GetDevices = () => window['go']['main']['App']['GetDevices']()
|
|||
// @ts-ignore
|
||||
export const GetDeviceInfo = () => window['go']['main']['App']['GetDeviceInfo']()
|
||||
// @ts-ignore
|
||||
export const GetSecurityOverview = () => window['go']['main']['App']['GetSecurityOverview']()
|
||||
// @ts-ignore
|
||||
export const GetDeviceMode = () => window['go']['main']['App']['GetDeviceMode']()
|
||||
// @ts-ignore
|
||||
export const Reboot = (mode: string) => window['go']['main']['App']['Reboot'](mode)
|
||||
|
|
@ -42,6 +44,30 @@ export const CopyFile = (src: string, dst: string) => window['go']['main']['App'
|
|||
export const PullMultipleFiles = (paths: string[]) => window['go']['main']['App']['PullMultipleFiles'](paths)
|
||||
// @ts-ignore
|
||||
export const SelectFileForPush = () => window['go']['main']['App']['SelectFileForPush']()
|
||||
// @ts-ignore
|
||||
export const PushWithProgress = (local: string, remoteDir: string) => window['go']['main']['App']['PushWithProgress'](local, remoteDir)
|
||||
// @ts-ignore
|
||||
export const PullPathsWithProgress = (paths: string[]) => window['go']['main']['App']['PullPathsWithProgress'](paths)
|
||||
// @ts-ignore
|
||||
export const SaveTextFile = (defaultName: string, content: string) => window['go']['main']['App']['SaveTextFile'](defaultName, content)
|
||||
// @ts-ignore
|
||||
export const HomeDir = () => window['go']['main']['App']['HomeDir']()
|
||||
// @ts-ignore
|
||||
export const ListLocalFiles = (path: string) => window['go']['main']['App']['ListLocalFiles'](path)
|
||||
// @ts-ignore
|
||||
export const PushPathsWithProgress = (localPaths: string[], remoteDir: string) => window['go']['main']['App']['PushPathsWithProgress'](localPaths, remoteDir)
|
||||
|
||||
// Screen mirror (scrcpy)
|
||||
// @ts-ignore
|
||||
export const ScrcpyAvailable = () => window['go']['main']['App']['ScrcpyAvailable']()
|
||||
// @ts-ignore
|
||||
export const ScrcpyRunning = () => window['go']['main']['App']['ScrcpyRunning']()
|
||||
// @ts-ignore
|
||||
export const StartScrcpy = (opts: any) => window['go']['main']['App']['StartScrcpy'](opts)
|
||||
// @ts-ignore
|
||||
export const StopScrcpy = () => window['go']['main']['App']['StopScrcpy']()
|
||||
// @ts-ignore
|
||||
export const CaptureScreenshot = () => window['go']['main']['App']['CaptureScreenshot']()
|
||||
|
||||
// Package ops
|
||||
// @ts-ignore
|
||||
|
|
@ -67,8 +93,12 @@ export const UninstallMultiplePackages = (pkgs: string[]) => window['go']['main'
|
|||
// @ts-ignore
|
||||
export const DisableMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['DisableMultiplePackages'](pkgs)
|
||||
// @ts-ignore
|
||||
export const UninstallAndDisableMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['UninstallAndDisableMultiplePackages'](pkgs)
|
||||
// @ts-ignore
|
||||
export const EnableMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['EnableMultiplePackages'](pkgs)
|
||||
// @ts-ignore
|
||||
export const RestoreMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['RestoreMultiplePackages'](pkgs)
|
||||
// @ts-ignore
|
||||
export const SelectFileForInstall = () => window['go']['main']['App']['SelectFileForInstall']()
|
||||
// @ts-ignore
|
||||
export const SideloadPackage = (path: string) => window['go']['main']['App']['SideloadPackage'](path)
|
||||
|
|
@ -91,11 +121,55 @@ export const DisconnectWirelessAdb = (ip: string, port: string) => window['go'][
|
|||
// @ts-ignore
|
||||
export const GetFastbootDevices = () => window['go']['main']['App']['GetFastbootDevices']()
|
||||
// @ts-ignore
|
||||
export const FlashPartition = (partition: string, file: string) => window['go']['main']['App']['FlashPartition'](partition, file)
|
||||
export const FlashPartition = (partition: string, file: string, force: boolean) => window['go']['main']['App']['FlashPartition'](partition, file, force)
|
||||
// @ts-ignore
|
||||
export const FastbootGetVar = (variable: string) => window['go']['main']['App']['FastbootGetVar'](variable)
|
||||
// @ts-ignore
|
||||
export const SelectFileForFlash = () => window['go']['main']['App']['SelectFileForFlash']()
|
||||
// @ts-ignore
|
||||
export const FastbootBoot = (file: string) => window['go']['main']['App']['FastbootBoot'](file)
|
||||
// @ts-ignore
|
||||
export const FlashBootImage = (partition: string, file: string, slot: string, force: boolean) => window['go']['main']['App']['FlashBootImage'](partition, file, slot, force)
|
||||
// @ts-ignore
|
||||
export const FastbootFlashing = (action: string) => window['go']['main']['App']['FastbootFlashing'](action)
|
||||
// @ts-ignore
|
||||
export const FastbootReboot = (target: string) => window['go']['main']['App']['FastbootReboot'](target)
|
||||
// @ts-ignore
|
||||
export const FlasherDeviceInfo = () => window['go']['main']['App']['FlasherDeviceInfo']()
|
||||
// Magisk root tools
|
||||
// @ts-ignore
|
||||
export const MagiskInstalled = () => window['go']['main']['App']['MagiskInstalled']()
|
||||
// @ts-ignore
|
||||
export const InstallMagisk = () => window['go']['main']['App']['InstallMagisk']()
|
||||
// @ts-ignore
|
||||
export const ExtractBootImages = (zipPath: string) => window['go']['main']['App']['ExtractBootImages'](zipPath)
|
||||
// @ts-ignore
|
||||
export const PushImageToDevice = (localPath: string) => window['go']['main']['App']['PushImageToDevice'](localPath)
|
||||
// @ts-ignore
|
||||
export const OpenMagisk = () => window['go']['main']['App']['OpenMagisk']()
|
||||
// @ts-ignore
|
||||
export const PullPatchedBoot = () => window['go']['main']['App']['PullPatchedBoot']()
|
||||
// @ts-ignore
|
||||
export const ListMagiskModules = () => window['go']['main']['App']['ListMagiskModules']()
|
||||
// @ts-ignore
|
||||
export const ToggleMagiskModule = (id: string, enable: boolean) => window['go']['main']['App']['ToggleMagiskModule'](id, enable)
|
||||
// @ts-ignore
|
||||
export const RemoveMagiskModule = (id: string) => window['go']['main']['App']['RemoveMagiskModule'](id)
|
||||
// Firmware download
|
||||
// @ts-ignore
|
||||
export const ListFirmware = (codename: string, kind: string) => window['go']['main']['App']['ListFirmware'](codename, kind)
|
||||
// @ts-ignore
|
||||
export const DownloadFirmware = (url: string, sha256: string) => window['go']['main']['App']['DownloadFirmware'](url, sha256)
|
||||
// @ts-ignore
|
||||
export const ListPayloadPartitions = (zipPath: string) => window['go']['main']['App']['ListPayloadPartitions'](zipPath)
|
||||
// @ts-ignore
|
||||
export const ExtractPayloadPartition = (zipPath: string, name: string) => window['go']['main']['App']['ExtractPayloadPartition'](zipPath, name)
|
||||
// @ts-ignore
|
||||
export const AnalyzeBootImage = (path: string) => window['go']['main']['App']['AnalyzeBootImage'](path)
|
||||
// @ts-ignore
|
||||
export const HashFile = (path: string) => window['go']['main']['App']['HashFile'](path)
|
||||
// @ts-ignore
|
||||
export const SelectAnyFile = () => window['go']['main']['App']['SelectAnyFile']()
|
||||
|
||||
// Logcat
|
||||
// @ts-ignore
|
||||
|
|
@ -104,11 +178,36 @@ export const StartLogcat = (filter: string, buffer: string) => window['go']['mai
|
|||
export const StopLogcat = () => window['go']['main']['App']['StopLogcat']()
|
||||
// @ts-ignore
|
||||
export const ClearLogcat = () => window['go']['main']['App']['ClearLogcat']()
|
||||
// @ts-ignore
|
||||
export const LogcatProcessNames = (): Promise<Record<string, string>> => window['go']['main']['App']['LogcatProcessNames']()
|
||||
|
||||
// App inspection
|
||||
// @ts-ignore
|
||||
export const InspectApp = (pkg: string) => window['go']['main']['App']['InspectApp'](pkg)
|
||||
// @ts-ignore
|
||||
export const ScanAppPrivacy = (pkg: string) => window['go']['main']['App']['ScanAppPrivacy'](pkg)
|
||||
// @ts-ignore
|
||||
export const ListActivities = (pkg: string) => window['go']['main']['App']['ListActivities'](pkg)
|
||||
// @ts-ignore
|
||||
export const StartActivity = (component: string) => window['go']['main']['App']['StartActivity'](component)
|
||||
// @ts-ignore
|
||||
export const StartIntentAction = (action: string, data: string) => window['go']['main']['App']['StartIntentAction'](action, data)
|
||||
// GSI Loader
|
||||
// @ts-ignore
|
||||
export const GsiCompat = () => window['go']['main']['App']['GsiCompat']()
|
||||
// @ts-ignore
|
||||
export const GsiDsuStatus = () => window['go']['main']['App']['GsiDsuStatus']()
|
||||
// @ts-ignore
|
||||
export const InstallDsu = (imagePath: string, systemSize: number, userdataSize: number) => window['go']['main']['App']['InstallDsu'](imagePath, systemSize, userdataSize)
|
||||
// @ts-ignore
|
||||
export const DsuEnable = () => window['go']['main']['App']['DsuEnable']()
|
||||
// @ts-ignore
|
||||
export const DsuDisable = () => window['go']['main']['App']['DsuDisable']()
|
||||
// @ts-ignore
|
||||
export const DsuWipe = () => window['go']['main']['App']['DsuWipe']()
|
||||
// @ts-ignore
|
||||
export const FlashGsiSystem = (imagePath: string, opts: any) => window['go']['main']['App']['FlashGsiSystem'](imagePath, opts)
|
||||
// @ts-ignore
|
||||
export const CheckPinning = (pkg: string) => window['go']['main']['App']['CheckPinning'](pkg)
|
||||
|
||||
// Certificates
|
||||
|
|
@ -140,3 +239,29 @@ export const GetAllProps = () => window['go']['main']['App']['GetAllProps']()
|
|||
export const SetProp = (key: string, value: string) => window['go']['main']['App']['SetProp'](key, value)
|
||||
// @ts-ignore
|
||||
export const GetProp = (key: string) => window['go']['main']['App']['GetProp'](key)
|
||||
|
||||
// APK Auditor
|
||||
// @ts-ignore
|
||||
export const SelectAPKForAudit = () => window['go']['main']['App']['SelectAPKForAudit']()
|
||||
// @ts-ignore
|
||||
export const AuditAPK = (path: string) => window['go']['main']['App']['AuditAPK'](path)
|
||||
// @ts-ignore
|
||||
export const AuditInstalledApp = (pkg: string) => window['go']['main']['App']['AuditInstalledApp'](pkg)
|
||||
// @ts-ignore
|
||||
export const ReadAPKEntry = (apkPath: string, entry: string) => window['go']['main']['App']['ReadAPKEntry'](apkPath, entry)
|
||||
// @ts-ignore
|
||||
export const ExportAudit = (audit: any, format: string) => window['go']['main']['App']['ExportAudit'](audit, format)
|
||||
|
||||
// App lock
|
||||
// @ts-ignore
|
||||
export const AppLockStatus = (): Promise<{ enabled: boolean; requireForDanger: boolean }> => window['go']['main']['App']['AppLockStatus']()
|
||||
// @ts-ignore
|
||||
export const VerifyAppPassword = (password: string): Promise<boolean> => window['go']['main']['App']['VerifyAppPassword'](password)
|
||||
// @ts-ignore
|
||||
export const SetAppPassword = (current: string, next: string): Promise<void> => window['go']['main']['App']['SetAppPassword'](current, next)
|
||||
// @ts-ignore
|
||||
export const DisableAppLock = (current: string): Promise<void> => window['go']['main']['App']['DisableAppLock'](current)
|
||||
// @ts-ignore
|
||||
export const SetRequireForDanger = (current: string, require: boolean): Promise<void> => window['go']['main']['App']['SetRequireForDanger'](current, require)
|
||||
// @ts-ignore
|
||||
export const UnlockDanger = (password: string): Promise<boolean> => window['go']['main']['App']['UnlockDanger'](password)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
// Local self-hosted fonts (bundled into the app — no network/CDN at runtime)
|
||||
import '@fontsource/ibm-plex-sans/400.css'
|
||||
import '@fontsource/ibm-plex-sans/500.css'
|
||||
import '@fontsource/ibm-plex-sans/600.css'
|
||||
import '@fontsource/jetbrains-mono/400.css'
|
||||
import '@fontsource/jetbrains-mono/500.css'
|
||||
import './styles/global.css'
|
||||
import { applyTheme, getTheme } from './lib/theme'
|
||||
import { applyAppearance } from './lib/appearance'
|
||||
|
||||
// Apply the saved theme + custom accent/font before first paint to avoid a flash.
|
||||
applyTheme(getTheme())
|
||||
applyAppearance()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,361 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================================
|
||||
Theme palettes — switch via data-theme on <html>.
|
||||
Values are RGB channels so Tailwind opacity modifiers work
|
||||
(e.g. bg-accent-green/5 -> rgb(var(--accent-green) / 0.05)).
|
||||
============================================================ */
|
||||
:root,
|
||||
:root[data-theme="dark"] {
|
||||
--bg-base: 10 10 15;
|
||||
--bg-surface: 17 17 24;
|
||||
--bg-raised: 24 24 31;
|
||||
--bg-border: 37 37 48;
|
||||
--accent-green: 0 255 136;
|
||||
--accent-dim: 0 204 106;
|
||||
--accent-muted: 0 51 34;
|
||||
--text-primary: 232 232 240;
|
||||
--text-secondary: 136 136 170;
|
||||
--text-muted: 68 68 90;
|
||||
--danger: 255 68 68;
|
||||
--warn: 255 170 0;
|
||||
--scrollbar-hover: 51 51 68;
|
||||
}
|
||||
|
||||
/* Catppuccin Frappé — soft pastels on a dark blue-grey base */
|
||||
:root[data-theme="frappe"] {
|
||||
--bg-base: 48 52 70; /* base #303446 */
|
||||
--bg-surface: 41 44 60; /* mantle #292c3c */
|
||||
--bg-raised: 65 69 89; /* surface0 #414559 */
|
||||
--bg-border: 81 87 109; /* surface1 #51576d */
|
||||
--accent-green: 166 209 137; /* green #a6d189 */
|
||||
--accent-dim: 140 180 115; /* darker green for hovers */
|
||||
--accent-muted: 65 69 89; /* surface0 */
|
||||
--text-primary: 198 208 245; /* text #c6d0f5 */
|
||||
--text-secondary: 165 173 206; /* subtext0 #a5adce */
|
||||
--text-muted: 115 121 148; /* overlay0 #737994 */
|
||||
--danger: 231 130 132; /* red #e78284 */
|
||||
--warn: 229 200 144; /* yellow #e5c890 */
|
||||
--scrollbar-hover: 98 104 128; /* surface2 #626880 */
|
||||
}
|
||||
|
||||
/* Catppuccin Latte — soft pastels, true light mode */
|
||||
:root[data-theme="latte"] {
|
||||
--bg-base: 239 241 245; /* base #eff1f5 */
|
||||
--bg-surface: 230 233 239; /* mantle #e6e9ef */
|
||||
--bg-raised: 220 224 232; /* crust #dce0e8 */
|
||||
--bg-border: 204 208 218; /* surface0 #ccd0da */
|
||||
--accent-green: 64 160 43; /* green #40a02b */
|
||||
--accent-dim: 50 130 35; /* darker green for hovers */
|
||||
--accent-muted: 204 227 192; /* light green tint */
|
||||
--text-primary: 76 79 105; /* text #4c4f69 */
|
||||
--text-secondary: 108 111 133; /* subtext0 #6c6f85 */
|
||||
--text-muted: 140 143 161; /* overlay1 #8c8fa1 */
|
||||
--danger: 210 15 57; /* red #d20f39 */
|
||||
--warn: 223 142 29; /* yellow #df8e1d */
|
||||
--scrollbar-hover: 188 192 204;/* surface1 #bcc0cc */
|
||||
}
|
||||
|
||||
/* Latte is the only light theme — theme its native form controls light. Set on
|
||||
body (not :root) so it never tints the transparent document canvas. */
|
||||
:root[data-theme="latte"] body { color-scheme: light; }
|
||||
|
||||
/* ============================================================
|
||||
Ported palettes from Notas — mapped onto the same RGB-channel
|
||||
tokens above. Switch via data-theme on <html>. Secondary tokens
|
||||
(accent-dim/-muted, text-muted, scrollbar-hover) are derived.
|
||||
============================================================ */
|
||||
/* Dracula — ported from Notas (dark) */
|
||||
:root[data-theme="dracula"] {
|
||||
--bg-base: 40 42 54;
|
||||
--bg-surface: 52 55 70;
|
||||
--bg-raised: 60 63 81;
|
||||
--bg-border: 68 71 90;
|
||||
--accent-green: 189 147 249;
|
||||
--accent-dim: 155 121 204;
|
||||
--accent-muted: 73 69 97;
|
||||
--text-primary: 248 248 242;
|
||||
--text-secondary: 158 168 199;
|
||||
--text-muted: 99 105 126;
|
||||
--danger: 255 85 85;
|
||||
--warn: 241 250 140;
|
||||
--scrollbar-hover: 142 151 179;
|
||||
}
|
||||
|
||||
/* Catppuccin Macchiato — ported from Notas (dark) */
|
||||
:root[data-theme="catppuccin-macchiato"] {
|
||||
--bg-base: 36 39 58;
|
||||
--bg-surface: 54 58 79;
|
||||
--bg-raised: 73 77 100;
|
||||
--bg-border: 73 77 100;
|
||||
--accent-green: 198 160 246;
|
||||
--accent-dim: 162 131 202;
|
||||
--accent-muted: 76 73 104;
|
||||
--text-primary: 202 211 245;
|
||||
--text-secondary: 165 173 203;
|
||||
--text-muted: 100 106 130;
|
||||
--danger: 237 135 150;
|
||||
--warn: 238 212 159;
|
||||
--scrollbar-hover: 148 156 184;
|
||||
}
|
||||
|
||||
/* Catppuccin Mocha — ported from Notas (dark) */
|
||||
:root[data-theme="catppuccin-mocha"] {
|
||||
--bg-base: 30 30 46;
|
||||
--bg-surface: 49 50 68;
|
||||
--bg-raised: 69 71 90;
|
||||
--bg-border: 69 71 90;
|
||||
--accent-green: 203 166 247;
|
||||
--accent-dim: 166 136 203;
|
||||
--accent-muted: 72 67 95;
|
||||
--text-primary: 205 214 244;
|
||||
--text-secondary: 166 173 200;
|
||||
--text-muted: 98 102 123;
|
||||
--danger: 243 139 168;
|
||||
--warn: 249 226 175;
|
||||
--scrollbar-hover: 149 155 180;
|
||||
}
|
||||
|
||||
/* Vintage Light — ported from Notas (light) */
|
||||
:root[data-theme="vintage-light"] {
|
||||
--bg-base: 246 239 225;
|
||||
--bg-surface: 239 229 208;
|
||||
--bg-raised: 231 218 191;
|
||||
--bg-border: 216 200 168;
|
||||
--accent-green: 176 125 58;
|
||||
--accent-dim: 144 102 48;
|
||||
--accent-muted: 230 213 185;
|
||||
--text-primary: 70 57 43;
|
||||
--text-secondary: 122 106 85;
|
||||
--text-muted: 184 172 155;
|
||||
--danger: 161 77 58;
|
||||
--warn: 176 125 58;
|
||||
--scrollbar-hover: 139 123 100;
|
||||
}
|
||||
:root[data-theme="vintage-light"] body { color-scheme: light; }
|
||||
|
||||
/* Neon Tessera — ported from Notas (dark) */
|
||||
:root[data-theme="neon-tessera"] {
|
||||
--bg-base: 10 14 20;
|
||||
--bg-surface: 17 22 31;
|
||||
--bg-raised: 22 29 41;
|
||||
--bg-border: 29 39 53;
|
||||
--accent-green: 0 229 255;
|
||||
--accent-dim: 0 188 209;
|
||||
--accent-muted: 14 53 65;
|
||||
--text-primary: 216 230 242;
|
||||
--text-secondary: 126 147 168;
|
||||
--text-muted: 68 80 94;
|
||||
--danger: 255 56 96;
|
||||
--warn: 255 196 0;
|
||||
--scrollbar-hover: 109 128 147;
|
||||
}
|
||||
|
||||
/* Adventure Time — ported from Notas (dark) */
|
||||
:root[data-theme="adventure-time"] {
|
||||
--bg-base: 31 29 69;
|
||||
--bg-surface: 42 39 85;
|
||||
--bg-raised: 52 48 106;
|
||||
--bg-border: 58 53 111;
|
||||
--accent-green: 231 116 30;
|
||||
--accent-dim: 189 95 25;
|
||||
--accent-muted: 70 51 77;
|
||||
--text-primary: 248 220 192;
|
||||
--text-secondary: 163 154 196;
|
||||
--text-muted: 97 92 132;
|
||||
--danger: 189 0 19;
|
||||
--warn: 231 176 0;
|
||||
--scrollbar-hover: 144 136 181;
|
||||
}
|
||||
|
||||
/* Borland — ported from Notas (dark) */
|
||||
:root[data-theme="borland"] {
|
||||
--bg-base: 0 0 164;
|
||||
--bg-surface: 10 26 176;
|
||||
--bg-raised: 23 48 192;
|
||||
--bg-border: 42 64 196;
|
||||
--accent-green: 255 255 78;
|
||||
--accent-dim: 209 209 64;
|
||||
--accent-muted: 47 60 161;
|
||||
--text-primary: 255 255 128;
|
||||
--text-secondary: 182 182 230;
|
||||
--text-muted: 91 91 197;
|
||||
--danger: 255 89 89;
|
||||
--warn: 255 255 78;
|
||||
--scrollbar-hover: 157 161 224;
|
||||
}
|
||||
|
||||
/* Commodore 64 — ported from Notas (dark) */
|
||||
:root[data-theme="c64"] {
|
||||
--bg-base: 64 49 141;
|
||||
--bg-surface: 77 62 160;
|
||||
--bg-raised: 90 75 176;
|
||||
--bg-border: 86 72 168;
|
||||
--accent-green: 191 206 114;
|
||||
--accent-dim: 157 169 93;
|
||||
--accent-muted: 94 84 153;
|
||||
--text-primary: 202 189 242;
|
||||
--text-secondary: 147 133 201;
|
||||
--text-muted: 106 91 171;
|
||||
--danger: 136 57 50;
|
||||
--warn: 191 206 114;
|
||||
--scrollbar-hover: 136 122 195;
|
||||
}
|
||||
|
||||
/* Fairy Floss Dark — ported from Notas (dark) */
|
||||
:root[data-theme="fairy-floss-dark"] {
|
||||
--bg-base: 59 54 76;
|
||||
--bg-surface: 74 69 100;
|
||||
--bg-raised: 86 80 111;
|
||||
--bg-border: 86 79 111;
|
||||
--accent-green: 255 184 209;
|
||||
--accent-dim: 209 151 171;
|
||||
--accent-muted: 101 86 116;
|
||||
--text-primary: 248 248 242;
|
||||
--text-secondary: 197 189 218;
|
||||
--text-muted: 128 122 147;
|
||||
--danger: 255 133 127;
|
||||
--warn: 255 234 0;
|
||||
--scrollbar-hover: 177 169 199;
|
||||
}
|
||||
|
||||
/* Flat — ported from Notas (dark) */
|
||||
:root[data-theme="flat"] {
|
||||
--bg-base: 44 62 80;
|
||||
--bg-surface: 52 73 94;
|
||||
--bg-raised: 62 88 112;
|
||||
--bg-border: 62 80 102;
|
||||
--accent-green: 52 152 219;
|
||||
--accent-dim: 43 125 180;
|
||||
--accent-muted: 52 85 113;
|
||||
--text-primary: 236 240 241;
|
||||
--text-secondary: 164 181 196;
|
||||
--text-muted: 104 122 138;
|
||||
--danger: 231 76 60;
|
||||
--warn: 241 196 15;
|
||||
--scrollbar-hover: 146 163 179;
|
||||
}
|
||||
|
||||
/* Gogh — Starry Night — ported from Notas (dark) */
|
||||
:root[data-theme="gogh"] {
|
||||
--bg-base: 13 27 52;
|
||||
--bg-surface: 20 38 74;
|
||||
--bg-raised: 27 50 96;
|
||||
--bg-border: 33 52 95;
|
||||
--accent-green: 244 205 58;
|
||||
--accent-dim: 200 168 48;
|
||||
--accent-muted: 54 63 72;
|
||||
--text-primary: 232 238 255;
|
||||
--text-secondary: 148 168 204;
|
||||
--text-muted: 80 98 128;
|
||||
--danger: 217 96 59;
|
||||
--warn: 244 205 58;
|
||||
--scrollbar-hover: 127 147 184;
|
||||
}
|
||||
|
||||
/* Grass — ported from Notas (dark) */
|
||||
:root[data-theme="grass"] {
|
||||
--bg-base: 19 119 61;
|
||||
--bg-surface: 28 138 74;
|
||||
--bg-raised: 35 154 85;
|
||||
--bg-border: 42 154 94;
|
||||
--accent-green: 231 176 0;
|
||||
--accent-dim: 189 144 0;
|
||||
--accent-muted: 58 144 63;
|
||||
--text-primary: 255 240 165;
|
||||
--text-secondary: 188 214 160;
|
||||
--text-muted: 104 166 110;
|
||||
--danger: 207 58 42;
|
||||
--warn: 231 176 0;
|
||||
--scrollbar-hover: 162 203 148;
|
||||
}
|
||||
|
||||
/* Gruvbox Material — ported from Notas (dark) */
|
||||
:root[data-theme="gruvbox-material"] {
|
||||
--bg-base: 40 40 40;
|
||||
--bg-surface: 50 48 47;
|
||||
--bg-raised: 60 56 54;
|
||||
--bg-border: 69 64 61;
|
||||
--accent-green: 216 166 87;
|
||||
--accent-dim: 177 136 71;
|
||||
--accent-muted: 75 66 53;
|
||||
--text-primary: 212 190 152;
|
||||
--text-secondary: 168 153 132;
|
||||
--text-muted: 104 96 86;
|
||||
--danger: 234 105 98;
|
||||
--warn: 216 166 87;
|
||||
--scrollbar-hover: 150 137 119;
|
||||
}
|
||||
|
||||
/* Homebrew — ported from Notas (dark) */
|
||||
:root[data-theme="homebrew"] {
|
||||
--bg-base: 0 0 0;
|
||||
--bg-surface: 12 20 12;
|
||||
--bg-raised: 18 32 18;
|
||||
--bg-border: 16 56 16;
|
||||
--accent-green: 0 255 0;
|
||||
--accent-dim: 0 209 0;
|
||||
--accent-muted: 10 55 10;
|
||||
--text-primary: 0 208 0;
|
||||
--text-secondary: 31 138 31;
|
||||
--text-muted: 16 69 16;
|
||||
--danger: 200 0 0;
|
||||
--warn: 154 154 0;
|
||||
--scrollbar-hover: 28 123 28;
|
||||
}
|
||||
|
||||
/* Ocean — ported from Notas (dark) */
|
||||
:root[data-theme="ocean"] {
|
||||
--bg-base: 43 48 59;
|
||||
--bg-surface: 52 61 70;
|
||||
--bg-raised: 62 72 85;
|
||||
--bg-border: 62 72 85;
|
||||
--accent-green: 143 161 179;
|
||||
--accent-dim: 117 132 147;
|
||||
--accent-muted: 66 76 86;
|
||||
--text-primary: 192 197 206;
|
||||
--text-secondary: 139 149 164;
|
||||
--text-muted: 91 98 112;
|
||||
--danger: 191 97 106;
|
||||
--warn: 235 203 139;
|
||||
--scrollbar-hover: 125 135 150;
|
||||
}
|
||||
|
||||
/* Kokuban — ported from Notas (dark) */
|
||||
:root[data-theme="kokuban"] {
|
||||
--bg-base: 31 53 38;
|
||||
--bg-surface: 39 64 48;
|
||||
--bg-raised: 47 76 57;
|
||||
--bg-border: 49 80 64;
|
||||
--accent-green: 242 233 200;
|
||||
--accent-dim: 198 191 164;
|
||||
--accent-muted: 69 89 71;
|
||||
--text-primary: 240 240 232;
|
||||
--text-secondary: 169 194 175;
|
||||
--text-muted: 100 124 106;
|
||||
--danger: 242 160 160;
|
||||
--warn: 240 230 140;
|
||||
--scrollbar-hover: 147 173 155;
|
||||
}
|
||||
|
||||
/* Mono Cyan — ported from Notas (dark) */
|
||||
:root[data-theme="mono-cyan"] {
|
||||
--bg-base: 8 20 20;
|
||||
--bg-surface: 14 31 31;
|
||||
--bg-raised: 20 48 48;
|
||||
--bg-border: 22 56 56;
|
||||
--accent-green: 0 208 208;
|
||||
--accent-dim: 0 171 171;
|
||||
--accent-muted: 12 58 58;
|
||||
--text-primary: 200 240 240;
|
||||
--text-secondary: 92 154 154;
|
||||
--text-muted: 50 87 87;
|
||||
--danger: 224 133 133;
|
||||
--warn: 128 224 224;
|
||||
--scrollbar-hover: 79 136 136;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
|
@ -13,29 +368,68 @@
|
|||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
/* Keep the WebKit canvas transparent so the rounded app-root corners show
|
||||
through to the desktop. Without this the browser paints an OPAQUE canvas
|
||||
backdrop dictated by `color-scheme`, which fills the four corners with a
|
||||
square — the theme-switch "square corners" bug. color-scheme is therefore
|
||||
set on <body>/controls below, NOT on :root, so it can't tint the canvas. */
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* The rounded window surface lives HERE — on #root, which is always present —
|
||||
not on a per-screen container. Previously only the main app root was
|
||||
rounded, so the loading screen and the lock gate showed a SQUARE window
|
||||
until the main view mounted (the "square at login, rounds a few seconds
|
||||
after the password" bug). Rounding #root makes every screen rounded from the
|
||||
first paint. overflow:hidden clips children to the radius; bg-base fills it;
|
||||
outside the radius stays transparent so the corners show the desktop. */
|
||||
#root {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgb(var(--bg-base));
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0f;
|
||||
color: #e8e8f0;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
/* The rounded app root (App.tsx) carries the real bg so the window corners
|
||||
clip to transparency. Needs the translucent window surface set in main.go.
|
||||
color-scheme lives here (not :root) to theme native controls without
|
||||
forcing an opaque document canvas. */
|
||||
background: transparent;
|
||||
color-scheme: dark;
|
||||
color: rgb(var(--text-primary));
|
||||
/* --app-font is an optional system-wide override set from Settings (see
|
||||
lib/appearance.ts); falls back to the bundled UI font. */
|
||||
font-family: var(--app-font, 'IBM Plex Sans', sans-serif);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
/* Content is selectable/copyable; interactive chrome opts out below. */
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Force dark theme on all form elements — overrides system/browser defaults */
|
||||
/* Buttons, navigation and the title bar shouldn't be text-selectable —
|
||||
keeps the native app feel and avoids accidental drag-selection of UI. */
|
||||
button, [role="button"], nav, aside, .titlebar {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgb(var(--accent-green) / 0.25);
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
/* Themed form elements — color-scheme is set per theme on :root */
|
||||
input, textarea, select {
|
||||
background-color: #18181f;
|
||||
color: #e8e8f0;
|
||||
border-color: #252530;
|
||||
color-scheme: dark;
|
||||
background-color: rgb(var(--bg-raised));
|
||||
color: rgb(var(--text-primary));
|
||||
border-color: rgb(var(--bg-border));
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: #18181f;
|
||||
color: #e8e8f0;
|
||||
background-color: rgb(var(--bg-raised));
|
||||
color: rgb(var(--text-primary));
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2388889a' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
|
|
@ -45,17 +439,17 @@
|
|||
}
|
||||
|
||||
select option {
|
||||
background-color: #18181f;
|
||||
color: #e8e8f0;
|
||||
background-color: rgb(var(--bg-raised));
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: #00ff88;
|
||||
background-color: #18181f;
|
||||
accent-color: rgb(var(--accent-green));
|
||||
background-color: rgb(var(--bg-raised));
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #44445a;
|
||||
color: rgb(var(--text-muted));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
|
@ -66,11 +460,11 @@
|
|||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #252530;
|
||||
background: rgb(var(--bg-border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #333344;
|
||||
background: rgb(var(--scrollbar-hover));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +529,13 @@
|
|||
@apply text-xs font-medium uppercase tracking-widest text-text-muted;
|
||||
}
|
||||
|
||||
/* compact icon button for the Logcat visual map overlays */
|
||||
.map-btn {
|
||||
@apply inline-flex items-center justify-center h-7 w-7 rounded bg-black/40
|
||||
text-text-secondary border border-bg-border hover:bg-bg-raised
|
||||
hover:text-text-primary transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.mono {
|
||||
@apply font-mono text-sm;
|
||||
}
|
||||
|
|
@ -142,7 +543,7 @@
|
|||
|
||||
/* Glow effect on accent elements */
|
||||
.glow {
|
||||
box-shadow: 0 0 12px rgba(0, 255, 136, 0.15);
|
||||
box-shadow: 0 0 12px rgb(var(--accent-green) / 0.15);
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
|
|
@ -158,6 +559,18 @@
|
|||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot-green { background: #00ff88; box-shadow: 0 0 6px #00ff8888; }
|
||||
.status-dot-red { background: #ff4444; box-shadow: 0 0 6px #ff444488; }
|
||||
.status-dot-gray { background: #44445a; animation: none; }
|
||||
.status-dot-green { background: rgb(var(--accent-green)); box-shadow: 0 0 6px rgb(var(--accent-green) / 0.53); }
|
||||
.status-dot-red { background: rgb(var(--danger)); box-shadow: 0 0 6px rgb(var(--danger) / 0.53); }
|
||||
.status-dot-gray { background: rgb(var(--text-muted)); animation: none; }
|
||||
|
||||
/* ============================================================
|
||||
Syntax highlighting (lib/syntax.tsx). Colours map onto the
|
||||
active theme's tokens, so highlighting tracks the theme (and
|
||||
any custom accent) automatically — no per-theme authoring.
|
||||
============================================================ */
|
||||
.syn-tag, .syn-key { color: rgb(var(--accent-green)); }
|
||||
.syn-attr { color: rgb(var(--accent-dim)); }
|
||||
.syn-str { color: rgb(var(--warn)); }
|
||||
.syn-num, .syn-bool{ color: rgb(var(--danger)); }
|
||||
.syn-com { color: rgb(var(--text-muted)); font-style: italic; }
|
||||
.syn-punc { color: rgb(var(--text-secondary)); }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
// Colors are driven by CSS variables (RGB channels) defined per theme in
|
||||
// src/styles/global.css, so opacity modifiers like `bg-accent-green/5` keep
|
||||
// working. Switch themes by setting data-theme="dark|frappe|latte" on <html>.
|
||||
const rgbVar = (name) => `rgb(var(${name}) / <alpha-value>)`
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
|
|
@ -8,23 +13,23 @@ export default {
|
|||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
base: '#0a0a0f',
|
||||
surface: '#111118',
|
||||
raised: '#18181f',
|
||||
border: '#252530',
|
||||
base: rgbVar('--bg-base'),
|
||||
surface: rgbVar('--bg-surface'),
|
||||
raised: rgbVar('--bg-raised'),
|
||||
border: rgbVar('--bg-border'),
|
||||
},
|
||||
accent: {
|
||||
green: '#00ff88',
|
||||
dim: '#00cc6a',
|
||||
muted: '#003322',
|
||||
green: rgbVar('--accent-green'),
|
||||
dim: rgbVar('--accent-dim'),
|
||||
muted: rgbVar('--accent-muted'),
|
||||
},
|
||||
text: {
|
||||
primary: '#e8e8f0',
|
||||
secondary: '#8888aa',
|
||||
muted: '#44445a',
|
||||
primary: rgbVar('--text-primary'),
|
||||
secondary: rgbVar('--text-secondary'),
|
||||
muted: rgbVar('--text-muted'),
|
||||
},
|
||||
danger: '#ff4444',
|
||||
warn: '#ffaa00',
|
||||
danger: rgbVar('--danger'),
|
||||
warn: rgbVar('--warn'),
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
|
||||
|
|
|
|||