Compare commits

...

67 commits

Author SHA1 Message Date
Jesse Li-Yates
e26087029f
Merge pull request #6 from jegly/dependabot/go_modules/go_modules-a3c8a40308
chore(deps): bump golang.org/x/crypto from 0.51.0 to 0.52.0 in the go_modules group across 1 directory
2026-07-30 09:28:17 +10:00
Jesse Li-Yates
d172427624
Delete RELEASE_NOTES_v1.1.0.md 2026-07-10 09:59:07 +10:00
dependabot[bot]
090d7c2472
chore(deps): bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.51.0 to 0.52.0
- [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 10:10:18 +00:00
jegly
cf3fb26bd2 Add system tray, APK Installer, fix package visibility, new themes/fonts; bump to v1.3.0
- System tray (Linux): pure Go D-Bus StatusNotifierItem, no cgo/libappindicator.
  Close now offers Minimize to Tray vs Quit instead of always quitting.
- New APK Installer module: batch-install from a folder or hand-picked files,
  live per-file progress, select all/none.
- Fix Packages/App Inspector silently hiding packages disabled/uninstalled by
  Canta/Shizuku (pm list packages needs -u to see them). Added a state filter
  and per-row Restore/Enable/Disable/Uninstall actions.
- 6 new theme palettes (Nord, Tokyo Night, Solarized Dark/Light, Rosé Pine,
  Everforest - 26 total), custom text colour, 8 bundled display fonts,
  adjustable font size.
- Horizontal sidebar now shrinks label text progressively instead of
  scrolling when the window narrows; fixed a touchpad stuck-scrollbar-drag bug.
- Updated README and fixed stale Go 1.23 -> 1.25 build instructions.
2026-07-09 20:05:26 +10:00
jegly
ed97bc2c14 Add v1.2.0 release notes 2026-07-06 16:04:09 +10:00
jegly
d3f5ef60c8 Update README for v1.2.0: GSI Loader, Intent Lab, Privacy Scanner, App Lock, new themes 2026-07-06 16:03:56 +10:00
jegly
9d7eba705a Fix path injection in local file handler (CodeQL go/path-injection)
ServeFile's built-in ".." rejection only covers r.URL.Path, not a path handed
to it explicitly from a query param — validate p resolves to a real, regular
file before serving.
2026-07-06 15:51:40 +10:00
Jesse Li-Yates
b6700b57e3
Merge pull request #4 from jegly/dependabot/npm_and_yarn/frontend/npm_and_yarn-d5bb1fcf35
chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates
2026-07-06 15:35:57 +10:00
Jesse Li-Yates
af3af9c5ec
Merge pull request #5 from jegly/dependabot/go_modules/go_modules-a3c06b5f9d
chore(deps): bump the go_modules group across 1 directory with 2 updates
2026-07-06 15:35:40 +10:00
dependabot[bot]
dfcb3e7c55
chore(deps): bump the go_modules group across 1 directory with 2 updates
Bumps the go_modules group with 2 updates in the / directory: [golang.org/x/image](https://github.com/golang/image) and [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/image` from 0.20.0 to 0.41.0
- [Commits](https://github.com/golang/image/compare/v0.20.0...v0.41.0)

Updates `golang.org/x/net` from 0.47.0 to 0.55.0
- [Commits](https://github.com/golang/net/compare/v0.47.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.41.0
  dependency-type: indirect
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 05:32:56 +00:00
jegly
a9930a005b go mod tidy: fix missing go.sum entry after merge 2026-07-06 15:29:20 +10:00
jegly
4da6a69964 Merge dev: GSI Loader, Intent Lab, Firmware, Screen Mirror, App Lock, privileged uninstall helper (v1.2.0) 2026-07-06 15:27:38 +10:00
jegly
26d82c68d5 Add GSI Loader, APK Audit, Firmware, Intent Lab, Screen Mirror, Magisk, and App Lock features; bump to v1.2.0
- New sidebar views: GSI Loader (DSU + permanent fastboot flash), APK Audit,
  Firmware, Intent Lab, Screen Mirror
- New backend modules: backend_gsi.go, backend_apkaudit*.go, backend_firmware.go,
  backend_intent.go, backend_magisk.go, backend_applock.go, backend_scrcpy.go,
  backend_payload.go, backend_privacy.go, backend_overview.go, backend_bootinfo.go,
  backend_flasher.go, backend_transfer.go, backend_filehttp.go
- App Lock / danger-gate infrastructure (DangerGate, LockGate, applock.ts)
- Privileged uninstall via embedded Android helper dex (android-helper/)
- nfpm packaging version bump to 1.2.0
2026-07-06 15:17:59 +10:00
dependabot[bot]
b712b76657
chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates
Bumps the npm_and_yarn group with 2 updates in the /frontend directory: [postcss](https://github.com/postcss/postcss) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `postcss` from 8.5.8 to 8.5.10
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10)

Updates `vite` from 5.4.21 to 6.4.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.3/packages/vite)

Updates `esbuild` from 0.21.5 to 0.25.12
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.12)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 6.4.3
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: esbuild
  dependency-version: 0.25.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 02:48:15 +00:00
Jesse Li-Yates
98546b701a
Update logcatpatterns.ts 2026-06-30 12:47:36 +10:00
Jesse Li-Yates
93b8aae567
Clean up proprietary README content
Removed proprietary information and instructions from README.
2026-06-30 12:47:04 +10:00
jegly
b0fb52be39 Merge remote main (keep remote README + CI) with full source
# Conflicts:
#	README.md
2026-06-30 12:44:49 +10:00
jegly
b3a6fc05f3 Merge full source incl. logcat map engine into main
# Conflicts:
#	README.md
#	frontend/package.json
#	frontend/pnpm-lock.yaml
#	go.mod
2026-06-30 12:42:50 +10:00
jegly
ac609bc80c Publish full source including logcat visual map engine 2026-06-30 12:38:37 +10:00
Jesse Li-Yates
0d6ae4262b
Update README with improved demo description
Clarified the description of the live system map demo.
2026-06-26 22:22:01 +10:00
Jesse Li-Yates
97316b652d
Update README.md 2026-06-04 23:28:03 +10:00
Jesse Li-Yates
d6da1803ce
Update README.md 2026-06-04 22:07:34 +10:00
Jesse Li-Yates
cf5515e4fc
Update README.md 2026-06-04 22:05:32 +10:00
Jesse Li-Yates
7136a69867
Update README.md 2026-06-04 19:46:27 +10:00
Jesse Li-Yates
dbc1e998e7
Update README.md 2026-06-04 19:37:57 +10:00
Jesse Li-Yates
7e844b3704
Update README.md 2026-06-04 19:33:06 +10:00
Jesse Li-Yates
7b718632dd
Update README.md 2026-06-04 19:16:15 +10:00
Jesse Li-Yates
729c31faba
Update README.md 2026-06-04 19:11:49 +10:00
Jesse Li-Yates
c5d8776083
Add files via upload 2026-06-04 19:08:53 +10:00
Jesse Li-Yates
3b68a88b6e
Delete ATK_MAP_ENGINE_DEMO4.mp4 2026-06-04 18:55:55 +10:00
Jesse Li-Yates
c495191a82
Delete ATK_MAP_ENGINE_DEMO3.mp4 2026-06-04 18:55:45 +10:00
Jesse Li-Yates
c1e4a23bf4
Delete ATK_MAP_ENGINE_DEMO2.mp4 2026-06-04 18:54:49 +10:00
Jesse Li-Yates
31fe9425c7
Delete ATK_MAP_ENGINE_DEMO1.mp4 2026-06-04 18:54:37 +10:00
Jesse Li-Yates
741bc49999
Add files via upload 2026-06-04 18:52:15 +10:00
Jesse Li-Yates
3dffdfc0de
Add files via upload 2026-06-04 18:51:22 +10:00
Jesse Li-Yates
104fcb6db0
Add files via upload 2026-06-04 18:40:45 +10:00
Jesse Li-Yates
4334906f29
Delete assets/appicon.png 2026-06-04 18:40:23 +10:00
Jesse Li-Yates
a569a4cb77
Add files via upload 2026-06-04 18:39:51 +10:00
Jesse Li-Yates
31903d53f8
Delete assets/appicon.png 2026-06-04 18:39:26 +10:00
Jesse Li-Yates
93083463b5
Update README.md 2026-06-04 18:38:27 +10:00
Jesse Li-Yates
9551b42c78
Add files via upload 2026-06-04 18:37:22 +10:00
Jesse Li-Yates
1ed201ac41
Create placeholder 2026-06-04 18:33:42 +10:00
Jesse Li-Yates
9b3b3a5dfb
Delete screenshot directory 2026-06-04 18:33:19 +10:00
Jesse Li-Yates
2ea5f11aab
Update README.md 2026-05-24 18:54:17 +10:00
Jesse Li-Yates
ec5b43022e
Update README.md 2026-05-24 18:53:16 +10:00
Jesse Li-Yates
cdc4e77b2b
Add files via upload 2026-05-24 18:53:03 +10:00
Jesse Li-Yates
53d2e801a5
Delete screenshot/atk_banner.png 2026-05-24 18:52:50 +10:00
Jesse Li-Yates
a27f541add
Update README.md 2026-05-24 18:50:42 +10:00
Jesse Li-Yates
e6b2b2a5f0
Add files via upload 2026-05-24 18:48:48 +10:00
Jesse Li-Yates
dd08a883b9
Create FUNDING.yml 2026-04-24 20:26:09 +10:00
Jesse Li-Yates
920744ff3a
Update README.md 2026-04-17 15:43:51 +10:00
Jesse Li-Yates
956e3b1779
Update README.md 2026-04-16 18:11:27 +10:00
Jesse Li-Yates
a564d88f9f
Merge pull request #3 from jegly/dependabot/npm_and_yarn/frontend/npm_and_yarn-436684d2cf
chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
2026-04-10 17:18:34 +10:00
Jesse Li-Yates
b884d624d2
Update README.md 2026-04-10 17:17:56 +10:00
Jesse Li-Yates
fd386c5aab
Update README.md 2026-04-10 17:17:00 +10:00
dependabot[bot]
31393b6e1a
chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.21 to 6.4.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

Updates `esbuild` from 0.21.5 to 0.25.12
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.12)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: esbuild
  dependency-version: 0.25.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 05:00:54 +00:00
Jesse Li-Yates
22fd8a9359
Update README.md 2026-04-02 16:39:31 +11:00
Jesse Li-Yates
ca6154fa84
Delete screenshot/placeholder 2026-04-02 16:35:33 +11:00
Jesse Li-Yates
02a7e3875a
Add files via upload 2026-04-02 16:35:13 +11:00
Jesse Li-Yates
1c53ef1526
Create placeholder 2026-04-02 16:33:45 +11:00
Jesse Li-Yates
aef54fa7f6
Add DeepWiki badge and enhance project description
Added badge for DeepWiki and updated project description.
2026-04-02 16:20:37 +11:00
Jesse Li-Yates
e86d50a056
Merge pull request #2 from jegly/dependabot/go_modules/go_modules-44917c1e6c
chore(deps): bump golang.org/x/image from 0.20.0 to 0.38.0 in the go_modules group across 1 directory
2026-03-31 20:09:47 +11:00
dependabot[bot]
a9922aa029
chore(deps): bump golang.org/x/image
Bumps the go_modules group with 1 update in the / directory: [golang.org/x/image](https://github.com/golang/image).


Updates `golang.org/x/image` from 0.20.0 to 0.38.0
- [Commits](https://github.com/golang/image/compare/v0.20.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.38.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 16:24:07 +00:00
Jesse Li-Yates
27c82c566a
Update version number to v1.0.14 2026-03-29 14:51:36 +11:00
Jesse Li-Yates
9b95a508b1
Update README.md 2026-03-29 14:51:04 +11:00
jegly
10902567d8 fix: downgrade vite to v5 - v8 breaks production build 2026-03-28 19:09:49 +11:00
jegly
76fbccc9c4 fix: add separate desktop files for deb and AppImage 2026-03-27 16:50:34 +11:00
176 changed files with 24596 additions and 3070 deletions

17
.github/FUNDING.yml vendored Normal file
View 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']

View file

@ -1,189 +0,0 @@
name: Build ATK
on:
push:
tags:
- 'v*'
workflow_dispatch:
env:
GO_VERSION: "1.23"
NODE_VERSION: "20"
PNPM_VERSION: "10"
jobs:
build-linux:
name: Build Linux (deb + AppImage)
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev fuse libfuse2
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
- name: Install frontend dependencies
run: pnpm install
working-directory: frontend
- name: Set webkit pkg-config path
run: |
echo "PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig" >> $GITHUB_ENV
echo "CGO_CFLAGS=$(pkg-config --cflags webkit2gtk-4.1)" >> $GITHUB_ENV
echo "CGO_LDFLAGS=$(pkg-config --libs webkit2gtk-4.1)" >> $GITHUB_ENV
- name: Download Go modules
run: go mod download
- name: Build binary
run: wails build -tags webkit2_41
- name: Generate icon and desktop file
run: |
mkdir -p build
cp assets/appicon.png build/appicon.png
cp assets/atk-deb.desktop build/atk.desktop
- name: Build .deb
run: |
nfpm pkg --packager deb --target build/
mv build/atk_*.deb "build/ATK-${{ github.ref_name }}-amd64.deb" || true
- name: Build AppImage
run: |
wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps
cp build/bin/ATK AppDir/usr/bin/ATK
cp assets/atk-appimage.desktop AppDir/usr/share/applications/ATK.desktop
cp build/appicon.png AppDir/usr/share/icons/hicolor/256x256/apps/atk.png
ARCH=x86_64 ./linuxdeploy-x86_64.AppImage --appdir AppDir --output appimage
find . -maxdepth 1 -name "*.AppImage" -not -name "linuxdeploy*" -exec mv {} "build/ATK-${{ github.ref_name }}-x86_64.AppImage" \;
- name: Upload Linux artifacts
uses: actions/upload-artifact@v4
with:
name: "atk-linux-${{ github.ref_name }}"
path: |
build/bin/ATK
build/*.deb
build/*.AppImage
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
name: "ATK ${{ github.ref_name }}"
body: "ATK Android Toolkit ${{ github.ref_name }}"
files: |
build/ATK-${{ github.ref_name }}-amd64.deb
build/ATK-${{ github.ref_name }}-x86_64.AppImage
build/bin/ATK
draft: false
prerelease: false
build-windows:
name: Build Windows (exe)
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
run: pnpm install
working-directory: frontend
- name: Download Go modules
run: go mod download
- name: Build Windows binary
run: wails build
- name: Rename output
shell: cmd
run: copy "build\bin\ATK.exe" "build\ATK-${{ github.ref_name }}-windows-amd64.exe"
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: "atk-windows-${{ github.ref_name }}"
path: build/ATK-${{ github.ref_name }}-windows-amd64.exe
- name: Upload to Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: build/ATK-${{ github.ref_name }}-windows-amd64.exe
build-macos:
name: Build macOS (unsigned DMG)
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
run: pnpm install
working-directory: frontend
- name: Download Go modules
run: go mod download
- name: Build macOS binary
run: wails build
- name: Create DMG
run: |
mkdir -p dmg_staging
if [ -d "build/bin/ATK.app" ]; then
cp -r build/bin/ATK.app dmg_staging/
else
cp build/bin/ATK dmg_staging/
fi
hdiutil create -volname "ATK" -srcfolder dmg_staging -ov -format UDZO "build/ATK-${{ github.ref_name }}-macos-unsigned.dmg"
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
with:
name: "atk-macos-${{ github.ref_name }}"
path: build/ATK-${{ github.ref_name }}-macos-unsigned.dmg
- name: Upload to Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: build/ATK-${{ github.ref_name }}-macos-unsigned.dmg

11
.gitignore vendored
View file

@ -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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
ATK_SCREENSHOTS/Backup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
ATK_SCREENSHOTS/Files.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
ATK_SCREENSHOTS/Flasher.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
ATK_SCREENSHOTS/Logcat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
ATK_SCREENSHOTS/Shell.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

493
README.md
View file

@ -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 0100 **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 0100 privacy score and AF 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
View 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 0100 privacy score with an AF 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
View 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());
}
}
}

Binary file not shown.

23
android-helper/build.sh Normal file
View 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
View file

@ -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)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before After
Before After

18
assets/appicon.svg Normal file
View 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

View 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
View 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

View 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

BIN
assets/tray_icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

BIN
assets/tray_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

951
backend_apkaudit.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
}

View file

@ -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
View 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
View 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
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before After
Before After

View file

@ -1,4 +1,5 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=ATK
GenericName=Android Toolkit

View file

@ -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{

View file

@ -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 {

View file

@ -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>

View file

@ -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"
}
}

View file

@ -1 +1 @@
d772c5ee4d5ec9453e4b361871c1c91f
5044e767932c804d924f539d25c7ab46

1845
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -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',
},
}}
/>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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>
)

View 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>
)
}

File diff suppressed because it is too large Load diff

View 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>
)
}

View 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>
)
}

View file

@ -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>
)}

View file

@ -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>
)

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { Shield, RefreshCw, Plus, Trash2, AlertTriangle, Check, Lock } from 'lucide-react'
import { ListSystemCerts, ListUserCerts, InstallUserCert, RemoveUserCert, SelectCertFile } from '../../lib/wails'
import { notify } from '../../lib/notify'
import DismissibleBanner from '../DismissibleBanner'
import type { CertInfo } from '../../lib/types'
export default function ViewCerts() {
@ -70,15 +71,13 @@ export default function ViewCerts() {
</div>
{/* Burp/MITM info banner */}
<div className="border-b border-bg-border/50 bg-accent-green/5 px-4 py-2 shrink-0">
<div className="flex items-start gap-2">
<Shield size={13} className="text-accent-green shrink-0 mt-0.5" />
<div className="text-xs text-text-secondary space-y-0.5">
<p className="font-medium text-accent-green">HTTPS Interception Setup (Burp Suite / mitmproxy)</p>
<p>1. Export your proxy CA cert as DER/PEM 2. Click "Install User CA" above 3. Set device proxy to your machine IP 4. For Android 7+ apps with pinning use Magisk TrustUserCerts module or patch the APK</p>
</div>
<DismissibleBanner id="info-certs-burp" className="border-b border-bg-border/50 bg-accent-green/5 px-4 py-2 shrink-0 text-accent-green">
<Shield size={13} className="text-accent-green shrink-0 mt-0.5" />
<div className="text-xs text-text-secondary space-y-0.5">
<p className="font-medium text-accent-green">HTTPS Interception Setup (Burp Suite / mitmproxy)</p>
<p>1. Export your proxy CA cert as DER/PEM 2. Click "Install User CA" above 3. Set device proxy to your machine IP 4. For Android 7+ apps with pinning use Magisk TrustUserCerts module or patch the APK</p>
</div>
</div>
</DismissibleBanner>
{/* Tabs */}
<div className="border-b border-bg-border flex shrink-0">
@ -99,13 +98,13 @@ export default function ViewCerts() {
{/* Warning for user certs */}
{activeTab === 'user' && (
<div className="border-b border-warn/20 bg-warn/5 px-4 py-2 flex items-start gap-2 shrink-0">
<DismissibleBanner id="warn-certs-user" className="border-b border-warn/20 bg-warn/5 px-4 py-2 shrink-0 text-warn">
<AlertTriangle size={13} className="text-warn shrink-0 mt-0.5" />
<p className="text-xs text-warn/80">
<span className="font-medium">Android 7+ restricts user certs</span> apps targeting API 24+ won't trust them by default.
Use <span className="mono">TrustUserCerts</span> Magisk module or recompile the app's network security config to include user certs.
</p>
</div>
</DismissibleBanner>
)}
{/* Cert list */}

Some files were not shown because too many files have changed in this diff Show more