Compare commits
69 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e26087029f |
||
|
|
d172427624 |
||
|
|
090d7c2472 |
||
|
|
cf3fb26bd2 | ||
|
|
ed97bc2c14 | ||
|
|
d3f5ef60c8 | ||
|
|
9d7eba705a | ||
|
|
b6700b57e3 |
||
|
|
af3af9c5ec |
||
|
|
dfcb3e7c55 |
||
|
|
a9930a005b | ||
|
|
4da6a69964 | ||
|
|
26d82c68d5 | ||
|
|
b712b76657 |
||
|
|
98546b701a |
||
|
|
93b8aae567 |
||
|
|
b0fb52be39 | ||
|
|
b3a6fc05f3 | ||
|
|
ac609bc80c | ||
|
|
0d6ae4262b |
||
|
|
97316b652d |
||
|
|
d6da1803ce |
||
|
|
cf5515e4fc |
||
|
|
7136a69867 |
||
|
|
dbc1e998e7 |
||
|
|
7e844b3704 |
||
|
|
7b718632dd |
||
|
|
729c31faba |
||
|
|
c5d8776083 |
||
|
|
3b68a88b6e |
||
|
|
c495191a82 |
||
|
|
c1e4a23bf4 |
||
|
|
31fe9425c7 |
||
|
|
741bc49999 |
||
|
|
3dffdfc0de |
||
|
|
104fcb6db0 |
||
|
|
4334906f29 |
||
|
|
a569a4cb77 |
||
|
|
31903d53f8 |
||
|
|
93083463b5 |
||
|
|
9551b42c78 |
||
|
|
1ed201ac41 |
||
|
|
9b3b3a5dfb |
||
|
|
2ea5f11aab |
||
|
|
ec5b43022e |
||
|
|
cdc4e77b2b |
||
|
|
53d2e801a5 |
||
|
|
a27f541add |
||
|
|
e6b2b2a5f0 |
||
|
|
dd08a883b9 |
||
|
|
920744ff3a |
||
|
|
956e3b1779 |
||
|
|
a564d88f9f |
||
|
|
b884d624d2 |
||
|
|
fd386c5aab |
||
|
|
31393b6e1a |
||
|
|
22fd8a9359 |
||
|
|
ca6154fa84 |
||
|
|
02a7e3875a |
||
|
|
1c53ef1526 |
||
|
|
aef54fa7f6 |
||
|
|
e86d50a056 |
||
|
|
a9922aa029 |
||
|
|
27c82c566a |
||
|
|
9b95a508b1 |
||
|
|
10902567d8 | ||
|
|
76fbccc9c4 | ||
|
|
937641f346 | ||
|
|
9d469dc9cf |
17
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: jegly
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: jegly
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
|
||||
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
|
||||
printf '[Desktop Entry]\nType=Application\nName=ATK\nGenericName=Android Toolkit\nComment=All-in-one ADB GUI for Android power users and bug hunters\nExec=ATK\nIcon=atk\nCategories=Development;Utility;\nTerminal=false\nStartupWMClass=ATK\n' > 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 build/atk.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
|
||||
11
.gitignore
vendored
|
|
@ -37,3 +37,14 @@ ATK_*.log
|
|||
# Temp
|
||||
*.tmp
|
||||
/tmp/
|
||||
|
||||
# Raw font source dump (bundled fonts already live in frontend/src/assets/fonts/)
|
||||
/DotGothic16,Geist_Pixel,Gugi,Orbitron,Playfair_Display,etc/
|
||||
/DotGothic16,Geist_Pixel,Gugi,Orbitron,Playfair_Display,etc.zip
|
||||
|
||||
# ── 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
Executable file
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 |
493
README.md
|
|
@ -1,145 +1,375 @@
|
|||
```
|
||||
█████╗ ████████╗██╗ ██╗
|
||||
██╔══██╗╚══██╔══╝██║ ██╔╝
|
||||
███████║ ██║ █████╔╝
|
||||
██╔══██║ ██║ ██╔═██╗
|
||||
██║ ██║ ██║ ██║ ██╗
|
||||
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
|
||||
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, restore, pull APK, plus **privileged removal of protected system apps without root**. Correctly shows packages disabled/uninstalled by other tools (e.g. Canta/Shizuku) instead of hiding them, with a state filter (Enabled/Disabled/Uninstalled) |
|
||||
| 📲 **APK Installer** | Batch-install APKs from a folder or hand-picked files, with live per-file progress and select all/none |
|
||||
| 🧹 **Debloater** | 5,362 packages across Samsung, Xiaomi, Google, and 11 more OEMs |
|
||||
| 💻 **Shell Terminal** | adb shell and host, command library, export session |
|
||||
| 📡 **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, plus a 0–100 **privacy score** from a DEX tracker/ad-SDK scan |
|
||||
| 🚀 **Intent Lab** | List an app's launchable activities from `dumpsys` and start them with one click, plus a free-form implicit-intent launcher — reach hidden settings screens |
|
||||
| 🔎 **APK Audit** | Static APK security audit: perms, trackers, certs, rule findings |
|
||||
| 🔐 **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 |
|
||||
| 🧰 **Utilities** | 631 one-click commands across 50+ categories |
|
||||
| ⚡ **Flasher** | Fastboot, live-boot, Magisk root, firmware download |
|
||||
| 💿 **GSI Loader** | Boot a Generic System Image via **DSU** (temporary, no unlock/wipe) or a danger-gated **permanent fastboot flash**, with a Treble/ABI/VNDK compatibility pre-check |
|
||||
|
||||
> [!TIP]
|
||||
> Hide any module you don't use from **Settings → Sidebar Features**. 26 theme
|
||||
> palettes — Dark, Catppuccin Frappé/Latte/Mocha/Macchiato, Dracula, Gruvbox
|
||||
> Material, Nord, Tokyo Night, Solarized, Rosé Pine, Everforest, and more —
|
||||
> plus a custom accent/text colour, 8 bundled display fonts, adjustable font
|
||||
> size, and sidebar position (left, top, bottom) are all configurable too. A
|
||||
> system tray icon (Linux) lets ATK keep running in the background instead of
|
||||
> quitting.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
[ SECURITY ]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
```
|
||||
## ✨ What's new in v1.3.0
|
||||
|
||||
```
|
||||
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.
|
||||
- 🖥️ **System tray** (Linux): closing the window now offers **Minimize to
|
||||
Tray** instead of only Quit, with a tray menu to show/hide the window or
|
||||
quit for good.
|
||||
- 📲 **APK Installer**: a new module to batch-install APKs from a folder or
|
||||
hand-picked files, with live per-file progress and select all/none.
|
||||
- 📦 **Package visibility fix**: Packages and App Inspector were silently
|
||||
hiding any package disabled/uninstalled by tools like Canta or Shizuku
|
||||
(`pm uninstall --user 0` doesn't fully remove it, just hides it from a plain
|
||||
package list). They now show correctly, with a state filter
|
||||
(Enabled/Disabled/Uninstalled) and per-row Restore/Enable/Disable/Uninstall.
|
||||
- 🎨 **6 more theme palettes** — Nord, Tokyo Night, Solarized Dark/Light, Rosé
|
||||
Pine, Everforest (26 total) — plus a custom text colour, 8 bundled display
|
||||
fonts, and an adjustable base font size, all in Settings → Appearance.
|
||||
- 🖱️ **Sidebar polish**: a horizontal (top/bottom) sidebar now shrinks label
|
||||
text progressively as the window narrows instead of showing a scrollbar,
|
||||
and a touchpad-triggered stuck-scrollbar-drag bug is fixed.
|
||||
|
||||
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.
|
||||
<details>
|
||||
<summary>Previous release (v1.2.0)</summary>
|
||||
|
||||
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.
|
||||
```
|
||||
- 💿 **GSI Loader**: boot a Generic System Image via **DSU** (temporary guest OS)
|
||||
or a danger-gated **permanent fastboot flash**, with a built-in compatibility
|
||||
pre-check.
|
||||
- 🚀 **Intent Lab**: list and launch an app's exported activities from `dumpsys`,
|
||||
plus a free-form implicit-intent launcher, to reach hidden settings screens.
|
||||
- 🕵️ **Privacy & Tracker Scanner**: a 0–100 privacy score and A–F grade for any
|
||||
app, from an offline DEX tracker/ad-SDK scan, built into App Inspector.
|
||||
- 🔒 **App Lock**: password-protect the app and gate destructive actions
|
||||
(flashing, permanent GSI install) behind a separate danger-unlock.
|
||||
- 🎨 **~14 new theme palettes** — Dracula, Catppuccin Mocha/Macchiato, Gruvbox
|
||||
Material, C64, Adventure Time, and more.
|
||||
- 🧹 **Debloater**: unmatched device packages now show as **Unknown** instead of
|
||||
disappearing from the list.
|
||||
- 🔐 **Security**: fixed a path-injection issue in the local file viewer, and
|
||||
bumped several dependencies with known CVEs (`x/image`, `x/net`, `vite`,
|
||||
`postcss`, `esbuild`).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
[ 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
|
||||
|
||||
---
|
||||
|
||||
## 💿 GSI Loader
|
||||
|
||||
Boot a Generic System Image (GSI) two different ways, each with its own risk
|
||||
profile.
|
||||
|
||||
| Mode | What it does |
|
||||
|---|---|
|
||||
| **DSU (Temporary)** | Loads the GSI as a guest OS via Android's Dynamic System Updates. No bootloader unlock, no data wipe — reboot to return to your normal system |
|
||||
| **GSI Flasher (Permanent)** | Fastboot-flashes the GSI to the system partition. Destructive: wipes userdata, needs an unlocked bootloader. Danger-gated behind App Lock |
|
||||
|
||||
Both modes run a **compatibility pre-check** first — Treble enablement, CPU ABI,
|
||||
VNDK isolation, and Android version — so you know whether a GSI is even viable
|
||||
on the connected device before you commit to either path. You supply the GSI
|
||||
image file (ATK doesn't fetch these — see Android Flash Tool / ci.android.com).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The permanent flash path wipes user data and requires an unlocked bootloader.
|
||||
> It's gated behind App Lock's danger-unlock, same as other destructive flash
|
||||
> operations.
|
||||
|
||||
---
|
||||
|
||||
## 🔎 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.
|
||||
> - **App Lock.** Optionally password-protect the app itself, with a separate danger-unlock required for destructive actions (permanent flashing, permanent GSI install, wiping).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
libwebkit2gtk-4.1-dev adb fastboot
|
||||
|
||||
# Go 1.23
|
||||
wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
|
||||
# Go 1.25+ (required by go.mod)
|
||||
wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go
|
||||
sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
|
||||
sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
|
|
@ -150,7 +380,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 +390,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 +406,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>
|
||||
|
|
|
|||
58
RELEASE_NOTES_v1.2.0.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# ATK v1.2.0
|
||||
|
||||
Six new tools, a big theming pass, and a security cleanup.
|
||||
|
||||
## ✨ What's new
|
||||
- 💿 **GSI Loader** — boot a Generic System Image two ways: **DSU** (temporary
|
||||
guest OS, no unlock/wipe, reboot to reclean) or a **permanent fastboot flash**
|
||||
(danger-gated, requires unlocked bootloader). Built-in compatibility pre-check
|
||||
(Treble, ABI, VNDK isolation) before either path.
|
||||
- 🚀 **Intent Lab** — list an app's launchable (exported) activities straight from
|
||||
`dumpsys` and start them with one click, plus a free-form implicit-intent
|
||||
launcher. Reach hidden settings menus and internal screens that never show up
|
||||
on the launcher.
|
||||
- 📥 **Firmware** — download and SHA-verify firmware images for a device
|
||||
codename, right from the app.
|
||||
- 📱 **Screen Mirror** — scrcpy integration: start/stop mirroring and grab
|
||||
screenshots without leaving ATK.
|
||||
- 🧙 **Magisk integration** — install Magisk, extract/patch boot images, and
|
||||
manage installed modules (list, toggle, remove) in one panel.
|
||||
- 🕵️ **Privacy & Tracker Scanner** — scans an app's DEX bytecode for known
|
||||
tracker/analytics/ad SDK signatures, cross-references dangerous permissions,
|
||||
and derives a 0–100 privacy score with an A–F grade. Fully offline, no
|
||||
network calls.
|
||||
- 🔒 **App Lock** — password-protect the app and gate destructive actions
|
||||
(flashing, permanent GSI install, etc.) behind a separate danger-unlock.
|
||||
- 🔓 **Privileged uninstall, no root** — removes protected system apps via a
|
||||
bundled Android helper, with one-click restore.
|
||||
- 🎨 **~14 new theme palettes** (Dracula, Catppuccin Mocha/Macchiato,
|
||||
Gruvbox Material, C64, Adventure Time, and more), on top of the existing
|
||||
Dark/Frappé/Latte.
|
||||
- 🧹 **Debloater**: device packages with no UAD database match now show as
|
||||
**Unknown/Uncategorized** instead of silently disappearing from the list.
|
||||
- 🔎 **APK Audit**: entry viewer now has syntax-highlighted code display instead
|
||||
of a plain text dump.
|
||||
|
||||
## 🔐 Security
|
||||
- Fixed a path-injection issue in the local file viewer (CodeQL
|
||||
`go/path-injection`) — the resolved path is now validated as a real, regular
|
||||
file before being served.
|
||||
- Dependency bumps: `golang.org/x/image` and `golang.org/x/net` (DoS fixes),
|
||||
`vite`, `postcss`, and `esbuild` (dev-tooling only, not shipped in the
|
||||
built app).
|
||||
|
||||
## 📦 Install (Linux)
|
||||
**Debian / Ubuntu:**
|
||||
```bash
|
||||
sudo dpkg -i atk_1.2.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)"
|
||||
85
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
|
||||
|
|
@ -57,6 +60,11 @@ type FileEntry struct {
|
|||
type PackageInfo struct {
|
||||
PackageName string `json:"packageName"`
|
||||
IsEnabled bool `json:"isEnabled"`
|
||||
// IsInstalled is false when the package is present on the system image but
|
||||
// uninstalled for user 0 (e.g. via `pm uninstall --user 0`, which is what
|
||||
// Canta/Shizuku do to "remove" a system app). Such packages are still on
|
||||
// disk and show up in `pm list packages -u`, but not in the plain listing.
|
||||
IsInstalled bool `json:"isInstalled"`
|
||||
}
|
||||
|
||||
// AdbConfig holds user-configurable ADB settings
|
||||
|
|
@ -77,6 +85,14 @@ 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
|
||||
|
||||
// window visibility, tracked for the tray's show/hide toggle
|
||||
windowMu sync.Mutex
|
||||
windowShown bool
|
||||
}
|
||||
|
||||
// NewApp creates a new App instance
|
||||
|
|
@ -90,4 +106,73 @@ func NewApp() *App {
|
|||
// Startup is called when the app starts
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
a.windowShown = true
|
||||
// Frameless windows can open off-centre on some WMs; centre on launch.
|
||||
runtime.WindowCenter(ctx)
|
||||
initTray(a)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
closeTray()
|
||||
}
|
||||
|
||||
// ShowWindow restores the main window - used by the tray's "Show ATK" /
|
||||
// left-click action and by the frontend's "Minimize to tray" quit dialog.
|
||||
func (a *App) ShowWindow() {
|
||||
a.windowMu.Lock()
|
||||
a.windowShown = true
|
||||
a.windowMu.Unlock()
|
||||
runtime.WindowShow(a.ctx)
|
||||
runtime.WindowUnminimise(a.ctx)
|
||||
}
|
||||
|
||||
// HideWindow parks the window (app keeps running, reachable from the tray).
|
||||
func (a *App) HideWindow() {
|
||||
a.windowMu.Lock()
|
||||
a.windowShown = false
|
||||
a.windowMu.Unlock()
|
||||
runtime.WindowHide(a.ctx)
|
||||
}
|
||||
|
||||
// ToggleWindow shows the window if hidden, hides it if shown - the tray's
|
||||
// left-click / "Show ATK" menu action.
|
||||
func (a *App) ToggleWindow() {
|
||||
a.windowMu.Lock()
|
||||
shown := a.windowShown
|
||||
a.windowMu.Unlock()
|
||||
if shown {
|
||||
a.HideWindow()
|
||||
} else {
|
||||
a.ShowWindow()
|
||||
}
|
||||
}
|
||||
|
||||
// TrayAvailable reports whether a real system tray icon was registered (e.g.
|
||||
// false on Linux if no StatusNotifierWatcher/AppIndicator host is running).
|
||||
// The frontend uses this to decide whether "Minimize to tray" is even a
|
||||
// sensible option to offer in the close-confirmation dialog.
|
||||
func (a *App) TrayAvailable() bool {
|
||||
return trayAvailable()
|
||||
}
|
||||
|
||||
// QuitApp fully quits ATK (not just the window) - used by both the tray's
|
||||
// "Quit ATK" menu item and the frontend's close-confirmation dialog.
|
||||
func (a *App) QuitApp() {
|
||||
runtime.Quit(a.ctx)
|
||||
}
|
||||
|
||||
// OpenURL opens a link in the user's default system browser. A bare <a
|
||||
// target="_blank"> isn't reliable inside the embedded webview - this is
|
||||
// Wails' supported way to hand off to the OS.
|
||||
func (a *App) OpenURL(url string) {
|
||||
runtime.BrowserOpenURL(a.ctx, url)
|
||||
}
|
||||
|
|
|
|||
|
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 |
13
assets/atk-appimage.desktop
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=ATK
|
||||
GenericName=Android Toolkit
|
||||
Comment=All-in-one ADB GUI for Android power users and bug hunters
|
||||
Exec=ATK
|
||||
Icon=atk
|
||||
Categories=Utility;
|
||||
Terminal=false
|
||||
StartupWMClass=ATK
|
||||
Keywords=android;adb;fastboot;debloat;logcat;flash;
|
||||
StartupNotify=true
|
||||
13
assets/atk-deb.desktop
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=ATK
|
||||
GenericName=Android Toolkit
|
||||
Comment=All-in-one ADB GUI for Android power users and bug hunters
|
||||
Exec=/opt/atk/ATK
|
||||
Icon=atk
|
||||
Categories=Utility;
|
||||
Terminal=false
|
||||
StartupWMClass=ATK
|
||||
Keywords=android;adb;fastboot;debloat;logcat;flash;
|
||||
StartupNotify=true
|
||||
1
assets/atk-tray-symbolic.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10c1.28,0 2.5,-0.2 3.68,-0.68l-0.36,-1.9C14.34,19.79 13.19,20 12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8s8,3.59 8,8v0.9c0,0.61 -0.6,1.32 -1.5,1.32s-1.5,-0.71 -1.5,-1.32V8h-2v0.68C14.42,8.24 13.51,8 12.5,8C10.02,8 8,10.02 8,12.5s2.02,4.5 4.5,4.5c1.14,0 2.17,-0.42 2.96,-1.11c0.55,0.66 1.42,1.11 2.29,1.11c1.66,0 3.25,-1.34 3.25,-3.32V12C21,6.48 16.52,2 12,2zM12.5,15c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5S13.88,15 12.5,15z" fill="#bebebe"/></svg>
|
||||
|
After Width: | Height: | Size: 600 B |
13
assets/atk.desktop
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=ATK
|
||||
GenericName=Android Toolkit
|
||||
Comment=All-in-one ADB GUI for Android power users and bug hunters
|
||||
Exec=/opt/atk/ATK
|
||||
Icon=atk
|
||||
Categories=Utility;
|
||||
Terminal=false
|
||||
StartupWMClass=ATK
|
||||
Keywords=android;adb;fastboot;debloat;logcat;flash;
|
||||
StartupNotify=true
|
||||
BIN
assets/tray_icon.ico
Normal file
|
After Width: | Height: | Size: 696 B |
BIN
assets/tray_icon.png
Normal file
|
After Width: | Height: | Size: 411 B |
BIN
assets/tray_icon_template.png
Normal file
|
After Width: | Height: | Size: 396 B |
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)
|
||||
}
|
||||
}
|
||||
118
backend_apkinstaller.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// ApkFileInfo describes one APK found in a chosen folder, for the batch
|
||||
// installer's checklist.
|
||||
type ApkFileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ListApksInFolder scans a folder (non-recursive) for .apk files.
|
||||
func (a *App) ListApksInFolder(folderPath string) ([]ApkFileInfo, error) {
|
||||
entries, err := os.ReadDir(folderPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read folder: %w", err)
|
||||
}
|
||||
|
||||
var apks []ApkFileInfo
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".apk") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
apks = append(apks, ApkFileInfo{
|
||||
Path: filepath.Join(folderPath, e.Name()),
|
||||
Name: e.Name(),
|
||||
Size: info.Size(),
|
||||
})
|
||||
}
|
||||
sort.Slice(apks, func(i, j int) bool { return apks[i].Name < apks[j].Name })
|
||||
return apks, nil
|
||||
}
|
||||
|
||||
// StatApkFiles builds ApkFileInfo entries for explicitly chosen APK paths
|
||||
// (from the multi-file picker), so manual selection and folder scans feed the
|
||||
// same checklist shape. Unreadable paths are silently skipped.
|
||||
func (a *App) StatApkFiles(paths []string) ([]ApkFileInfo, error) {
|
||||
out := make([]ApkFileInfo, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
out = append(out, ApkFileInfo{Path: p, Name: info.Name(), Size: info.Size()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InstallApksWithProgress installs each APK in order (adb install -r),
|
||||
// emitting apkinstall:progress events so the UI can show a live per-file
|
||||
// checklist rather than one opaque spinner. Cancellable via CancelOperation.
|
||||
func (a *App) InstallApksWithProgress(paths []string) (string, error) {
|
||||
if len(paths) == 0 {
|
||||
return "", fmt.Errorf("no APKs selected")
|
||||
}
|
||||
|
||||
ctx, cancel := a.beginCancellableOp(0)
|
||||
defer cancel()
|
||||
|
||||
total := len(paths)
|
||||
var ok, fail int
|
||||
var details strings.Builder
|
||||
|
||||
for i, p := range paths {
|
||||
name := baseName(p)
|
||||
// Keyed by full path (not just fileName) - two selected APKs from
|
||||
// different folders can share a filename, and the path is what's
|
||||
// actually unique in the frontend's checklist.
|
||||
runtime.EventsEmit(a.ctx, "apkinstall:progress", map[string]interface{}{
|
||||
"current": i + 1, "total": total, "path": p, "fileName": name, "status": "installing",
|
||||
})
|
||||
|
||||
output, err := a.runCommandContext(ctx, "adb", "install", "-r", p)
|
||||
status, msg := "success", ""
|
||||
switch {
|
||||
case err != nil:
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
case strings.Contains(output, "Failure"):
|
||||
status = "failed"
|
||||
msg = strings.TrimSpace(output)
|
||||
}
|
||||
|
||||
if status == "success" {
|
||||
ok++
|
||||
} else {
|
||||
fail++
|
||||
details.WriteString(fmt.Sprintf("• %s: %s\n", name, msg))
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "apkinstall:progress", map[string]interface{}{
|
||||
"current": i + 1, "total": total, "path": p, "fileName": name, "status": status, "message": msg,
|
||||
})
|
||||
|
||||
if strings.Contains(msg, "cancelled") {
|
||||
break
|
||||
}
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "apkinstall:done", nil)
|
||||
|
||||
summary := fmt.Sprintf("Installed %d of %d APK(s).", ok, total)
|
||||
if fail > 0 {
|
||||
summary += fmt.Sprintf(" Failed: %d\n%s", fail, details.String())
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
68
backend_filehttp.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 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's built-in ".." rejection only applies to r.URL.Path, not to
|
||||
// a path we hand it explicitly — so confirm p resolves to a real,
|
||||
// regular file before serving it (blocks traversal to devices/pipes/dirs
|
||||
// and nonexistent paths, satisfies CodeQL go/path-injection).
|
||||
clean := filepath.Clean(p)
|
||||
info, err := os.Stat(clean)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// ServeFile picks the Content-Type and supports range requests.
|
||||
http.ServeFile(w, r, clean)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
169
backend_logcatpatterns.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Relationship extraction for the Logcat visual map — ported to Go so the mining
|
||||
// heuristics live in native (compiled) code rather than shipped JavaScript.
|
||||
//
|
||||
// A log line is just text, but Android's framework + system-event logs encode
|
||||
// real relationships: who started whom, who crashed, who got killed, who sent a
|
||||
// signal to which pid. We mine those so the map can draw meaningful edges on top
|
||||
// of the ambient co-occurrence web. Runs in the existing per-line log pipeline
|
||||
// (parseLogcatLine), so the result ships attached to each LogcatLine — no extra IPC.
|
||||
|
||||
// LogRef is one relationship a log line implies. JSON shape matches the frontend.
|
||||
type LogRef struct {
|
||||
Kind string `json:"kind"` // activity|spawn|death|crash|anr|signal|gfx|mention
|
||||
Target string `json:"target"` // package, component, or pid payload
|
||||
TargetKind string `json:"targetKind"` // package|component|pid
|
||||
}
|
||||
|
||||
var (
|
||||
lcpPkg = regexp.MustCompile(`\b([a-z][a-z0-9_]*(?:\.[a-z0-9_]+){2,})\b`)
|
||||
lcpPkgAnchor = regexp.MustCompile(`^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+){2,}$`)
|
||||
lcpComponent = regexp.MustCompile(`(?i)([a-z][a-z0-9_.]+)/([a-z0-9_.$]+)`)
|
||||
lcpEventCSV = regexp.MustCompile(`\[([^\]]*)\]`)
|
||||
lcpSig = regexp.MustCompile(`Sending signal\.\s*PID:\s*(\d+)`)
|
||||
lcpProcess = regexp.MustCompile(`(?i)Process:\s*([a-z][a-z0-9_.]+)`)
|
||||
lcpANRin = regexp.MustCompile(`\bANR in\b`)
|
||||
lcpStartProc = regexp.MustCompile(`\bStart proc\b`)
|
||||
lcpKilling = regexp.MustCompile(`\bKilling\b|\bhas died\b|\bdied\b`)
|
||||
lcpStartAct = regexp.MustCompile(`\bSTART u\d+|\bDisplayed\b|\bmoveTaskTo`)
|
||||
lcpFatal = regexp.MustCompile(`FATAL EXCEPTION`)
|
||||
lcpGfxTag = regexp.MustCompile(`^(SurfaceFlinger|WindowManager|ViewRootImpl|Choreographer|gralloc|OpenGLRenderer)`)
|
||||
lcpMentSkip = regexp.MustCompile(`^(java|javax|sun|kotlin|android|androidx|dalvik)\.`)
|
||||
)
|
||||
|
||||
func lcpFirstPackage(s string) string {
|
||||
if m := lcpPkg.FindStringSubmatch(s); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Pull the package field out of an event-log CSV payload (first dotted token).
|
||||
func lcpEventPackage(msg string) string {
|
||||
csv := lcpEventCSV.FindStringSubmatch(msg)
|
||||
if csv == nil {
|
||||
return lcpFirstPackage(msg)
|
||||
}
|
||||
for _, f := range strings.Split(csv[1], ",") {
|
||||
t := strings.TrimSpace(f)
|
||||
if lcpPkgAnchor.MatchString(t) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return lcpFirstPackage(msg)
|
||||
}
|
||||
|
||||
// lcpExtractRefs mines the relationships a single log line implies. Returns an
|
||||
// empty slice for the vast majority of lines.
|
||||
func lcpExtractRefs(tag, msg string) []LogRef {
|
||||
refs := []LogRef{}
|
||||
push := func(kind, target, targetKind string) {
|
||||
if target != "" {
|
||||
refs = append(refs, LogRef{Kind: kind, Target: target, TargetKind: targetKind})
|
||||
}
|
||||
}
|
||||
|
||||
// binary event-log tags (events buffer)
|
||||
switch tag {
|
||||
case "am_proc_start", "am_proc_bound":
|
||||
push("spawn", lcpEventPackage(msg), "package")
|
||||
return refs
|
||||
case "am_proc_died", "am_kill", "am_low_memory":
|
||||
push("death", lcpEventPackage(msg), "package")
|
||||
return refs
|
||||
case "am_crash":
|
||||
push("crash", lcpEventPackage(msg), "package")
|
||||
return refs
|
||||
case "am_anr":
|
||||
push("anr", lcpEventPackage(msg), "package")
|
||||
return refs
|
||||
case "am_activity_launch_time", "am_focused_activity", "am_resume_activity", "am_pause_activity", "wm_focused_window":
|
||||
if c := lcpComponent.FindStringSubmatch(msg); c != nil {
|
||||
push("activity", c[1]+"/"+c[2], "component")
|
||||
} else {
|
||||
push("activity", lcpEventPackage(msg), "package")
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// framework text logs (main/system buffers)
|
||||
if tag == "ActivityManager" || tag == "ActivityTaskManager" {
|
||||
if lcpANRin.MatchString(msg) {
|
||||
push("anr", lcpFirstPackage(msg), "package")
|
||||
}
|
||||
if lcpStartProc.MatchString(msg) {
|
||||
push("spawn", lcpFirstPackage(msg), "package")
|
||||
}
|
||||
if lcpKilling.MatchString(msg) {
|
||||
push("death", lcpFirstPackage(msg), "package")
|
||||
}
|
||||
if sig := lcpSig.FindStringSubmatch(msg); sig != nil {
|
||||
push("signal", sig[1], "pid")
|
||||
}
|
||||
if lcpStartAct.MatchString(msg) {
|
||||
if c := lcpComponent.FindStringSubmatch(msg); c != nil {
|
||||
push("activity", c[1]+"/"+c[2], "component")
|
||||
} else {
|
||||
push("activity", lcpFirstPackage(msg), "package")
|
||||
}
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
return refs
|
||||
}
|
||||
}
|
||||
|
||||
if tag == "AndroidRuntime" || lcpFatal.MatchString(msg) {
|
||||
if p := lcpProcess.FindStringSubmatch(msg); p != nil {
|
||||
push("crash", p[1], "package")
|
||||
} else {
|
||||
push("crash", lcpFirstPackage(msg), "package")
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
return refs
|
||||
}
|
||||
}
|
||||
|
||||
if tag == "lowmemorykiller" || tag == "lmkd" {
|
||||
push("death", lcpFirstPackage(msg), "package")
|
||||
if len(refs) > 0 {
|
||||
return refs
|
||||
}
|
||||
}
|
||||
|
||||
if lcpGfxTag.MatchString(tag) {
|
||||
if c := lcpComponent.FindStringSubmatch(msg); c != nil {
|
||||
push("gfx", c[1]+"/"+c[2], "component")
|
||||
return refs
|
||||
}
|
||||
}
|
||||
|
||||
return refs
|
||||
}
|
||||
|
||||
// lcpExtractMentions: generic fallback — up to `max` package-looking tokens
|
||||
// (used when the map's "parsed mentions" toggle is on).
|
||||
func lcpExtractMentions(msg string, max int) []LogRef {
|
||||
out := []LogRef{}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range lcpPkg.FindAllStringSubmatch(msg, -1) {
|
||||
if len(out) >= max {
|
||||
break
|
||||
}
|
||||
t := m[1]
|
||||
if seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
if lcpMentSkip.MatchString(t) {
|
||||
continue
|
||||
}
|
||||
out = append(out, LogRef{Kind: "mention", Target: t, TargetKind: "package"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
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(
|
||||
|
|
@ -69,6 +78,33 @@ func (a *App) SelectDirectoryForPull() (string, error) {
|
|||
return path, err
|
||||
}
|
||||
|
||||
// SelectApkFolder opens a native directory picker for a folder of APKs to
|
||||
// batch-install.
|
||||
func (a *App) SelectApkFolder() (string, error) {
|
||||
path, err := zenity.SelectFile(
|
||||
zenity.Title("Select a folder of APKs"),
|
||||
zenity.Directory(),
|
||||
)
|
||||
if err == zenity.ErrCanceled {
|
||||
return "", nil
|
||||
}
|
||||
return path, err
|
||||
}
|
||||
|
||||
// SelectMultipleApkFiles opens a native multi-file picker filtered to APKs.
|
||||
func (a *App) SelectMultipleApkFiles() ([]string, error) {
|
||||
paths, err := zenity.SelectFileMultiple(
|
||||
zenity.Title("Select APKs to install"),
|
||||
zenity.FileFilters{
|
||||
{Name: "APK files", Patterns: []string{"*.apk"}, CaseFold: true},
|
||||
},
|
||||
)
|
||||
if err == zenity.ErrCanceled {
|
||||
return nil, nil
|
||||
}
|
||||
return paths, err
|
||||
}
|
||||
|
||||
// SelectFileWithFilter opens a file picker with custom file type filters.
|
||||
func (a *App) SelectFileWithFilter(title string, patterns []string) (string, error) {
|
||||
filters := []zenity.FileFilter{
|
||||
|
|
|
|||
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,19 +9,25 @@
|
|||
"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",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitejs/plugin-react": "4",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.3"
|
||||
"vite": "^6.4.3",
|
||||
"vite-plugin-javascript-obfuscator": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
d772c5ee4d5ec9453e4b361871c1c91f
|
||||
5044e767932c804d924f539d25c7ab46
|
||||
1845
frontend/pnpm-lock.yaml
generated
|
|
@ -1,48 +1,74 @@
|
|||
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 QuitGate from './components/QuitGate'
|
||||
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 ViewApkInstaller from './components/views/ViewApkInstaller'
|
||||
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 'apkinstaller': return <ViewApkInstaller />
|
||||
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 +84,37 @@ 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 />
|
||||
<QuitGate />
|
||||
<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',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
BIN
frontend/src/assets/fonts/DotGothic16-Regular.ttf
Normal file
BIN
frontend/src/assets/fonts/Gugi-Regular.ttf
Normal file
BIN
frontend/src/assets/fonts/Orbitron-VariableFont_wght.ttf
Normal file
BIN
frontend/src/assets/fonts/PlayfairDisplay-VariableFont_wght.ttf
Normal file
BIN
frontend/src/assets/fonts/PressStart2P-Regular.ttf
Normal file
BIN
frontend/src/assets/fonts/SpaceGrotesk-VariableFont_wght.ttf
Normal file
BIN
frontend/src/assets/fonts/VT323-Regular.ttf
Normal file
93
frontend/src/assets/fonts/licenses/DotGothic16-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2020 The DotGothic16 Project Authors (https://github.com/fontworks-fonts/DotGothic16)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
frontend/src/assets/fonts/licenses/Geist_Pixel-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2026 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
frontend/src/assets/fonts/licenses/Gugi-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright (c) 2017 by TAE System & Typefaces Co.. All rights reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
frontend/src/assets/fonts/licenses/Orbitron-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2018 The Orbitron Project Authors (https://github.com/theleagueof/orbitron), with Reserved Font Name: "Orbitron"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
frontend/src/assets/fonts/licenses/Playfair_Display-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
frontend/src/assets/fonts/licenses/Press_Start_2P-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
frontend/src/assets/fonts/licenses/Space_Grotesk-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
frontend/src/assets/fonts/licenses/VT323-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
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>
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/QuitGate.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { LogOut, Minus, X } from 'lucide-react'
|
||||
import { HideWindow, QuitApp, TrayAvailable } from '../lib/wails'
|
||||
import { _registerQuitHost } from '../lib/quitgate'
|
||||
|
||||
// Modal shown when the title bar's close button is clicked. Mirrors
|
||||
// Frequency's close dialog: offer Minimize-to-tray (default) alongside Quit,
|
||||
// rather than one silently winning. "Minimize to tray" only shows up if a
|
||||
// tray icon actually registered (TrayAvailable) - otherwise there'd be no way
|
||||
// back to the window.
|
||||
export default function QuitGate() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [trayOk, setTrayOk] = useState(false)
|
||||
|
||||
useEffect(() => _registerQuitHost(() => {
|
||||
TrayAvailable().then(setTrayOk).catch(() => setTrayOk(false))
|
||||
setOpen(true)
|
||||
}), [])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const close = () => setOpen(false)
|
||||
const minimize = () => { close(); HideWindow() }
|
||||
const quit = () => { close(); QuitApp() }
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={e => { if (e.target === e.currentTarget) close() }}
|
||||
>
|
||||
<div className="card p-5 w-80 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-text-primary">Close ATK?</p>
|
||||
<button onClick={close} className="btn-ghost text-xs p-1"><X size={14} /></button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{trayOk
|
||||
? 'ATK can keep running in the background and stay reachable from the tray, or quit completely.'
|
||||
: 'No tray icon is available on this system, so closing quits ATK completely.'}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={close} className="btn-ghost text-xs">Cancel</button>
|
||||
{trayOk && (
|
||||
<button onClick={minimize} className="btn-ghost text-xs flex items-center gap-1.5">
|
||||
<Minus size={12} /> Minimize to tray
|
||||
</button>
|
||||
)}
|
||||
<button onClick={quit} className="btn-danger text-xs flex items-center gap-1.5">
|
||||
<LogOut size={12} /> Quit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,77 +1,227 @@
|
|||
import {
|
||||
LayoutDashboard, FolderOpen, Package, Terminal,
|
||||
Zap, Wrench, Settings, Radio, Shield, Smartphone,
|
||||
ScrollText, Search, Lock, Archive, SlidersHorizontal
|
||||
LayoutDashboard, FolderOpen, Package, PackagePlus, Terminal,
|
||||
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: 'apkinstaller', icon: <PackagePlus size={17} />, label: 'APK Installer' },
|
||||
{ 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>
|
||||
// Floor for progressive label shrinking in the horizontal sidebar - below
|
||||
// this, text becomes illegible, so labels hide outright instead.
|
||||
const MIN_LABEL_SCALE = 0.65
|
||||
|
||||
<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>
|
||||
interface DragProps {
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
over: boolean
|
||||
}
|
||||
|
||||
export default function Sidebar({ activeView, onViewChange, position, showLabels }: Props) {
|
||||
const horizontal = position !== 'left'
|
||||
|
||||
const [hidden, setHidden] = useState<string[]>(getHiddenViews())
|
||||
useEffect(() => onHiddenViewsChange(setHidden), [])
|
||||
|
||||
// When horizontal (top/bottom sidebar), labels can make the row wider than
|
||||
// the window - rather than forcing a horizontal scrollbar, shrink label text
|
||||
// progressively as space gets tight (labelScale 1 -> MIN_LABEL_SCALE), and
|
||||
// only hide labels outright once even minimum-size text wouldn't fit.
|
||||
// `measureRef` is an offscreen clone always rendered WITH full-size labels
|
||||
// at natural width; comparing its scrollWidth against the real nav's
|
||||
// clientWidth (which itself doesn't change as labelScale changes) avoids
|
||||
// the flip-flop you'd get measuring the visible, already-shrunk row.
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [labelScale, setLabelScale] = useState(1)
|
||||
const effectiveShowLabels = showLabels && (!horizontal || labelScale > 0)
|
||||
|
||||
// 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))
|
||||
|
||||
useEffect(() => {
|
||||
if (!horizontal || !showLabels) { setLabelScale(1); return }
|
||||
const nav = navRef.current
|
||||
const measure = measureRef.current
|
||||
if (!nav || !measure) return
|
||||
const check = () => {
|
||||
const ratio = measure.scrollWidth > 0 ? nav.clientWidth / measure.scrollWidth : 1
|
||||
setLabelScale(ratio >= 1 ? 1 : ratio >= MIN_LABEL_SCALE ? ratio : 0)
|
||||
}
|
||||
check()
|
||||
const ro = new ResizeObserver(check)
|
||||
ro.observe(nav)
|
||||
return () => ro.disconnect()
|
||||
}, [horizontal, showLabels, visibleItems])
|
||||
|
||||
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
|
||||
? `${effectiveShowLabels ? 'h-[68px]' : 'h-[52px]'} w-full flex flex-row items-center bg-bg-surface ${edgeBorder} border-bg-border shrink-0`
|
||||
: `${effectiveShowLabels ? '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 = !effectiveShowLabels
|
||||
? 'w-8 h-8'
|
||||
: horizontal
|
||||
? 'flex-col gap-1 px-2 py-1.5 h-full justify-center'
|
||||
: 'flex-col gap-1 px-1 py-1.5 w-full'
|
||||
|
||||
const labelCls = `leading-tight text-center ${horizontal ? 'whitespace-nowrap' : ''}`
|
||||
|
||||
// Horizontal-only: button width and label font-size both track labelScale,
|
||||
// so shrinking text actually reclaims row space instead of just looking smaller.
|
||||
const btnStyle: React.CSSProperties | undefined =
|
||||
horizontal && effectiveShowLabels ? { minWidth: `${3.25 * labelScale}rem` } : undefined
|
||||
const labelStyle: React.CSSProperties = { fontSize: `${10 * labelScale}px` }
|
||||
|
||||
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}
|
||||
style={btnStyle}
|
||||
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}
|
||||
{effectiveShowLabels && <span className={labelCls} style={labelStyle}>{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}>
|
||||
{horizontal && showLabels && (
|
||||
<div ref={measureRef} aria-hidden="true" className="fixed -top-[9999px] left-0 flex flex-row items-center gap-0.5 px-1 pointer-events-none">
|
||||
{visibleItems.map(({ view, icon, label, dividerBefore }, idx) => (
|
||||
<div key={view} className="flex items-center">
|
||||
{dividerBefore && idx > 0 && <div className={dividerCls} />}
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-2 py-1.5 min-w-[3.25rem] h-[68px]">
|
||||
{icon}
|
||||
<span className="text-[10px] leading-tight text-center whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="w-px h-7 bg-bg-border mx-1" />
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-2 py-1.5 min-w-[3.25rem] h-[68px]">
|
||||
<Settings size={17} />
|
||||
<span className="text-[10px] leading-tight text-center whitespace-nowrap">Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<nav ref={navRef} 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>
|
||||
)
|
||||
|
|
|
|||
40
frontend/src/components/layout/TitleBar.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// 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.
|
||||
|
||||
import { requestQuitConfirm } from '../../lib/quitgate'
|
||||
|
||||
// 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={requestQuitConfirm} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2004
frontend/src/components/views/LogcatMap.tsx
Normal file
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>
|
||||
)
|
||||
}
|
||||
251
frontend/src/components/views/ViewApkInstaller.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import {
|
||||
PackagePlus, FolderOpen, FilePlus, Trash2, Search, Download,
|
||||
CheckCircle2, XCircle, Loader2, X, Ban
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
SelectApkFolder, SelectMultipleApkFiles, ListApksInFolder, StatApkFiles,
|
||||
InstallApksWithProgress, CancelOperation
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { ApkFileInfo } from '../../lib/types'
|
||||
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
type RowStatus = 'idle' | 'pending' | 'installing' | 'success' | 'failed'
|
||||
interface Progress { status: RowStatus; message?: string }
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
export default function ViewApkInstaller() {
|
||||
const [apks, setApks] = useState<ApkFileInfo[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [progress, setProgress] = useState<Record<string, Progress>>({})
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// Live per-file install status, driven by backend apkinstall:* events.
|
||||
useEffect(() => {
|
||||
const onProgress = (p: { current: number; total: number; path: string; status: RowStatus; message?: string }) => {
|
||||
setProgress(prev => ({ ...prev, [p.path]: { status: p.status, message: p.message } }))
|
||||
}
|
||||
const onDone = () => setInstalling(false)
|
||||
const off1 = rt()?.EventsOn?.('apkinstall:progress', onProgress)
|
||||
const off2 = rt()?.EventsOn?.('apkinstall:done', onDone)
|
||||
return () => { off1?.(); off2?.() }
|
||||
}, [])
|
||||
|
||||
const mergeApks = useCallback((incoming: ApkFileInfo[]) => {
|
||||
if (incoming.length === 0) return
|
||||
setApks(prev => {
|
||||
const byPath = new Map(prev.map(a => [a.path, a]))
|
||||
for (const a of incoming) byPath.set(a.path, a)
|
||||
return [...byPath.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
for (const a of incoming) next.add(a.path)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addFolder = async () => {
|
||||
try {
|
||||
const folder = await SelectApkFolder()
|
||||
if (!folder) return
|
||||
const found = await ListApksInFolder(folder)
|
||||
if (!found || found.length === 0) { notify.error('No APKs found in that folder'); return }
|
||||
mergeApks(found)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const addFiles = async () => {
|
||||
try {
|
||||
const paths = await SelectMultipleApkFiles()
|
||||
if (!paths || paths.length === 0) return
|
||||
const found = await StatApkFiles(paths)
|
||||
mergeApks(found || [])
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const removeOne = (path: string) => {
|
||||
setApks(prev => prev.filter(a => a.path !== path))
|
||||
setSelected(prev => { const next = new Set(prev); next.delete(path); return next })
|
||||
setProgress(prev => { const { [path]: _drop, ...rest } = prev; return rest })
|
||||
}
|
||||
|
||||
const clearAll = () => { setApks([]); setSelected(new Set()); setProgress({}) }
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
apks.filter(a => a.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[apks, search]
|
||||
)
|
||||
|
||||
const toggleSelect = (path: string) => setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(path) ? next.delete(path) : next.add(path)
|
||||
return next
|
||||
})
|
||||
|
||||
const selectAll = () => {
|
||||
if (selected.size === filtered.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(filtered.map(a => a.path)))
|
||||
}
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (selected.size === 0) { notify.error('Select at least one APK'); return }
|
||||
const targets = apks.filter(a => selected.has(a.path))
|
||||
setInstalling(true)
|
||||
const reset: Record<string, Progress> = {}
|
||||
for (const t of targets) reset[t.path] = { status: 'pending' }
|
||||
setProgress(prev => ({ ...prev, ...reset }))
|
||||
try {
|
||||
const summary = await InstallApksWithProgress(targets.map(t => t.path))
|
||||
notify.success(summary)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setInstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = async () => {
|
||||
try { await CancelOperation() } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
const statusIcon = (status: RowStatus | undefined) => {
|
||||
switch (status) {
|
||||
case 'installing': return <Loader2 size={13} className="animate-spin text-accent-green" />
|
||||
case 'success': return <CheckCircle2 size={13} className="text-accent-green" />
|
||||
case 'failed': return <XCircle size={13} className="text-danger" />
|
||||
case 'pending': return <span className="w-[13px] h-[13px] rounded-full border border-text-muted inline-block" />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<PackagePlus size={14} className="text-accent-green shrink-0" />
|
||||
<span className="text-xs text-text-secondary">APK Installer</span>
|
||||
|
||||
<button onClick={addFolder} disabled={installing} className="btn-ghost text-xs">
|
||||
<FolderOpen size={13} /> Add Folder
|
||||
</button>
|
||||
<button onClick={addFiles} disabled={installing} className="btn-ghost text-xs">
|
||||
<FilePlus size={13} /> Add APKs
|
||||
</button>
|
||||
{apks.length > 0 && (
|
||||
<button onClick={clearAll} disabled={installing} className="btn-ghost text-xs">
|
||||
<Trash2 size={13} /> Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1 min-w-[160px]">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-8 text-xs"
|
||||
placeholder="Filter list..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-bg-border" />
|
||||
|
||||
{installing ? (
|
||||
<button onClick={cancel} className="btn-danger text-xs">
|
||||
<Ban size={13} /> Cancel
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={install} disabled={selected.size === 0} className="btn-primary text-xs">
|
||||
<Download size={13} /> Install Selected ({selected.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List header */}
|
||||
{apks.length > 0 && (
|
||||
<div className="grid grid-cols-[24px_20px_1fr_80px_60px] gap-2 px-4 py-1.5 border-b border-bg-border text-text-muted text-xs shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filtered.length > 0 && selected.size === filtered.length}
|
||||
onChange={selectAll}
|
||||
disabled={installing}
|
||||
className="accent-accent-green"
|
||||
/>
|
||||
<span />
|
||||
<span>APK</span>
|
||||
<span>Size</span>
|
||||
<span></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{apks.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-muted">
|
||||
<PackagePlus size={28} className="opacity-30" />
|
||||
<p className="text-sm">Add a folder of APKs, or pick files individually</p>
|
||||
</div>
|
||||
)}
|
||||
{filtered.map(a => {
|
||||
const prog = progress[a.path]
|
||||
return (
|
||||
<div
|
||||
key={a.path}
|
||||
className={`
|
||||
grid grid-cols-[24px_20px_1fr_80px_60px] gap-2 px-4 py-2
|
||||
border-b border-bg-border/50 items-center text-xs
|
||||
hover:bg-bg-raised transition-colors
|
||||
${selected.has(a.path) ? 'bg-accent-green/5' : ''}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(a.path)}
|
||||
onChange={() => toggleSelect(a.path)}
|
||||
disabled={installing}
|
||||
className="accent-accent-green"
|
||||
/>
|
||||
<span title={prog?.message}>{statusIcon(prog?.status)}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="mono text-text-secondary truncate">{a.name}</p>
|
||||
{prog?.status === 'failed' && prog.message && (
|
||||
<p className="text-danger text-[11px] truncate" title={prog.message}>{prog.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-text-muted">{formatSize(a.size)}</span>
|
||||
<button
|
||||
onClick={() => removeOne(a.path)}
|
||||
disabled={installing}
|
||||
className="btn-ghost text-xs py-0.5 px-1.5 justify-self-start"
|
||||
title="Remove from list"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</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 shrink-0">
|
||||
<span>{filtered.length} APK(s){search ? ` matching "${search}"` : ''}</span>
|
||||
{selected.size > 0 && <span>{selected.size} selected</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,20 @@
|
|||
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',
|
||||
}
|
||||
|
||||
type StateFilter = 'all' | 'enabled' | 'disabled' | 'uninstalled'
|
||||
|
||||
export default function ViewAppInspect() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>('all')
|
||||
const [packages, setPackages] = useState<PackageInfo[]>([])
|
||||
const [pkgsLoaded, setPkgsLoaded] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -13,6 +22,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 +60,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 +81,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 {
|
||||
|
|
@ -49,12 +104,20 @@ export default function ViewAppInspect() {
|
|||
}
|
||||
}
|
||||
|
||||
const filtered = packages.filter(p =>
|
||||
p.packageName.toLowerCase().includes(search.toLowerCase())
|
||||
).slice(0, 20)
|
||||
const filtered = packages
|
||||
.filter(p => p.packageName.toLowerCase().includes(search.toLowerCase()))
|
||||
.filter(p => {
|
||||
switch (stateFilter) {
|
||||
case 'enabled': return p.isInstalled && p.isEnabled
|
||||
case 'disabled': return p.isInstalled && !p.isEnabled
|
||||
case 'uninstalled': return !p.isInstalled
|
||||
default: return true
|
||||
}
|
||||
})
|
||||
|
||||
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 +127,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">
|
||||
|
|
@ -82,6 +145,17 @@ export default function ViewAppInspect() {
|
|||
<button onClick={() => inspect(search)} disabled={!search || loading} className="btn-primary text-xs w-full justify-center">
|
||||
{loading ? 'Inspecting...' : 'Inspect'}
|
||||
</button>
|
||||
<select
|
||||
className="input text-xs w-full"
|
||||
value={stateFilter}
|
||||
onChange={e => setStateFilter(e.target.value as StateFilter)}
|
||||
title="Filter by state"
|
||||
>
|
||||
<option value="all">All states</option>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="uninstalled">Uninstalled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
|
|
@ -92,13 +166,21 @@ export default function ViewAppInspect() {
|
|||
className="w-full text-left px-3 py-2 text-xs text-text-secondary hover:bg-bg-raised hover:text-text-primary transition-colors border-b border-bg-border/30"
|
||||
>
|
||||
<p className="truncate mono">{p.packageName}</p>
|
||||
<p className={p.isEnabled ? 'text-accent-green' : 'text-danger'}>{p.isEnabled ? 'enabled' : 'disabled'}</p>
|
||||
<p className={!p.isInstalled ? 'text-text-muted' : p.isEnabled ? 'text-accent-green' : 'text-danger'}>
|
||||
{!p.isInstalled ? 'uninstalled' : p.isEnabled ? 'enabled' : 'disabled'}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
{!pkgsLoaded && (
|
||||
<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 +268,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 +439,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>
|
||||
)
|
||||
|
|
|
|||