Initial release — ATK Android Toolkit v1.0.0

This commit is contained in:
jegly 2026-03-27 13:18:34 +11:00
commit f45c0991c5
53 changed files with 10445 additions and 0 deletions

69
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,69 @@
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
runs-on: ubuntu-22.04
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
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- 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: Build binary
run: wails build -tags webkit2_41
- name: Package .deb
run: nfpm pkg --packager deb --target build/
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: atk-linux-${{ github.ref_name }}
path: |
build/bin/ATK
build/*.deb

39
.gitignore vendored Normal file
View file

@ -0,0 +1,39 @@
# Build output
build/bin/
build/*.deb
build/*.rpm
build/*.AppImage
build/*.tar.gz
# Go
*.exe
*.test
*.prof
vendor/
# Frontend
frontend/dist/
frontend/node_modules/
frontend/.astro/
# Wails dev
frontend/wailsjs/
# Editor
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
ATK_*.log
# Temp
*.tmp
/tmp/

60
LICENSE Normal file
View file

@ -0,0 +1,60 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or you can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these rights.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For the full license text see: https://www.gnu.org/licenses/gpl-3.0.txt
ATK (Android Toolkit)
Copyright (C) 2025 jegly (https://github.com/jegly)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
---
THIRD-PARTY ATTRIBUTIONS
Universal Android Debloater (UAD-ng)
Package database (uad_lists.json) used under GPL-3.0
Copyright (C) Universal-Debloater-Alliance
https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation
Wails v2
GUI framework used under MIT License
Copyright (C) Lea Anthony
https://github.com/wailsapp/wails
PixelFlasher
Flash sequence logic studied and independently reimplemented
Original: Copyright (C) Badabing2005, AGPL-3.0
https://github.com/badabing2005/PixelFlasher

193
README.md Normal file
View file

@ -0,0 +1,193 @@
# ATK — Android Toolkit
> An all-in-one ADB GUI for Android power users, security researchers, and bug hunters.
Built with [Wails v2](https://wails.io) (Go + React). Runs natively on Linux, macOS, and Windows. Uses **your system ADB** — no bundled binaries, no mystery executables.
---
## Features
### 📱 Dashboard
- Live device list with connection status
- Rich device info — model, Android version, build, kernel, CPU, RAM, storage, battery, IP, root status, security patch, bootloader state
- Wireless ADB — enable TCP/IP, connect/disconnect
- One-click reboot to system / recovery / bootloader / fastboot / sideload
### 📁 File Explorer
- Browse the device filesystem
- Push, pull, rename, copy, delete, create folders
- Batch select and export multiple files
- No timeout on large transfers
### 📦 Package Manager
- List all / user / system / disabled packages
- Batch install, uninstall, enable, disable
- Pull APK from device, clear data, force stop
- Install APKs from local files
### 🛡️ Debloater
- **2,157 packages** from the Universal Android Debloater (UAD-ng) database
- Covers: Samsung, Xiaomi, OnePlus/Oppo, Huawei, Sony, Motorola, LG, Nokia, Asus, Realme, Google, Carriers, AOSP
- Safety ratings: Safe / Caution / Keep
- Dependency warnings
- Filter by manufacturer, safety level, or search
- Batch disable or uninstall for user 0
### 📜 Live Logcat
- Real-time streaming via Wails events
- Colour-coded by log level (V/D/I/W/E/F)
- Filter by level, tag, or search string
- Buffer selector: main / radio / events / crash / all
- Auto-scroll, save to file, clear buffer
### 🔍 App Inspector
- Deep package inspection: version, paths, UID, install dates, debuggable flag
- All granted permissions
- Activities, services, broadcast receivers, content providers
- Native libraries (.so files)
- Signing certificate info
- Full package dump
- Certificate pinning heuristic check (OkHttp, TrustKit, networkSecurityConfig)
### 🔒 Certificate Manager
- List all system and user CA certificates
- Install user CA certificates (for Burp Suite / mitmproxy HTTPS interception)
- Remove user certificates
- Fingerprint and expiry display
- Built-in HTTPS interception setup guide
### 💾 Device Backup
- `adb backup` with APK, shared storage, and app selection options
- Restore from `.adb` backup files
- Honest warnings about Android 12+ restrictions
### ⚙️ Prop Editor
- View all 300+ system properties grouped by category
- Search and filter
- Edit properties inline (uses root if available)
- Read-only properties clearly marked
### 💻 Shell Terminal
- Direct `adb shell` and `adb host` command execution
- Command history (arrow keys)
- No shell injection — args are split directly, no shell interpretation
### 🔧 Utilities
- **487 commands** across 15 categories:
Device Info, Processes & Memory, Battery & Power, Network & Connectivity,
Permissions & Security, Package Manager, Activities & Services, Sensors & Media,
Logs & Diagnostics, File System, Settings & Config, **Fastboot** (including all
`fastboot oem` commands), Root & Magisk, Instrumentation & Testing, Reboot
- Commands that need arguments prompt inline before running
- Search across all commands
- Output copy button
### ⚡ Flasher (Fastboot)
- Flash individual partitions
- Fastboot getvar queries
- ADB sideload
- Partition allowlist prevents accidental flashes to wrong targets
### 📲 Pixel Factory Flash
- Select a Pixel factory image zip directly from Google
- Reads `flash-all.sh` from inside the zip — executes the correct sequence automatically
- Options: wipe data, disable verity, disable verification, **force flash**, flash both slots
- Step-by-step live progress with per-step status
- Live flash log
---
## Security Design
- **No bundled binaries** — uses system `adb`/`fastboot` from your PATH
- **Settings view shows SHA-256** of whichever binary is being used — verify it yourself
- **No shell string building** — every command uses `exec.Command(binary, arg1, arg2, ...)` with discrete args passed directly to execve. No shell injection surface.
- **Input validation** — package names, partition names, IP addresses, and paths all validated before use
- **Partition allowlist** — fastboot flash only accepts known partition names
---
## Building from Source
### Prerequisites (Ubuntu/Debian)
```bash
sudo apt update
sudo apt install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev adb fastboot curl wget git
# Go 1.23
wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.bashrc
source ~/.bashrc
# Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
sudo npm install -g pnpm
# Wails CLI
go install github.com/wailsapp/wails/v2/cmd/wails@latest
```
### Build
```bash
git clone https://github.com/jegly/ATK
cd ATK
go mod tidy
cd frontend && pnpm install && cd ..
wails build -tags webkit2_41
# Binary output
./build/bin/ATK
```
### Install system-wide
```bash
sudo cp build/bin/ATK /usr/local/bin/atk
```
### Build .deb package
```bash
# Install nfpm
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
# Build binary first
wails build -tags webkit2_41
# Package as .deb
nfpm pkg --packager deb --target build/
```
### Dev mode (hot reload)
```bash
wails dev -tags webkit2_41
```
---
## Licence
ATK is licensed under the **GNU General Public License v3.0**.
The debloater package database is sourced from
[Universal Android Debloater Next Generation](https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation)
(GPL-3.0, Universal-Debloater-Alliance).
See [LICENSE](LICENSE) for full terms and third-party attributions.
---
## Acknowledgements
- [Universal Android Debloater Alliance](https://github.com/Universal-Debloater-Alliance) — package database
- [Wails](https://wails.io) — Go + Web framework
- [PixelFlasher](https://github.com/badabing2005/PixelFlasher) — Pixel flash sequence reference
- [Lucide](https://lucide.dev) — icons
- [shadcn/ui](https://ui.shadcn.com) — UI components

93
app.go Normal file
View file

@ -0,0 +1,93 @@
package main
import (
"context"
"sync"
)
// DeviceMode represents whether a device is in ADB or Fastboot mode
type DeviceMode string
const (
DeviceModeUnknown DeviceMode = "unknown"
DeviceModeADB DeviceMode = "adb"
DeviceModeFastboot DeviceMode = "fastboot"
)
// Device represents a connected ADB device
type Device struct {
Serial string `json:"serial"`
Status string `json:"status"`
}
// DeviceInfo holds detailed information about a connected device
type DeviceInfo struct {
Model string `json:"model"`
AndroidVersion string `json:"androidVersion"`
BuildNumber string `json:"buildNumber"`
BatteryLevel string `json:"batteryLevel"`
Serial string `json:"serial"`
IPAddress string `json:"ipAddress"`
RootStatus string `json:"rootStatus"`
Codename string `json:"codename"`
RamTotal string `json:"ramTotal"`
StorageInfo string `json:"storageInfo"`
Brand string `json:"brand"`
DeviceName string `json:"deviceName"`
SecurityPatch string `json:"securityPatch"`
Uptime string `json:"uptime"`
BootloaderStatus string `json:"bootloaderStatus"`
ScreenResolution string `json:"screenResolution"`
BasebandVersion string `json:"basebandVersion"`
KernelVersion string `json:"kernelVersion"`
CPUArch string `json:"cpuArch"`
}
// FileEntry represents a file or directory on the device
type FileEntry struct {
Name string `json:"name"`
Type string `json:"type"`
Size string `json:"size"`
Permissions string `json:"permissions"`
Date string `json:"date"`
Time string `json:"time"`
}
// PackageInfo represents an installed app package
type PackageInfo struct {
PackageName string `json:"packageName"`
IsEnabled bool `json:"isEnabled"`
}
// AdbConfig holds user-configurable ADB settings
type AdbConfig struct {
AdbPath string `json:"adbPath"`
FastbootPath string `json:"fastbootPath"`
}
// App is the main application struct
type App struct {
ctx context.Context
config AdbConfig
// binary path cache
binaryCache map[string]string
cacheMutex sync.RWMutex
// cancellation for long-running ops
currentCancel context.CancelFunc
opMutex sync.Mutex
}
// NewApp creates a new App instance
func NewApp() *App {
return &App{
binaryCache: make(map[string]string),
config: AdbConfig{},
}
}
// Startup is called when the app starts
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
}

183
backend_appinspect.go Normal file
View file

@ -0,0 +1,183 @@
package main
import (
"fmt"
"strings"
)
type AppInspection struct {
PackageName string `json:"packageName"`
VersionName string `json:"versionName"`
VersionCode string `json:"versionCode"`
TargetSDK string `json:"targetSdk"`
MinSDK string `json:"minSdk"`
InstallPath string `json:"installPath"`
DataDir string `json:"dataDir"`
Installer string `json:"installer"`
FirstInstall string `json:"firstInstall"`
LastUpdated string `json:"lastUpdated"`
IsSystem bool `json:"isSystem"`
IsEnabled bool `json:"isEnabled"`
IsDebuggable bool `json:"isDebuggable"`
UID string `json:"uid"`
Permissions []string `json:"permissions"`
Activities []string `json:"activities"`
Services []string `json:"services"`
Receivers []string `json:"receivers"`
Providers []string `json:"providers"`
NativeLibs []string `json:"nativeLibs"`
SharedLibs []string `json:"sharedLibs"`
CertSubject string `json:"certSubject"`
CertIssuer string `json:"certIssuer"`
CertExpiry string `json:"certExpiry"`
CertSHA256 string `json:"certSha256"`
ManifestDump string `json:"manifestDump"`
}
// InspectApp returns deep information about an installed package.
func (a *App) InspectApp(packageName string) (AppInspection, error) {
if err := validatePackageName(packageName); err != nil {
return AppInspection{}, err
}
result := AppInspection{PackageName: packageName}
// Full package dump
dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
if err != nil {
return result, fmt.Errorf("failed to dump package: %w", err)
}
result.ManifestDump = dump
// Parse key fields from dump
for _, line := range strings.Split(dump, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "versionName="):
result.VersionName = strings.TrimPrefix(line, "versionName=")
case strings.HasPrefix(line, "versionCode="):
parts := strings.Fields(line)
if len(parts) > 0 {
result.VersionCode = strings.TrimPrefix(parts[0], "versionCode=")
}
case strings.HasPrefix(line, "targetSdk="):
result.TargetSDK = strings.TrimPrefix(line, "targetSdk=")
case strings.HasPrefix(line, "minSdk="):
result.MinSDK = strings.TrimPrefix(line, "minSdk=")
case strings.HasPrefix(line, "codePath="):
result.InstallPath = strings.TrimPrefix(line, "codePath=")
case strings.HasPrefix(line, "dataDir="):
result.DataDir = strings.TrimPrefix(line, "dataDir=")
case strings.HasPrefix(line, "installerPackageName="):
result.Installer = strings.TrimPrefix(line, "installerPackageName=")
case strings.HasPrefix(line, "firstInstallTime="):
result.FirstInstall = strings.TrimPrefix(line, "firstInstallTime=")
case strings.HasPrefix(line, "lastUpdateTime="):
result.LastUpdated = strings.TrimPrefix(line, "lastUpdateTime=")
case strings.HasPrefix(line, "pkgFlags="):
flags := line
result.IsSystem = strings.Contains(flags, "SYSTEM")
result.IsDebuggable = strings.Contains(flags, "DEBUGGABLE")
case strings.HasPrefix(line, "userId="):
result.UID = strings.TrimPrefix(line, "userId=")
case strings.HasPrefix(line, "enabledState="):
result.IsEnabled = strings.Contains(line, "ENABLED") && !strings.Contains(line, "DISABLED")
}
}
// Granted permissions
perms, _ := a.runAdbShell("dumpsys", "package", packageName)
inPerms := false
for _, line := range strings.Split(perms, "\n") {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "granted=true") || strings.Contains(trimmed, "install permissions:") {
inPerms = true
}
if inPerms && strings.HasPrefix(trimmed, "android.permission.") {
perm := strings.Split(trimmed, ":")[0]
result.Permissions = append(result.Permissions, perm)
}
if inPerms && trimmed == "" {
inPerms = false
}
}
// Activities, services, receivers, providers via pm dump
pmDump, _ := a.runAdbShell("pm", "dump", packageName)
section := ""
for _, line := range strings.Split(pmDump, "\n") {
trimmed := strings.TrimSpace(line)
switch {
case strings.Contains(trimmed, "Activity Resolver Table"):
section = "activities"
case strings.Contains(trimmed, "Service Resolver Table"):
section = "services"
case strings.Contains(trimmed, "Receiver Resolver Table"):
section = "receivers"
case strings.Contains(trimmed, "Provider Resolver Table"):
section = "providers"
case strings.Contains(trimmed, "Key Set Manager"):
section = ""
}
if section != "" && strings.Contains(trimmed, packageName+"/") {
parts := strings.Fields(trimmed)
for _, p := range parts {
if strings.Contains(p, packageName+"/") {
className := strings.Split(p, "/")[1]
switch section {
case "activities":
result.Activities = appendUnique(result.Activities, className)
case "services":
result.Services = appendUnique(result.Services, className)
case "receivers":
result.Receivers = appendUnique(result.Receivers, className)
case "providers":
result.Providers = appendUnique(result.Providers, className)
}
}
}
}
}
// Native libraries
apkPath := strings.TrimPrefix(strings.TrimSpace(result.InstallPath), "package:")
if apkPath != "" {
libOut, _ := a.runAdbShell("unzip", "-l", apkPath+"/base.apk")
for _, line := range strings.Split(libOut, "\n") {
if strings.Contains(line, "lib/") && strings.HasSuffix(line, ".so") {
parts := strings.Fields(line)
if len(parts) > 0 {
result.NativeLibs = appendUnique(result.NativeLibs, parts[len(parts)-1])
}
}
}
}
// Certificate info via apksigner or keytool
certOut, _ := a.runAdbShell("pm", "dump", packageName)
for _, line := range strings.Split(certOut, "\n") {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "Signing certificates:") {
// next lines have cert info
}
if strings.HasPrefix(trimmed, "Subject:") {
result.CertSubject = strings.TrimPrefix(trimmed, "Subject: ")
}
if strings.HasPrefix(trimmed, "Issuer:") {
result.CertIssuer = strings.TrimPrefix(trimmed, "Issuer: ")
}
}
return result, nil
}
func appendUnique(slice []string, item string) []string {
for _, s := range slice {
if s == item {
return slice
}
}
return append(slice, item)
}

121
backend_backup.go Normal file
View file

@ -0,0 +1,121 @@
package main
import (
"context"
"fmt"
"strings"
"time"
)
type BackupOptions struct {
IncludeAPKs bool `json:"includeApks"`
IncludeShared bool `json:"includeShared"`
IncludeSystem bool `json:"includeSystem"`
Packages []string `json:"packages"`
AllApps bool `json:"allApps"`
}
// StartBackup runs adb backup with the given options.
// The user will need to confirm on the device screen.
func (a *App) StartBackup(opts BackupOptions, localPath string) (string, error) {
if localPath == "" {
var err error
localPath, err = a.SelectSaveFile("backup.adb")
if err != nil || localPath == "" {
return "Backup cancelled", nil
}
}
args := []string{"backup", "-f", localPath}
if opts.IncludeAPKs {
args = append(args, "-apk")
} else {
args = append(args, "-noapk")
}
if opts.IncludeShared {
args = append(args, "-shared")
} else {
args = append(args, "-noshared")
}
if opts.IncludeSystem {
args = append(args, "-system")
} else {
args = append(args, "-nosystem")
}
if opts.AllApps {
args = append(args, "-all")
} else if len(opts.Packages) > 0 {
args = append(args, opts.Packages...)
}
ctx, cancel := a.beginCancellableOp(0) // no timeout — user cancellable
defer cancel()
out, err := a.runCommandContext(ctx, "adb", args...)
if err != nil {
if strings.Contains(err.Error(), "cancelled") {
return "Backup cancelled", nil
}
return "", fmt.Errorf("backup failed: %w", err)
}
return fmt.Sprintf("Backup saved to %s\n%s", localPath, out), nil
}
// RestoreBackup restores from an .adb backup file.
func (a *App) RestoreBackup(localPath string) (string, error) {
if localPath == "" {
return "", fmt.Errorf("no backup file specified")
}
ctx, cancel := a.beginCancellableOp(0)
defer cancel()
out, err := a.runCommandContext(ctx, "adb", "restore", localPath)
if err != nil {
return "", fmt.Errorf("restore failed: %w", err)
}
return "Restore initiated. Confirm on device.\n" + out, nil
}
// SelectBackupFile opens a file picker for .adb backup files.
func (a *App) SelectBackupFile() (string, error) {
return a.SelectFileWithFilter("Select backup file", []string{"*.adb", "*.ab"})
}
// GetInstalledUserApps returns list of user-installed packages for backup selection.
func (a *App) GetInstalledUserApps() ([]PackageInfo, error) {
return a.ListPackages("user")
}
// BackupSingleApp backs up a single app by package name.
func (a *App) BackupSingleApp(packageName string, includeAPK bool) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
localPath, err := a.SelectSaveFile(packageName + ".adb")
if err != nil || localPath == "" {
return "Backup cancelled", nil
}
apkFlag := "-noapk"
if includeAPK {
apkFlag = "-apk"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
out, err := a.runCommandContext(ctx, "adb", "backup", "-f", localPath, apkFlag, "-noshared", packageName)
if err != nil {
return "", fmt.Errorf("backup failed: %w", err)
}
return fmt.Sprintf("App backup saved to %s\n%s", localPath, out), nil
}

179
backend_cert.go Normal file
View file

@ -0,0 +1,179 @@
package main
import (
"fmt"
"strings"
"time"
)
type CertInfo struct {
Filename string `json:"filename"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
Expiry string `json:"expiry"`
Fingerprint string `json:"fingerprint"`
IsUser bool `json:"isUser"`
IsSystem bool `json:"isSystem"`
}
// ListSystemCerts returns all system CA certificates.
func (a *App) ListSystemCerts() ([]CertInfo, error) {
return a.listCerts("/system/etc/security/cacerts", false)
}
// ListUserCerts returns user-installed CA certificates.
func (a *App) ListUserCerts() ([]CertInfo, error) {
return a.listCerts("/data/misc/user/0/cacerts-added", true)
}
func (a *App) listCerts(path string, isUser bool) ([]CertInfo, error) {
out, err := a.runAdbShell("ls", path)
if err != nil || strings.TrimSpace(out) == "" {
return nil, nil
}
var certs []CertInfo
for _, fname := range strings.Fields(out) {
fname = strings.TrimSpace(fname)
if fname == "" || strings.HasPrefix(fname, "ls:") {
continue
}
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)
}
return certs, nil
}
// 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) {
// Get the cert hash (used as filename by Android)
// Push the cert to a temp location first
remoteTmp := "/sdcard/tmp_cert.pem"
_, err := a.runCommand("adb", "push", localCertPath, remoteTmp)
if err != nil {
return "", fmt.Errorf("failed to push cert: %w", err)
}
// Get the hash
hashOut, err := a.runAdbShell("openssl", "x509", "-inform", "PEM",
"-subject_hash_old", "-in", remoteTmp)
if err != nil {
return "", fmt.Errorf("failed to compute cert hash (is openssl on device?): %w", err)
}
hash := strings.TrimSpace(strings.Split(hashOut, "\n")[0])
if hash == "" {
return "", fmt.Errorf("could not compute certificate hash")
}
destPath := "/data/misc/user/0/cacerts-added/" + hash + ".0"
// Requires root
_, err = a.runAdbShell("su", "-c",
fmt.Sprintf("cp %s %s && chmod 644 %s", remoteTmp, destPath, destPath))
if err != nil {
return "", fmt.Errorf("failed to install cert (root required): %w", err)
}
// Cleanup
a.runAdbShell("rm", remoteTmp)
return fmt.Sprintf("Certificate installed as %s.0 — you may need to reboot", hash), nil
}
// RemoveUserCert removes a user-installed CA certificate by filename.
func (a *App) RemoveUserCert(filename string) (string, error) {
if strings.Contains(filename, "/") || strings.Contains(filename, "..") {
return "", fmt.Errorf("invalid certificate filename")
}
destPath := "/data/misc/user/0/cacerts-added/" + filename
_, err := a.runAdbShell("su", "-c", "rm "+destPath)
if err != nil {
return "", fmt.Errorf("failed to remove cert (root required): %w", err)
}
return "Certificate removed: " + filename, nil
}
// SelectCertFile opens a file picker for PEM/CRT files.
func (a *App) SelectCertFile() (string, error) {
return a.SelectFileWithFilter("Select CA Certificate", []string{"*.pem", "*.crt", "*.cer"})
}
// CheckPinning checks if an app has certificate pinning configured.
// This is a heuristic check based on known pinning libraries and manifest flags.
func (a *App) CheckPinning(packageName string) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
results := []string{}
// Check network security config
dump, err := a.runAdbShell("dumpsys", "package", packageName)
if err != nil {
return "", fmt.Errorf("failed to dump package: %w", err)
}
if strings.Contains(dump, "networkSecurityConfig") {
results = append(results, "⚠ networkSecurityConfig present — may have custom trust anchors or pinning")
}
// Check for known pinning libraries in the APK
apkPath, _ := a.runAdbShell("pm", "path", packageName)
apkPath = strings.TrimPrefix(strings.TrimSpace(apkPath), "package:")
if apkPath != "" {
zipList, _ := a.runAdbShell("unzip", "-l", apkPath)
checks := map[string]string{
"okhttp3": "OkHttp3 — likely has CertificatePinner",
"TrustKit": "TrustKit — SSL pinning library",
"conscrypt": "Conscrypt — custom SSL provider",
"PublicKeyPins": "PublicKeyPins — HPKP style pinning",
"pinning": "pinning — generic pinning reference",
"certificate_transparency": "Certificate Transparency enforced",
}
for keyword, desc := range checks {
if strings.Contains(strings.ToLower(zipList), strings.ToLower(keyword)) {
results = append(results, "⚠ "+desc)
}
}
}
if len(results) == 0 {
return "No obvious pinning detected — but always verify with a proxy (Burp/mitmproxy)", nil
}
return strings.Join(results, "\n"), nil
}

141
backend_logcat.go Normal file
View file

@ -0,0 +1,141 @@
package main
import (
"bufio"
"context"
"fmt"
"os/exec"
"strings"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
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"`
}
var (
logcatCancel context.CancelFunc
logcatMu sync.Mutex
)
// StartLogcat begins streaming logcat to the frontend via events.
// filter: optional tag filter e.g. "ActivityManager:I *:S"
// buffer: "main", "radio", "events", "crash", or "all"
func (a *App) StartLogcat(filter string, buffer string) error {
logcatMu.Lock()
defer logcatMu.Unlock()
// Stop any existing logcat
if logcatCancel != nil {
logcatCancel()
logcatCancel = nil
}
adbPath, err := a.getBinaryPath("adb")
if err != nil {
return err
}
args := []string{"logcat", "-v", "threadtime"}
if buffer != "" && buffer != "main" {
if buffer == "all" {
args = append(args, "-b", "all")
} else {
args = append(args, "-b", buffer)
}
}
if filter != "" {
args = append(args, strings.Fields(filter)...)
}
ctx, cancel := context.WithCancel(context.Background())
logcatCancel = cancel
cmd := exec.CommandContext(ctx, adbPath, args...)
setCommandSysProcAttr(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return fmt.Errorf("failed to get stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
cancel()
return fmt.Errorf("failed to start logcat: %w", err)
}
go func() {
defer cancel()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
parsed := parseLogcatLine(line)
runtime.EventsEmit(a.ctx, "logcat:line", parsed)
}
runtime.EventsEmit(a.ctx, "logcat:stopped", nil)
cmd.Wait()
}()
return nil
}
// StopLogcat stops the running logcat stream.
func (a *App) StopLogcat() {
logcatMu.Lock()
defer logcatMu.Unlock()
if logcatCancel != nil {
logcatCancel()
logcatCancel = nil
}
}
// ClearLogcat clears all logcat buffers.
func (a *App) ClearLogcat() error {
_, err := a.runCommand("adb", "logcat", "-c")
return err
}
// parseLogcatLine parses a threadtime format logcat line:
// MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: message
func parseLogcatLine(line string) LogcatLine {
result := LogcatLine{Raw: line}
parts := strings.SplitN(line, " ", 7)
if len(parts) < 7 {
result.Message = line
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])
}
}
return result
}

137
backend_props.go Normal file
View file

@ -0,0 +1,137 @@
package main
import (
"fmt"
"sort"
"strings"
)
type PropEntry struct {
Key string `json:"key"`
Value string `json:"value"`
Category string `json:"category"`
}
// GetAllProps returns all system properties as structured entries.
func (a *App) GetAllProps() ([]PropEntry, error) {
out, err := a.runAdbShell("getprop")
if err != nil {
return nil, fmt.Errorf("failed to get props: %w", err)
}
var props []PropEntry
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "[") {
continue
}
// Format: [key]: [value]
line = strings.TrimPrefix(line, "[")
parts := strings.SplitN(line, "]: [", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := strings.TrimSuffix(parts[1], "]")
props = append(props, PropEntry{
Key: key,
Value: value,
Category: categorizeProp(key),
})
}
sort.Slice(props, func(i, j int) bool {
return props[i].Key < props[j].Key
})
return props, nil
}
// SetProp sets a system property value.
// Note: many system props are read-only and require root to change.
func (a *App) SetProp(key, value string) (string, error) {
if key == "" {
return "", fmt.Errorf("key cannot be empty")
}
// Validate key characters
for _, ch := range key {
if ch == ';' || ch == '&' || ch == '|' || ch == '`' || ch == '$' || ch == '\n' {
return "", fmt.Errorf("invalid character in property key")
}
}
// Try setprop first (works for some props without root)
_, err := a.runAdbShell("setprop", key, value)
if err != nil {
// Try with root
_, err2 := a.runAdbShell("su", "-c", fmt.Sprintf("setprop %s %s", key, value))
if err2 != nil {
return "", fmt.Errorf("failed to set prop (may be read-only or need root): %w", err)
}
}
// Verify
newVal, _ := a.runAdbShell("getprop", key)
return fmt.Sprintf("Set %s = %s", key, strings.TrimSpace(newVal)), nil
}
// GetProp gets a single property value.
func (a *App) GetProp(key string) (string, error) {
if key == "" {
return "", fmt.Errorf("key cannot be empty")
}
out, err := a.runAdbShell("getprop", key)
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
// categorizeProp assigns a category to a property based on its key prefix.
func categorizeProp(key string) string {
switch {
case strings.HasPrefix(key, "ro.build"):
return "Build"
case strings.HasPrefix(key, "ro.product"):
return "Product"
case strings.HasPrefix(key, "ro.boot"):
return "Boot"
case strings.HasPrefix(key, "ro.hardware"):
return "Hardware"
case strings.HasPrefix(key, "ro.crypto"):
return "Security"
case strings.HasPrefix(key, "ro.debuggable") ||
strings.HasPrefix(key, "service.adb"):
return "Debug"
case strings.HasPrefix(key, "persist."):
return "Persist"
case strings.HasPrefix(key, "sys."):
return "System"
case strings.HasPrefix(key, "net.") ||
strings.HasPrefix(key, "dhcp.") ||
strings.HasPrefix(key, "wifi."):
return "Network"
case strings.HasPrefix(key, "gsm.") ||
strings.HasPrefix(key, "ril.") ||
strings.HasPrefix(key, "telephony."):
return "Telephony"
case strings.HasPrefix(key, "dalvik."):
return "Dalvik/ART"
case strings.HasPrefix(key, "init."):
return "Init"
case strings.HasPrefix(key, "dev."):
return "Device"
case strings.HasPrefix(key, "audio.") ||
strings.HasPrefix(key, "media."):
return "Media"
case strings.HasPrefix(key, "camera."):
return "Camera"
case strings.HasPrefix(key, "bluetooth."):
return "Bluetooth"
default:
return "Other"
}
}

37
build/README.md Normal file
View file

@ -0,0 +1,37 @@
# Build Directory
## Building the binary
```bash
wails build -tags webkit2_41
# Output: build/bin/ATK
```
## Building the .deb package
Requires [nfpm](https://nfpm.goreleaser.com/):
```bash
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
```
Then build the binary first, then package:
```bash
wails build -tags webkit2_41
nfpm pkg --packager deb --target build/
# Output: build/atk_1.0.0_amd64.deb
```
Install the .deb:
```bash
sudo dpkg -i build/atk_1.0.0_amd64.deb
# Then run:
atk
```
## App icon
Replace `build/appicon.png` with a 256x256 PNG to customise the application icon.
The icon is used in the .deb package and the Linux app launcher.

11
build/atk.desktop Normal file
View file

@ -0,0 +1,11 @@
[Desktop Entry]
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=Development;Utility;
Terminal=false
StartupWMClass=ATK
Keywords=android;adb;fastboot;debloat;logcat;flash;

373
device_service.go Normal file
View file

@ -0,0 +1,373 @@
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
"sync"
)
// GetDevices returns all connected ADB devices.
func (a *App) GetDevices() ([]Device, error) {
output, err := a.runCommand("adb", "devices")
if err != nil {
return nil, err
}
var devices []Device
lines := strings.Split(output, "\n")
// First line is "List of devices attached" header
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
devices = append(devices, Device{
Serial: parts[0],
Status: parts[1],
})
}
}
return devices, nil
}
// getProp fetches a single Android system property.
// Uses shell getprop with the property name as a discrete argument - safe.
func (a *App) getProp(prop string) string {
// prop is passed as a discrete arg - no shell injection possible
output, err := a.runAdbShell("getprop", prop)
if err != nil {
return "N/A"
}
v := strings.TrimSpace(output)
if v == "" {
return "N/A"
}
return v
}
// checkRootStatus checks if the device is rooted by attempting `su -c id`.
func (a *App) checkRootStatus() string {
// "su", "-c", "id" are all discrete args
output, err := a.runAdbShell("su", "-c", "id")
if err == nil && strings.TrimSpace(output) == "0" {
return "Rooted"
}
// Try alternative: check if su binary exists
suCheck, err := a.runAdbShell("which", "su")
if err == nil && strings.TrimSpace(suCheck) != "" {
return "Rooted (su present)"
}
return "Not rooted"
}
// getIPAddress retrieves the device WiFi IP address.
func (a *App) getIPAddress() string {
// Use ip addr show wlan0 - args are discrete
output, err := a.runAdbShell("ip", "addr", "show", "wlan0")
if err == nil {
re := regexp.MustCompile(`inet (\d+\.\d+\.\d+\.\d+)/\d+`)
if m := re.FindStringSubmatch(output); len(m) > 1 {
return m[1]
}
}
// Fallback: dhcp property
ip := a.getProp("dhcp.wlan0.ipaddress")
if ip != "N/A" && ip != "" {
return ip
}
return "N/A"
}
// getRamInfo returns total RAM as a formatted string.
func (a *App) getRamInfo() string {
// cat /proc/meminfo - path is a constant, safe discrete arg
output, err := a.runAdbShell("cat", "/proc/meminfo")
if err != nil {
return "N/A"
}
re := regexp.MustCompile(`MemTotal:\s*(\d+)\s*kB`)
m := re.FindStringSubmatch(output)
if len(m) < 2 {
return "N/A"
}
kb, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return "N/A"
}
return fmt.Sprintf("%.1f GB", kb/1024/1024)
}
// getStorageInfo returns used/total storage for /data.
func (a *App) getStorageInfo() string {
// df with /data as discrete arg
output, err := a.runAdbShell("df", "/data")
if err != nil {
return "N/A"
}
lines := strings.Split(output, "\n")
if len(lines) < 2 {
return "N/A"
}
fields := strings.Fields(lines[1])
if len(fields) < 4 {
return "N/A"
}
totalKB, err1 := strconv.ParseFloat(fields[1], 64)
usedKB, err2 := strconv.ParseFloat(fields[2], 64)
if err1 != nil || err2 != nil {
return "N/A"
}
return fmt.Sprintf("%.1f GB / %.1f GB", usedKB/1024/1024, totalKB/1024/1024)
}
// getBatteryLevel returns the current battery percentage.
func (a *App) getBatteryLevel() string {
// dumpsys battery - tool and subcommand as discrete args
output, err := a.runAdbShell("dumpsys", "battery")
if err != nil {
return "N/A"
}
re := regexp.MustCompile(`level:\s*(\d+)`)
m := re.FindStringSubmatch(output)
if len(m) > 1 {
return m[1] + "%"
}
return "N/A"
}
// getScreenResolution returns the device screen resolution.
func (a *App) getScreenResolution() string {
// wm size - tool and subcommand as discrete args
output, err := a.runAdbShell("wm", "size")
if err != nil {
return "N/A"
}
re := regexp.MustCompile(`Physical size:\s*(\d+x\d+)`)
m := re.FindStringSubmatch(output)
if len(m) > 1 {
return m[1]
}
return "N/A"
}
// getUptime returns the device uptime in human-readable form.
func (a *App) getUptime() string {
// cat /proc/uptime - constant path, safe
output, err := a.runAdbShell("cat", "/proc/uptime")
if err != nil {
return "N/A"
}
parts := strings.Fields(output)
if len(parts) == 0 {
return "N/A"
}
seconds, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return "N/A"
}
days := int(seconds) / 86400
hours := (int(seconds) % 86400) / 3600
mins := (int(seconds) % 3600) / 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
// getKernelVersion returns the Linux kernel version.
func (a *App) getKernelVersion() string {
// uname -r - tool and flag as discrete args
output, err := a.runAdbShell("uname", "-r")
if err != nil {
return "N/A"
}
return strings.TrimSpace(output)
}
// getCPUArch returns the device CPU architecture.
func (a *App) getCPUArch() string {
return a.getProp("ro.product.cpu.abi")
}
// GetDeviceInfo fetches all device information concurrently.
func (a *App) GetDeviceInfo() (DeviceInfo, error) {
var info DeviceInfo
var wg sync.WaitGroup
var mu sync.Mutex
// Simple props fetched concurrently
propJobs := []struct {
prop string
setter func(string)
}{
{"ro.product.model", func(v string) { info.Model = v }},
{"ro.build.version.release", func(v string) { info.AndroidVersion = v }},
{"ro.build.id", func(v string) { info.BuildNumber = v }},
{"ro.product.device", func(v string) { info.Codename = v }},
{"ro.product.brand", func(v string) { info.Brand = v }},
{"ro.product.name", func(v string) { info.DeviceName = v }},
{"ro.build.version.security_patch", func(v string) { info.SecurityPatch = v }},
{"ro.bootloader", func(v string) { info.BootloaderStatus = v }},
{"gsm.version.baseband", func(v string) { info.BasebandVersion = v }},
{"ro.product.cpu.abi", func(v string) { info.CPUArch = v }},
}
for _, job := range propJobs {
wg.Add(1)
go func(prop string, setter func(string)) {
defer wg.Done()
val := a.getProp(prop)
mu.Lock()
setter(val)
mu.Unlock()
}(job.prop, job.setter)
}
// Complex getters
complexJobs := []struct {
fn func() string
setter func(string)
}{
{a.getIPAddress, func(v string) { info.IPAddress = v }},
{a.checkRootStatus, func(v string) { info.RootStatus = v }},
{a.getRamInfo, func(v string) { info.RamTotal = v }},
{a.getStorageInfo, func(v string) { info.StorageInfo = v }},
{a.getBatteryLevel, func(v string) { info.BatteryLevel = v }},
{a.getScreenResolution, func(v string) { info.ScreenResolution = v }},
{a.getUptime, func(v string) { info.Uptime = v }},
{a.getKernelVersion, func(v string) { info.KernelVersion = v }},
}
for _, job := range complexJobs {
wg.Add(1)
go func(fn func() string, setter func(string)) {
defer wg.Done()
val := fn()
mu.Lock()
setter(val)
mu.Unlock()
}(job.fn, job.setter)
}
// Serial number
wg.Add(1)
go func() {
defer wg.Done()
serial, err := a.runCommand("adb", "get-serialno")
mu.Lock()
if err == nil {
info.Serial = strings.TrimSpace(serial)
} else {
info.Serial = a.getProp("ro.serialno")
}
mu.Unlock()
}()
wg.Wait()
return info, nil
}
// detectDeviceMode checks whether the device is in ADB or Fastboot mode.
func (a *App) detectDeviceMode() (DeviceMode, error) {
adbDevices, adbErr := a.GetDevices()
if adbErr == nil {
for _, d := range adbDevices {
switch strings.ToLower(strings.TrimSpace(d.Status)) {
case "device", "recovery", "sideload":
return DeviceModeADB, nil
}
}
}
fbDevices, fbErr := a.GetFastbootDevices()
if fbErr == nil && len(fbDevices) > 0 {
return DeviceModeFastboot, nil
}
if adbErr != nil && fbErr != nil {
return DeviceModeUnknown, fmt.Errorf("no device: adb: %v; fastboot: %v", adbErr, fbErr)
}
return DeviceModeUnknown, nil
}
// GetDeviceMode returns the current device connection mode as a string.
func (a *App) GetDeviceMode() (string, error) {
mode, err := a.detectDeviceMode()
return string(mode), err
}
// Reboot reboots the device into the specified mode.
// mode can be: "" (normal), "recovery", "bootloader", "fastboot", "sideload"
func (a *App) Reboot(mode string) error {
mode = strings.TrimSpace(mode)
// Validate mode against known values - no arbitrary strings
validModes := map[string]bool{
"": true,
"recovery": true,
"bootloader": true,
"fastboot": true,
"sideload": true,
}
if !validModes[mode] {
return fmt.Errorf("invalid reboot mode: %q", mode)
}
connectionMode, err := a.detectDeviceMode()
if err != nil {
return err
}
switch connectionMode {
case DeviceModeADB:
args := []string{"reboot"}
if mode != "" {
args = append(args, mode)
}
_, err := a.runCommand("adb", args...)
return err
case DeviceModeFastboot:
if mode == "bootloader" {
_, err := a.runCommand("fastboot", "reboot-bootloader")
return err
}
args := []string{"reboot"}
if mode != "" {
args = append(args, mode)
}
_, err := a.runCommand("fastboot", args...)
return err
default:
return fmt.Errorf("no device connected in ADB or Fastboot mode")
}
}

86
dialog_service.go Normal file
View file

@ -0,0 +1,86 @@
package main
import (
"github.com/ncruces/zenity"
)
// SelectFileForPush opens a native file picker for choosing a file to push.
func (a *App) SelectFileForPush() (string, error) {
path, err := zenity.SelectFile(
zenity.Title("Select file to push to device"),
)
if err == zenity.ErrCanceled {
return "", nil
}
return path, err
}
// SelectFileForInstall opens a file picker filtered to APK files.
func (a *App) SelectFileForInstall() (string, error) {
path, err := zenity.SelectFile(
zenity.Title("Select APK to install"),
zenity.FileFilters{
{Name: "APK files", Patterns: []string{"*.apk"}, CaseFold: true},
{Name: "All files", Patterns: []string{"*"}},
},
)
if err == zenity.ErrCanceled {
return "", nil
}
return path, err
}
// SelectFileForFlash opens a file picker for image files (for fastboot flash).
func (a *App) SelectFileForFlash() (string, error) {
path, err := zenity.SelectFile(
zenity.Title("Select image to flash"),
zenity.FileFilters{
{Name: "Image files", Patterns: []string{"*.img", "*.zip", "*.bin"}, CaseFold: true},
{Name: "All files", Patterns: []string{"*"}},
},
)
if err == zenity.ErrCanceled {
return "", nil
}
return path, err
}
// SelectSaveFile opens a native save-as dialog.
func (a *App) SelectSaveFile(defaultName string) (string, error) {
path, err := zenity.SelectFileSave(
zenity.Title("Save as"),
zenity.Filename(defaultName),
)
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(
zenity.Title("Select destination folder"),
zenity.Directory(),
)
if err == zenity.ErrCanceled {
return "", nil
}
return path, err
}
// SelectFileWithFilter opens a file picker with custom file type filters.
func (a *App) SelectFileWithFilter(title string, patterns []string) (string, error) {
filters := []zenity.FileFilter{
{Name: "Matching files", Patterns: patterns, CaseFold: true},
{Name: "All files", Patterns: []string{"*"}},
}
path, err := zenity.SelectFile(
zenity.Title(title),
zenity.FileFilters(filters),
)
if err == zenity.ErrCanceled {
return "", nil
}
return path, err
}

328
executor.go Normal file
View file

@ -0,0 +1,328 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const DefaultCommandTimeout = 60 * time.Second
// getBinaryPath resolves the path to a named binary (adb or fastboot).
// Priority order:
// 1. User-configured path in AdbConfig
// 2. System PATH (when user has adb installed via apt/sdk)
// 3. Local ./bin/linux/ directory (fallback)
//
// NEVER builds shell strings - always returns an absolute path
// suitable for use as the first argument to exec.Command.
func (a *App) getBinaryPath(name string) (string, error) {
// Check user config first
a.cacheMutex.RLock()
if cached, ok := a.binaryCache[name]; ok {
a.cacheMutex.RUnlock()
return cached, nil
}
a.cacheMutex.RUnlock()
var candidates []string
// 1. User-configured explicit path
switch name {
case "adb":
if a.config.AdbPath != "" {
candidates = append(candidates, a.config.AdbPath)
}
case "fastboot":
if a.config.FastbootPath != "" {
candidates = append(candidates, a.config.FastbootPath)
}
}
// 2. System PATH - preferred because user installed it from a trusted source
if p, err := exec.LookPath(name); err == nil {
candidates = append(candidates, p)
}
// 3. Local bin directory relative to executable
exePath, err := os.Executable()
if err == nil {
installDir := filepath.Dir(exePath)
candidates = append(candidates,
filepath.Join(installDir, "bin", name),
filepath.Join(installDir, "bin", "linux", name),
)
}
// 4. Local bin relative to working directory
candidates = append(candidates,
filepath.Join(".", "bin", name),
filepath.Join(".", "bin", "linux", name),
)
for _, candidate := range candidates {
if candidate == "" {
continue
}
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
continue
}
abs, err := filepath.Abs(candidate)
if err != nil {
continue
}
a.cacheMutex.Lock()
a.binaryCache[name] = abs
a.cacheMutex.Unlock()
return abs, nil
}
return "", fmt.Errorf(
"'%s' not found. Install with: sudo apt install adb fastboot\n"+
"Or set a custom path in Settings.",
name,
)
}
// invalidateBinaryCache clears the cache so next call re-resolves paths.
// Called when user changes config paths.
func (a *App) invalidateBinaryCache() {
a.cacheMutex.Lock()
defer a.cacheMutex.Unlock()
a.binaryCache = make(map[string]string)
}
// VerifyBinary returns the SHA-256 hash of the resolved binary so the user
// can verify it themselves against Google's published hashes.
// We deliberately do NOT hardcode expected hashes - versions change and
// we don't want to block legitimate upgrades.
func (a *App) VerifyBinary(name string) (string, error) {
path, err := a.getBinaryPath(name)
if err != nil {
return "", err
}
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("cannot open binary: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("cannot hash binary: %w", err)
}
hash := hex.EncodeToString(h.Sum(nil))
return fmt.Sprintf("path: %s\nsha256: %s", path, hash), nil
}
// runCommandContext executes a binary with the given arguments.
// SECURITY: args are NEVER joined into a shell string. Each arg is a discrete
// element passed directly to execve - no shell expansion, no injection possible.
func (a *App) runCommandContext(ctx context.Context, binary string, args ...string) (string, error) {
binaryPath, err := a.getBinaryPath(binary)
if err != nil {
return "", err
}
cmd := exec.CommandContext(ctx, binaryPath, args...)
setCommandSysProcAttr(cmd)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("command timed out after %s", DefaultCommandTimeout)
}
if ctx.Err() == context.Canceled {
return "", fmt.Errorf("cancelled")
}
errOut := strings.TrimSpace(stderr.String())
if errOut == "" {
errOut = err.Error()
}
// Translate common ADB error messages to human-readable form
switch {
case strings.Contains(errOut, "device offline"):
return "", fmt.Errorf("device is offline — try reconnecting USB")
case strings.Contains(errOut, "unauthorized"):
return "", fmt.Errorf("unauthorized — accept the USB debugging prompt on your phone")
case strings.Contains(errOut, "no devices/emulators found"):
return "", fmt.Errorf("no device found — check USB connection and USB debugging is enabled")
}
return "", fmt.Errorf("%s", errOut)
}
return strings.TrimSpace(stdout.String()), nil
}
// runCommand runs a command with the default 60s timeout.
func (a *App) runCommand(binary string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), DefaultCommandTimeout)
defer cancel()
return a.runCommandContext(ctx, binary, args...)
}
// runCommandTimeout runs a command with a custom timeout.
func (a *App) runCommandTimeout(timeout time.Duration, binary string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return a.runCommandContext(ctx, binary, args...)
}
// runAdbShell runs `adb shell <args...>` where each arg is a discrete argument.
// SECURITY: Never use this with a pre-built shell string. Always pass individual args.
// Example: runAdbShell("ls", "-la", "/sdcard") NOT runAdbShell("ls -la /sdcard")
func (a *App) runAdbShell(args ...string) (string, error) {
shellArgs := append([]string{"shell"}, args...)
return a.runCommand("adb", shellArgs...)
}
// runAdbShellTimeout is runAdbShell with a custom timeout.
func (a *App) runAdbShellTimeout(timeout time.Duration, args ...string) (string, error) {
shellArgs := append([]string{"shell"}, args...)
return a.runCommandTimeout(timeout, "adb", shellArgs...)
}
// CheckSystemRequirements verifies adb and fastboot are accessible and working.
func (a *App) CheckSystemRequirements() (map[string]string, error) {
result := map[string]string{}
adbPath, err := a.getBinaryPath("adb")
if err != nil {
return nil, fmt.Errorf("adb not found: %w", err)
}
fbPath, err := a.getBinaryPath("fastboot")
if err != nil {
return nil, fmt.Errorf("fastboot not found: %w", err)
}
// Run adb --version to confirm it works
adbVer, err := a.runCommand("adb", "version")
if err != nil {
return nil, fmt.Errorf("adb found at %s but failed to run: %w", adbPath, err)
}
// Extract just the version line
for _, line := range strings.Split(adbVer, "\n") {
if strings.HasPrefix(line, "Android Debug Bridge") {
result["adb"] = strings.TrimSpace(line)
break
}
}
if result["adb"] == "" {
result["adb"] = adbPath
}
result["adbPath"] = adbPath
result["fastbootPath"] = fbPath
return result, nil
}
// GetBinaryInfo returns path and SHA-256 for both binaries.
// Exposes this to the frontend so users can verify their own tooling.
func (a *App) GetBinaryInfo() (map[string]string, error) {
result := map[string]string{}
for _, name := range []string{"adb", "fastboot"} {
info, err := a.VerifyBinary(name)
if err != nil {
result[name] = fmt.Sprintf("error: %s", err.Error())
} else {
result[name] = info
}
}
return result, nil
}
// SetAdbPath allows the user to override the adb binary path.
func (a *App) SetAdbPath(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("path does not exist: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory, expected a binary file")
}
a.config.AdbPath = path
a.invalidateBinaryCache()
return nil
}
// SetFastbootPath allows the user to override the fastboot binary path.
func (a *App) SetFastbootPath(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("path does not exist: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory, expected a binary file")
}
a.config.FastbootPath = path
a.invalidateBinaryCache()
return nil
}
// CancelOperation cancels any currently running long operation.
func (a *App) CancelOperation() string {
a.opMutex.Lock()
defer a.opMutex.Unlock()
if a.currentCancel != nil {
a.currentCancel()
a.currentCancel = nil
return "Operation cancelled."
}
return "No active operation to cancel."
}
// beginCancellableOp sets up a cancellable context for a long operation.
// Caller must call the returned cancel func (defer it).
func (a *App) beginCancellableOp(timeout time.Duration) (context.Context, context.CancelFunc) {
a.opMutex.Lock()
defer a.opMutex.Unlock()
// Cancel any previously running op
if a.currentCancel != nil {
a.currentCancel()
}
var ctx context.Context
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
a.currentCancel = cancel
return ctx, func() {
cancel()
a.opMutex.Lock()
if a.currentCancel != nil {
a.currentCancel = nil
}
a.opMutex.Unlock()
}
}

263
file_service.go Normal file
View file

@ -0,0 +1,263 @@
package main
import (
"fmt"
"regexp"
"strings"
"time"
)
// 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)
if err != nil {
return nil, fmt.Errorf("failed to list %s: %w", path, err)
}
return parseFileList(output), nil
}
// parseFileList parses the output of `ls -lA` into FileEntry structs.
func parseFileList(output string) []FileEntry {
var files []FileEntry
spaceRe := regexp.MustCompile(`\s+`)
for _, rawLine := range strings.Split(output, "\n") {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "total") {
continue
}
parts := spaceRe.Split(line, 9)
if len(parts) < 6 {
continue
}
permissions := parts[0]
fileType := "File"
size := ""
if len(parts) > 4 {
size = parts[4]
}
if len(permissions) > 0 {
switch permissions[0] {
case 'd':
fileType = "Directory"
case 'l':
fileType = "Symlink"
size = "" // symlinks don't have meaningful sizes
}
}
var name, date, modTime string
switch {
case len(parts) >= 8:
date = parts[5]
modTime = parts[6]
name = strings.Join(parts[7:], " ")
case len(parts) == 7:
date = parts[5]
name = parts[6]
default:
name = parts[len(parts)-1]
}
// Strip symlink target from name
if fileType == "Symlink" {
if idx := strings.Index(name, " -> "); idx >= 0 {
name = name[:idx]
}
}
files = append(files, FileEntry{
Name: strings.TrimSpace(name),
Type: fileType,
Size: size,
Permissions: permissions,
Date: strings.TrimSpace(date),
Time: strings.TrimSpace(modTime),
})
}
return files
}
// PushFile pushes a local file to the device.
// Both paths are passed as discrete arguments - no shell injection.
func (a *App) PushFile(localPath, remotePath string) (string, error) {
ctx, cancel := a.beginCancellableOp(30 * time.Minute)
defer cancel()
output, err := a.runCommandContext(ctx, "adb", "push", localPath, remotePath)
if err != nil {
if strings.Contains(err.Error(), "cancelled") {
return "", fmt.Errorf("push cancelled")
}
return "", fmt.Errorf("push failed: %w", err)
}
return output, nil
}
// PullFile pulls a file from the device to a local path.
// Both paths are passed as discrete arguments - no shell injection.
func (a *App) PullFile(remotePath, localPath string) (string, error) {
// No timeout for pulls - only user cancellation
ctx, cancel := a.beginCancellableOp(0)
defer cancel()
// -a preserves timestamps
output, err := a.runCommandContext(ctx, "adb", "pull", "-a", remotePath, localPath)
if err != nil {
if strings.Contains(err.Error(), "cancelled") {
return "", fmt.Errorf("pull cancelled")
}
return "", fmt.Errorf("pull failed: %w", err)
}
return output, nil
}
// CreateFolder creates a directory on the device.
// fullPath is passed as a discrete argument - safe.
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)
if err != nil {
return "", fmt.Errorf("failed to create folder: %w", err)
}
return fmt.Sprintf("Created: %s", fullPath), nil
}
// DeleteFile deletes a file or directory on the device.
// fullPath is passed as a discrete argument - safe.
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)
if err != nil {
return "", fmt.Errorf("failed to delete: %w", err)
}
return fmt.Sprintf("Deleted: %s", fullPath), nil
}
// RenameFile renames/moves a file on the device.
// Both paths are discrete arguments - safe.
func (a *App) RenameFile(oldPath, newPath string) (string, error) {
if err := validateRemotePath(oldPath); err != nil {
return "", fmt.Errorf("invalid source path: %w", err)
}
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)
if err != nil {
return "", fmt.Errorf("failed to rename: %w", err)
}
return fmt.Sprintf("Renamed to: %s", newPath), nil
}
// CopyFile copies a file on the device.
// Both paths are discrete arguments - safe.
func (a *App) CopyFile(srcPath, dstPath string) (string, error) {
if err := validateRemotePath(srcPath); err != nil {
return "", fmt.Errorf("invalid source path: %w", err)
}
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)
if err != nil {
return "", fmt.Errorf("failed to copy: %w", err)
}
return fmt.Sprintf("Copied to: %s", dstPath), nil
}
// DeleteMultipleFiles deletes multiple files/directories.
func (a *App) DeleteMultipleFiles(fullPaths []string) (string, error) {
if len(fullPaths) == 0 {
return "", fmt.Errorf("no files selected")
}
var successCount, failCount int
var errDetails strings.Builder
for _, path := range fullPaths {
if _, err := a.DeleteFile(path); err != nil {
failCount++
errDetails.WriteString(fmt.Sprintf("• %s: %v\n", path, err))
} else {
successCount++
}
}
summary := fmt.Sprintf("Deleted %d item(s).", successCount)
if failCount > 0 {
summary += fmt.Sprintf(" Failed: %d\n%s", failCount, errDetails.String())
}
return summary, nil
}
// PullMultipleFiles pulls multiple files from the device to a user-chosen directory.
func (a *App) PullMultipleFiles(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 "Export cancelled.", nil
}
var successCount, failCount int
var errDetails strings.Builder
for _, remotePath := range remotePaths {
_, err := a.PullFile(remotePath, localDir)
if err != nil {
if strings.Contains(err.Error(), "cancelled") {
errDetails.WriteString(fmt.Sprintf("• %s: cancelled\n", remotePath))
failCount++
break // user cancelled - stop the batch
}
failCount++
errDetails.WriteString(fmt.Sprintf("• %s: %v\n", remotePath, err))
} else {
successCount++
}
}
summary := fmt.Sprintf("Exported %d item(s) to %s.", successCount, localDir)
if failCount > 0 {
summary += fmt.Sprintf(" Failed: %d\n%s", failCount, errDetails.String())
}
return summary, 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 {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("path cannot be empty")
}
if path == "/" {
return fmt.Errorf("refusing to operate on root filesystem path /")
}
return nil
}

15
frontend/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
frontend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "atk-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"lucide-react": "^0.383.0",
"sonner": "^1.4.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.1.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

85
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,85 @@
import { useState, useEffect } from 'react'
import { Toaster } from 'sonner'
import Sidebar from './components/layout/Sidebar'
import ViewDashboard from './components/views/ViewDashboard'
import ViewFiles from './components/views/ViewFiles'
import ViewPackages from './components/views/ViewPackages'
import ViewDebloater from './components/views/ViewDebloater'
import ViewShell from './components/views/ViewShell'
import ViewLogcat from './components/views/ViewLogcat'
import ViewAppInspect from './components/views/ViewAppInspect'
import ViewCerts from './components/views/ViewCerts'
import ViewBackup from './components/views/ViewBackup'
import ViewProps from './components/views/ViewProps'
import ViewFlasher from './components/views/ViewFlasher'
import ViewPixelFlasher from './components/views/ViewPixelFlasher'
import ViewUtilities from './components/views/ViewUtilities'
import ViewSettings from './components/views/ViewSettings'
import { CheckSystemRequirements } from './lib/wails'
import type { View } from './lib/types'
export default function App() {
const [view, setView] = useState<View>('dashboard')
const [ready, setReady] = useState(false)
const [initError, setInitError] = useState('')
useEffect(() => {
CheckSystemRequirements()
.then(() => setReady(true))
.catch((err: string) => { setInitError(err); setReady(true) })
}, [])
const renderView = () => {
switch (view) {
case 'dashboard': return <ViewDashboard />
case 'files': return <ViewFiles />
case 'packages': return <ViewPackages />
case 'debloater': return <ViewDebloater />
case 'shell': return <ViewShell />
case 'logcat': return <ViewLogcat />
case 'appinspect': return <ViewAppInspect />
case 'certs': return <ViewCerts />
case 'backup': return <ViewBackup />
case 'props': return <ViewProps />
case 'flasher': return <ViewFlasher />
case 'pixelflasher': return <ViewPixelFlasher />
case 'utilities': return <ViewUtilities />
case 'settings': return <ViewSettings />
default: return <ViewDashboard />
}
}
if (!ready) return (
<div className="flex h-full items-center justify-center bg-bg-base">
<div className="text-center">
<div className="w-8 h-8 border-2 border-accent-green border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-text-secondary text-sm">Initialising ATK...</p>
</div>
</div>
)
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>
<Toaster
position="bottom-right"
theme="dark"
toastOptions={{
style: {
background: '#18181f', border: '1px solid #252530',
color: '#e8e8f0', fontFamily: "'IBM Plex Sans', sans-serif", fontSize: '13px',
},
}}
/>
</div>
)
}

View file

@ -0,0 +1,78 @@
import {
LayoutDashboard, FolderOpen, Package, Terminal,
Zap, Wrench, Settings, Radio, Shield, Smartphone,
ScrollText, Search, Lock, Archive, SlidersHorizontal
} from 'lucide-react'
import type { View } from '../../lib/types'
interface Props {
activeView: View
onViewChange: (v: View) => void
}
const navItems: { view: View; icon: React.ReactNode; label: string; dividerBefore?: boolean }[] = [
{ view: 'dashboard', icon: <LayoutDashboard size={17} />, label: 'Dashboard' },
{ view: 'files', icon: <FolderOpen size={17} />, label: 'Files' },
{ view: 'packages', icon: <Package size={17} />, label: 'Packages' },
{ view: 'debloater', icon: <Shield size={17} />, label: 'Debloater' },
{ view: 'shell', icon: <Terminal size={17} />, label: 'Shell' },
{ view: 'logcat', icon: <ScrollText size={17} />, label: 'Logcat', dividerBefore: true },
{ view: 'appinspect', icon: <Search size={17} />, label: 'App Inspector' },
{ view: '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' },
]
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>
<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>
</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>
</aside>
)
}

View file

@ -0,0 +1,278 @@
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 { notify } from '../../lib/notify'
import type { AppInspection, PackageInfo } from '../../lib/types'
export default function ViewAppInspect() {
const [search, setSearch] = useState('')
const [packages, setPackages] = useState<PackageInfo[]>([])
const [pkgsLoaded, setPkgsLoaded] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<AppInspection | null>(null)
const [pinning, setPinning] = useState('')
const [activeTab, setActiveTab] = useState('overview')
const [showManifest, setShowManifest] = useState(false)
const loadPackages = async () => {
if (pkgsLoaded) return
try {
const pkgs = await ListPackages('all')
setPackages(pkgs || [])
setPkgsLoaded(true)
} catch {}
}
const inspect = async (pkg: string) => {
if (!pkg.trim()) return
setLoading(true)
setResult(null)
setPinning('')
setActiveTab('overview')
try {
const data = await InspectApp(pkg.trim())
setResult(data)
} catch (e: any) {
notify.error(e)
} finally {
setLoading(false)
}
}
const checkPinning = async () => {
if (!result) return
try {
const out = await CheckPinning(result.packageName)
setPinning(out)
} catch (e: any) {
notify.error(e)
}
}
const filtered = packages.filter(p =>
p.packageName.toLowerCase().includes(search.toLowerCase())
).slice(0, 20)
const tabs = [
{ id: 'overview', label: 'Overview', icon: <Package size={12} /> },
{ id: '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} /> },
{ id: 'cert', label: 'Signing Cert', icon: <FileCode size={12} /> },
{ id: 'manifest', label: 'Full Dump', icon: <Database size={12} /> },
]
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">
<div className="p-3 border-b border-bg-border space-y-2 shrink-0">
<p className="section-title">App Inspector</p>
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
<input
className="input pl-7 text-xs w-full"
placeholder="Package name..."
value={search}
onChange={e => setSearch(e.target.value)}
onFocus={loadPackages}
onKeyDown={e => e.key === 'Enter' && inspect(search)}
/>
</div>
<button onClick={() => inspect(search)} disabled={!search || loading} className="btn-primary text-xs w-full justify-center">
{loading ? 'Inspecting...' : 'Inspect'}
</button>
</div>
<div className="flex-1 overflow-auto">
{filtered.map(p => (
<button
key={p.packageName}
onClick={() => { setSearch(p.packageName); inspect(p.packageName) }}
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>
</button>
))}
{!pkgsLoaded && (
<p className="text-text-muted text-xs text-center p-4">Type to search or focus to load package list</p>
)}
</div>
</div>
{/* Right: inspection results */}
<div className="flex-1 flex flex-col overflow-hidden">
{!result && !loading && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-muted">
<Package size={32} className="opacity-20" />
<p className="text-sm">Select a package to inspect</p>
</div>
)}
{loading && (
<div className="flex items-center justify-center h-full">
<div className="w-6 h-6 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
</div>
)}
{result && (
<>
{/* Package header */}
<div className="border-b border-bg-border px-4 py-3 flex items-start justify-between shrink-0">
<div>
<p className="mono text-sm text-text-primary">{result.packageName}</p>
<p className="text-xs text-text-muted mt-0.5">
v{result.versionName} (code {result.versionCode}) · SDK {result.minSdk}{result.targetSdk}
</p>
<div className="flex gap-2 mt-1.5 flex-wrap">
{result.isSystem && <span className="badge-gray">system</span>}
{result.isDebuggable && <span className="badge-yellow">debuggable</span>}
{result.isEnabled
? <span className="badge-green">enabled</span>
: <span className="badge-red">disabled</span>
}
<span className="badge-gray">UID {result.uid}</span>
</div>
</div>
<button onClick={checkPinning} className="btn-ghost text-xs shrink-0">
<AlertTriangle size={12} /> Check Pinning
</button>
</div>
{/* Pinning result */}
{pinning && (
<div className="border-b border-warn/20 bg-warn/5 px-4 py-2 text-xs shrink-0">
<pre className="whitespace-pre-wrap text-warn/90">{pinning}</pre>
</div>
)}
{/* Tabs */}
<div className="border-b border-bg-border flex shrink-0 overflow-x-auto">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-3 py-2 text-xs whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.id
? 'border-accent-green text-accent-green'
: 'border-transparent text-text-muted hover:text-text-secondary'
}`}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 overflow-auto p-4">
{activeTab === 'overview' && (
<div className="grid grid-cols-2 gap-x-8 gap-y-2">
{[
{ label: 'Install path', value: result.installPath },
{ label: 'Data dir', value: result.dataDir },
{ label: 'Installed by', value: result.installer || 'Unknown' },
{ label: 'First installed', value: result.firstInstall },
{ label: 'Last updated', value: result.lastUpdated },
{ label: 'UID', value: result.uid },
{ label: 'Target SDK', value: result.targetSdk },
{ label: 'Min SDK', value: result.minSdk },
].map(({ label, value }) => (
<div key={label} className="flex gap-2 min-w-0">
<span className="text-text-muted text-xs w-28 shrink-0">{label}</span>
<span className="text-xs text-text-primary mono truncate">{value || 'N/A'}</span>
</div>
))}
</div>
)}
{activeTab === 'permissions' && (
<div className="space-y-1">
{result.permissions?.length === 0 && (
<p className="text-text-muted text-xs">No permissions detected</p>
)}
{result.permissions?.map(p => (
<div key={p} className="flex items-center gap-2 py-1 border-b border-bg-border/30">
<Shield size={11} className="text-warn shrink-0" />
<span className="mono text-xs text-text-secondary">{p}</span>
</div>
))}
</div>
)}
{activeTab === 'components' && (
<div className="space-y-4">
{[
{ label: 'Activities', items: result.activities, icon: <Activity size={11} /> },
{ label: 'Services', items: result.services, icon: <Server size={11} /> },
{ label: 'Receivers', items: result.receivers, icon: <Activity size={11} /> },
{ label: 'Providers', items: result.providers, icon: <Database size={11} /> },
].map(({ label, items, icon }) => (
<div key={label}>
<p className="section-title mb-2">{label} ({items?.length || 0})</p>
{!items?.length && <p className="text-text-muted text-xs">None detected</p>}
{items?.map(item => (
<div key={item} className="flex items-center gap-2 py-1 border-b border-bg-border/30">
<span className="text-text-muted shrink-0">{icon}</span>
<span className="mono text-xs text-text-secondary">{item}</span>
</div>
))}
</div>
))}
</div>
)}
{activeTab === 'libs' && (
<div className="space-y-1">
{!result.nativeLibs?.length && (
<p className="text-text-muted text-xs">No native libraries detected</p>
)}
{result.nativeLibs?.map(lib => (
<div key={lib} className="flex items-center gap-2 py-1 border-b border-bg-border/30">
<Cpu size={11} className="text-text-muted shrink-0" />
<span className="mono text-xs text-text-secondary">{lib}</span>
</div>
))}
</div>
)}
{activeTab === 'cert' && (
<div className="space-y-3">
{[
{ label: 'Subject', value: result.certSubject },
{ label: 'Issuer', value: result.certIssuer },
{ label: 'Expires', value: result.certExpiry },
{ label: 'SHA-256', value: result.certSha256 },
].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 || 'Not available — run aapt or apksigner manually for cert details'}
</p>
</div>
))}
</div>
)}
{activeTab === 'manifest' && (
<div>
<button
onClick={() => setShowManifest(v => !v)}
className="btn-ghost text-xs mb-3"
>
{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>
)}
</div>
)}
</div>
</>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,225 @@
import { useState } from 'react'
import { Archive, RotateCcw, AlertTriangle, Package, Check } from 'lucide-react'
import { StartBackup, RestoreBackup, SelectBackupFile, ListPackages } from '../../lib/wails'
import { notify } from '../../lib/notify'
import type { PackageInfo } from '../../lib/types'
export default function ViewBackup() {
const [packages, setPackages] = useState<PackageInfo[]>([])
const [pkgsLoaded, setPkgsLoaded] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [includeApks, setIncludeApks] = useState(true)
const [includeShared, setIncludeShared] = useState(false)
const [allApps, setAllApps] = useState(false)
const [backing, setBacking] = useState(false)
const [search, setSearch] = useState('')
const [result, setResult] = useState('')
const loadPackages = async () => {
if (pkgsLoaded) return
try {
const pkgs = await ListPackages('user')
setPackages(pkgs || [])
setPkgsLoaded(true)
} catch (e: any) {
notify.error(e)
}
}
const toggleSelect = (pkg: string) => setSelected(prev => {
const next = new Set(prev)
next.has(pkg) ? next.delete(pkg) : next.add(pkg)
return next
})
const selectAll = () => {
const visible = filtered.map(p => p.packageName)
if (selected.size === visible.length) {
setSelected(new Set())
} else {
setSelected(new Set(visible))
}
}
const startBackup = async () => {
if (!allApps && selected.size === 0) {
notify.error('Select packages or enable "All Apps"')
return
}
setBacking(true)
setResult('')
const id = notify.loading('Starting backup — confirm on device screen...')
try {
const out = await StartBackup({
includeApks,
includeShared,
includeSystem: false,
packages: [...selected],
allApps,
}, '')
notify.dismiss(id)
notify.success('Backup complete')
setResult(out)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
} finally {
setBacking(false)
}
}
const restore = async () => {
const path = await SelectBackupFile()
if (!path) return
if (!confirm(`Restore from:\n${path}\n\nThis will restore data on the device. Confirm on device screen.`)) return
const id = notify.loading('Starting restore — confirm on device...')
try {
const out = await RestoreBackup(path)
notify.dismiss(id)
notify.success(out)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const filtered = packages.filter(p =>
p.packageName.toLowerCase().includes(search.toLowerCase())
)
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">
<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>
</div>
<div className="grid grid-cols-1 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>
{/* Options */}
<div className="space-y-2">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={includeApks} onChange={e => setIncludeApks(e.target.checked)} className="accent-accent-green" />
<div>
<p className="text-xs text-text-primary">Include APK files</p>
<p className="text-xs text-text-muted">Backs up the app installer along with data</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={includeShared} onChange={e => setIncludeShared(e.target.checked)} className="accent-accent-green" />
<div>
<p className="text-xs text-text-primary">Include shared storage</p>
<p className="text-xs text-text-muted">Includes /sdcard contents (photos, downloads etc)</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={allApps} onChange={e => { setAllApps(e.target.checked); if (e.target.checked) setSelected(new Set()) }} className="accent-accent-green" />
<div>
<p className="text-xs text-text-primary">All apps</p>
<p className="text-xs text-text-muted">Back up all installed user apps (overrides selection below)</p>
</div>
</label>
</div>
{/* Package selection */}
{!allApps && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-text-muted">Select specific apps</p>
<button onClick={() => { loadPackages(); selectAll() }} className="btn-ghost text-xs">
{selected.size > 0 ? 'Deselect all' : 'Select all'}
</button>
</div>
<input
className="input text-xs w-full"
placeholder="Search packages..."
value={search}
onChange={e => setSearch(e.target.value)}
onFocus={loadPackages}
/>
<div className="max-h-48 overflow-auto space-y-0.5 border border-bg-border rounded">
{!pkgsLoaded && (
<p className="text-text-muted text-xs text-center p-4">Focus search to load packages</p>
)}
{filtered.map(p => (
<label key={p.packageName} className="flex items-center gap-2 px-3 py-1.5 hover:bg-bg-raised cursor-pointer">
<input
type="checkbox"
checked={selected.has(p.packageName)}
onChange={() => toggleSelect(p.packageName)}
className="accent-accent-green shrink-0"
/>
<span className="mono text-xs text-text-secondary truncate">{p.packageName}</span>
</label>
))}
</div>
{selected.size > 0 && (
<p className="text-xs text-accent-green">{selected.size} app(s) selected</p>
)}
</div>
)}
<div className="flex gap-2 pt-2">
<button onClick={startBackup} disabled={backing} className="btn-primary flex-1 justify-center">
<Archive size={13} />
{backing ? 'Backing up...' : 'Start Backup'}
</button>
<button onClick={restore} className="btn-ghost flex-1 justify-center text-xs">
<RotateCcw size={13} /> Restore
</button>
</div>
{result && (
<div className="bg-bg-raised rounded p-3 text-xs mono text-text-secondary whitespace-pre-wrap border border-bg-border">
{result}
</div>
)}
</div>
{/* Info panel */}
<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">
<div className="flex gap-2">
<Check size={12} className="text-accent-green shrink-0 mt-0.5" />
<p>Creates an encrypted <span className="mono">.adb</span> file on your host machine</p>
</div>
<div className="flex gap-2">
<Check size={12} className="text-accent-green shrink-0 mt-0.5" />
<p>You must confirm the backup on the device screen it won't start without device-side confirmation</p>
</div>
<div className="flex gap-2">
<AlertTriangle size={12} className="text-warn shrink-0 mt-0.5" />
<p>Apps that set <span className="mono">allowBackup=false</span> are silently skipped you won't be told which ones</p>
</div>
<div className="flex gap-2">
<AlertTriangle size={12} className="text-warn shrink-0 mt-0.5" />
<p>Android 12+ requires the <span className="mono">ALLOW_ADB_BACKUP</span> flag most production apps won't have it</p>
</div>
<div className="flex gap-2">
<AlertTriangle size={12} className="text-warn shrink-0 mt-0.5" />
<p>For bug hunting purposes, this is mainly useful for pulling app data from debuggable or rooted builds</p>
</div>
</div>
<div className="border-t border-bg-border pt-4">
<p className="section-title mb-2">Useful for bug hunting</p>
<div className="space-y-1.5 text-xs text-text-muted">
<p> Back up a target app's data before testing so you can restore to a clean state</p>
<p> Use with <span className="mono">-apk</span> to get both the APK and its data in one file</p>
<p> Combine with ADB shell to inspect <span className="mono">/data/data/com.package</span> directly if rooted</p>
</div>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,160 @@
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 type { CertInfo } from '../../lib/types'
export default function ViewCerts() {
const [systemCerts, setSystemCerts] = useState<CertInfo[]>([])
const [userCerts, setUserCerts] = useState<CertInfo[]>([])
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'user' | 'system'>('user')
const load = async () => {
setLoading(true)
try {
const [sys, usr] = await Promise.all([ListSystemCerts(), ListUserCerts()])
setSystemCerts(sys || [])
setUserCerts(usr || [])
} catch (e: any) {
notify.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
const installCert = async () => {
try {
const path = await SelectCertFile()
if (!path) return
const id = notify.loading('Installing certificate...')
const out = await InstallUserCert(path)
notify.dismiss(id)
notify.success(out)
load()
} catch (e: any) {
notify.error(e)
}
}
const removeCert = async (cert: CertInfo) => {
if (!confirm(`Remove certificate:\n${cert.subject || cert.filename}?`)) return
try {
const out = await RemoveUserCert(cert.filename)
notify.success(out)
load()
} catch (e: any) {
notify.error(e)
}
}
const certs = activeTab === 'user' ? userCerts : systemCerts
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-3 shrink-0">
<Lock size={14} className="text-accent-green" />
<span className="text-xs text-text-secondary">
{userCerts.length} user certs · {systemCerts.length} system certs
</span>
<div className="flex-1" />
<button onClick={installCert} className="btn-primary text-xs">
<Plus size={12} /> Install User CA
</button>
<button onClick={load} disabled={loading} className="btn-ghost text-xs">
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</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>
</div>
</div>
{/* Tabs */}
<div className="border-b border-bg-border flex shrink-0">
{(['user', 'system'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-xs border-b-2 transition-colors ${
activeTab === tab
? 'border-accent-green text-accent-green'
: 'border-transparent text-text-muted hover:text-text-secondary'
}`}
>
{tab === 'user' ? `User Certificates (${userCerts.length})` : `System Certificates (${systemCerts.length})`}
</button>
))}
</div>
{/* 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">
<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>
)}
{/* Cert list */}
<div className="flex-1 overflow-auto">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && certs.length === 0 && (
<div className="flex flex-col items-center justify-center h-32 gap-2 text-text-muted">
<Lock size={24} className="opacity-30" />
<p className="text-sm">
{activeTab === 'user' ? 'No user-installed certificates' : 'No system certificates found'}
</p>
</div>
)}
{certs.map(cert => (
<div key={cert.filename} className="border-b border-bg-border/50 px-4 py-3 flex items-start gap-3 hover:bg-bg-raised transition-colors">
<Shield size={14} className="text-accent-green shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-text-primary truncate">
{cert.subject || cert.filename}
</span>
{cert.isUser && <span className="badge-yellow">user</span>}
{cert.isSystem && <span className="badge-gray">system</span>}
</div>
{cert.issuer && <p className="text-xs text-text-muted mt-0.5">Issuer: {cert.issuer}</p>}
{cert.expiry && <p className="text-xs text-text-muted">Expires: {cert.expiry}</p>}
{cert.fingerprint && (
<p className="mono text-xs text-text-muted mt-0.5 break-all">{cert.fingerprint}</p>
)}
<p className="mono text-xs text-text-muted">{cert.filename}</p>
</div>
{cert.isUser && (
<button
onClick={() => removeCert(cert)}
className="btn-danger text-xs py-1 px-2 shrink-0"
title="Remove certificate"
>
<Trash2 size={11} />
</button>
)}
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,248 @@
import { useState, useEffect, useCallback } from 'react'
import { RefreshCw, Wifi, WifiOff, RotateCcw, Shield, Cpu, Battery, HardDrive, Monitor } from 'lucide-react'
import {
GetDevices, GetDeviceInfo, EnableWirelessAdb,
ConnectWirelessAdb, DisconnectWirelessAdb, Reboot
} from '../../lib/wails'
import { notify } from '../../lib/notify'
import type { Device, DeviceInfo } from '../../lib/types'
export default function ViewDashboard() {
const [devices, setDevices] = useState<Device[]>([])
const [info, setInfo] = useState<DeviceInfo | null>(null)
const [loading, setLoading] = useState(false)
const [infoLoading, setInfoLoading] = useState(false)
const [wirelessIp, setWirelessIp] = useState('')
const [wirelessPort, setWirelessPort] = useState('5555')
const refreshDevices = useCallback(async () => {
setLoading(true)
try {
const devs = await GetDevices()
setDevices(devs || [])
} catch (e: any) {
notify.error(e)
} finally {
setLoading(false)
}
}, [])
const loadDeviceInfo = useCallback(async () => {
setInfoLoading(true)
setInfo(null)
try {
const i = await GetDeviceInfo()
setInfo(i)
} catch (e: any) {
notify.error(e)
} finally {
setInfoLoading(false)
}
}, [])
useEffect(() => {
refreshDevices()
}, [refreshDevices])
const handleEnableWireless = async () => {
const id = notify.loading('Enabling wireless ADB...')
try {
const out = await EnableWirelessAdb(wirelessPort)
notify.dismiss(id)
notify.success(out || 'Wireless ADB enabled')
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const handleConnect = async () => {
if (!wirelessIp) { notify.error('Enter an IP address'); return }
const id = notify.loading(`Connecting to ${wirelessIp}:${wirelessPort}...`)
try {
const out = await ConnectWirelessAdb(wirelessIp, wirelessPort)
notify.dismiss(id)
notify.success(out)
refreshDevices()
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const handleDisconnect = async () => {
if (!wirelessIp) { notify.error('Enter an IP address'); return }
const id = notify.loading('Disconnecting...')
try {
const out = await DisconnectWirelessAdb(wirelessIp, wirelessPort)
notify.dismiss(id)
notify.success(out)
refreshDevices()
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const handleReboot = async (mode: string) => {
const label = mode || 'system'
const id = notify.loading(`Rebooting to ${label}...`)
try {
await Reboot(mode)
notify.dismiss(id)
notify.success(`Reboot to ${label} initiated`)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const connectedDevices = devices.filter(d => d.status === 'device')
return (
<div className="p-4 space-y-4 h-full overflow-auto">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-base font-medium text-text-primary">Dashboard</h1>
<button onClick={refreshDevices} disabled={loading} className="btn-ghost text-xs">
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{/* Device List */}
<div className="card p-4 space-y-3">
<p className="section-title">Connected Devices</p>
{devices.length === 0 ? (
<div className="text-center py-6">
<p className="text-text-muted text-sm">No devices detected</p>
<p className="text-text-muted text-xs mt-1">Connect a device with USB debugging enabled</p>
</div>
) : (
<div className="space-y-2">
{devices.map(d => (
<div key={d.serial} className="flex items-center justify-between bg-bg-raised rounded px-3 py-2">
<div>
<p className="mono text-text-primary text-xs">{d.serial}</p>
<p className="text-text-muted text-xs mt-0.5">{d.status}</p>
</div>
<span className={`status-dot ${d.status === 'device' ? 'status-dot-green' : 'status-dot-red'}`} />
</div>
))}
</div>
)}
{connectedDevices.length > 0 && (
<button onClick={loadDeviceInfo} disabled={infoLoading} className="btn-ghost w-full text-xs justify-center">
{infoLoading ? <span className="animate-spin"></span> : null}
{infoLoading ? 'Loading...' : 'Load Device Info'}
</button>
)}
</div>
{/* Device Info */}
<div className="card p-4 space-y-3 xl:col-span-2">
<p className="section-title">Device Information</p>
{!info && !infoLoading && (
<div className="text-center py-6">
<p className="text-text-muted text-sm">Select a device and click "Load Device Info"</p>
</div>
)}
{infoLoading && (
<div className="text-center py-6">
<div className="w-6 h-6 border-2 border-accent-green border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<p className="text-text-muted text-sm">Fetching device details...</p>
</div>
)}
{info && (
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{[
{ label: 'Model', value: `${info.brand} ${info.model}` },
{ label: 'Codename', value: info.codename },
{ label: 'Android', value: info.androidVersion },
{ label: 'Build', value: info.buildNumber },
{ label: 'Serial', value: info.serial, mono: true },
{ label: 'CPU Arch', value: info.cpuArch },
{ label: 'RAM', value: info.ramTotal, icon: <Cpu size={12} /> },
{ label: 'Storage', value: info.storageInfo, icon: <HardDrive size={12} /> },
{ label: 'Battery', value: info.batteryLevel, icon: <Battery size={12} /> },
{ label: 'Screen', value: info.screenResolution,icon: <Monitor size={12} /> },
{ label: 'IP Address', value: info.ipAddress },
{ label: 'Uptime', value: info.uptime },
{ label: 'Root', value: info.rootStatus, icon: <Shield size={12} /> },
{ label: 'Bootloader', value: info.bootloaderStatus },
{ label: 'Security Patch', value: info.securityPatch },
{ label: 'Kernel', value: info.kernelVersion, mono: true },
{ label: 'Baseband', value: info.basebandVersion, mono: true },
].map(({ label, value, mono, icon }) => (
<div key={label} className="flex items-start gap-2 min-w-0">
<span className="text-text-muted text-xs w-28 shrink-0 pt-0.5">{label}</span>
<span className={`text-xs text-text-primary truncate flex items-center gap-1 ${mono ? 'font-mono' : ''}`}>
{icon && <span className="text-text-muted">{icon}</span>}
{value || 'N/A'}
</span>
</div>
))}
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{/* Wireless ADB */}
<div className="card p-4 space-y-3">
<p className="section-title">Wireless ADB</p>
<div className="space-y-2">
<div className="flex gap-2">
<input
className="input"
placeholder="192.168.1.x"
value={wirelessIp}
onChange={e => setWirelessIp(e.target.value)}
/>
<input
className="input w-24 shrink-0"
placeholder="5555"
value={wirelessPort}
onChange={e => setWirelessPort(e.target.value)}
/>
</div>
<div className="flex gap-2">
<button onClick={handleEnableWireless} className="btn-ghost flex-1 justify-center text-xs">
<Wifi size={13} /> Enable TCP/IP
</button>
<button onClick={handleConnect} className="btn-primary flex-1 justify-center text-xs">
<Wifi size={13} /> Connect
</button>
<button onClick={handleDisconnect} className="btn-ghost flex-1 justify-center text-xs">
<WifiOff size={13} /> Disconnect
</button>
</div>
</div>
</div>
{/* Reboot */}
<div className="card p-4 space-y-3">
<p className="section-title">Reboot Options</p>
<div className="grid grid-cols-2 gap-2">
{[
{ label: 'System', mode: '', cls: 'btn-ghost' },
{ label: 'Recovery', mode: 'recovery', cls: 'btn-warn' },
{ label: 'Bootloader', mode: 'bootloader', cls: 'btn-warn' },
{ label: 'Fastboot', mode: 'fastboot', cls: 'btn-ghost' },
].map(({ label, mode, cls }) => (
<button
key={label}
onClick={() => handleReboot(mode)}
className={`${cls} justify-center text-xs`}
>
<RotateCcw size={13} /> {label}
</button>
))}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,314 @@
import { useState, useEffect, useMemo } from 'react'
import { Shield, RefreshCw, Search, Trash2, PowerOff, AlertTriangle, Check, X, ChevronDown, ChevronRight } from 'lucide-react'
import { ListPackages, DisableMultiplePackages, UninstallMultiplePackages } from '../../lib/wails'
import { notify } from '../../lib/notify'
import { DEBLOAT_CATEGORIES } from '../../lib/debloat_db'
import type { Safety } from '../../lib/debloat_db'
import type { PackageInfo } from '../../lib/types'
const SAFETY_CONFIG: Record<Safety, { label: string; cls: string; icon: React.ReactNode }> = {
safe: { label: 'Safe', cls: 'badge-green', icon: <Check size={10} /> },
caution: { label: 'Caution', cls: 'badge-yellow', icon: <AlertTriangle size={10} /> },
keep: { label: 'Keep', cls: 'badge-red', icon: <X size={10} /> },
}
export default function ViewDebloater() {
const [installed, setInstalled] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [safetyFilter, setSafety] = useState<Safety | 'all'>('all')
const [mfrFilter, setMfrFilter] = useState('all')
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
const [operating, setOperating] = useState(false)
const [showNotInstalled, setShowNotInstalled] = useState(false)
const loadInstalled = async () => {
setLoading(true)
setInstalled(new Set())
setSelected(new Set())
try {
const pkgs = await ListPackages('all')
const names = new Set<string>((pkgs || []).map((p: PackageInfo) => p.packageName))
setInstalled(names)
// Auto-open categories that have installed packages
const withInstalled = new Set<string>()
DEBLOAT_CATEGORIES.forEach(cat => {
if (cat.packages.some(p => names.has(p.pkg))) withInstalled.add(cat.name)
})
setOpenCats(withInstalled)
} catch (e: any) {
notify.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => { loadInstalled() }, [])
const manufacturers = useMemo(() => ['all', ...DEBLOAT_CATEGORIES.map(c => c.name)], [])
const visibleCategories = useMemo(() => {
return DEBLOAT_CATEGORIES
.filter(cat => mfrFilter === 'all' || cat.name === mfrFilter)
.map(cat => ({
...cat,
packages: cat.packages.filter(p => {
if (safetyFilter !== 'all' && p.safety !== safetyFilter) return false
if (!showNotInstalled && !installed.has(p.pkg)) return false
if (search) {
const q = search.toLowerCase()
return p.pkg.toLowerCase().includes(q) || p.label.toLowerCase().includes(q) || p.description.toLowerCase().includes(q)
}
return true
})
}))
.filter(cat => cat.packages.length > 0)
}, [search, safetyFilter, mfrFilter, installed, showNotInstalled])
const totalInstalled = useMemo(() =>
DEBLOAT_CATEGORIES.reduce((n, cat) => n + cat.packages.filter(p => installed.has(p.pkg)).length, 0),
[installed]
)
const toggleCat = (name: string) => setOpenCats(prev => {
const next = new Set(prev)
next.has(name) ? next.delete(name) : next.add(name)
return next
})
const toggleSelect = (pkg: string) => setSelected(prev => {
const next = new Set(prev)
next.has(pkg) ? next.delete(pkg) : next.add(pkg)
return next
})
const selectAllVisible = () => {
const selectable = visibleCategories
.flatMap(c => c.packages)
.filter(p => installed.has(p.pkg) && p.safety !== 'keep')
.map(p => p.pkg)
if (selected.size === selectable.length) {
setSelected(new Set())
} else {
setSelected(new Set(selectable))
}
}
const batchOp = async (label: string, op: (pkgs: string[]) => Promise<string>, confirm_msg: string) => {
if (selected.size === 0) { notify.error('Select packages first'); return }
if (!confirm(confirm_msg)) return
setOperating(true)
const id = notify.loading(`${label} ${selected.size} package(s)...`)
try {
const out = await op([...selected])
notify.dismiss(id)
notify.success(out)
setSelected(new Set())
loadInstalled()
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
} finally {
setOperating(false)
}
}
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 flex-wrap shrink-0 bg-bg-surface">
<Shield size={14} className="text-accent-green shrink-0" />
<span className="text-xs text-text-secondary">
{loading ? 'Scanning device...' : `${totalInstalled} of ${DEBLOAT_CATEGORIES.reduce((n,c)=>n+c.packages.length,0)} packages found on device`}
</span>
<div className="flex-1" />
{/* Manufacturer filter */}
<select
className="input text-xs w-36 py-1"
value={mfrFilter}
onChange={e => setMfrFilter(e.target.value)}
>
{manufacturers.map(m => (
<option key={m} value={m}>{m === 'all' ? 'All manufacturers' : m}</option>
))}
</select>
{/* Safety filter */}
<div className="flex gap-0.5 bg-bg-raised rounded p-0.5">
{(['all', 'safe', 'caution', 'keep'] as const).map(f => (
<button
key={f}
onClick={() => setSafety(f)}
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
safetyFilter === f ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
}`}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer">
<input
type="checkbox"
checked={showNotInstalled}
onChange={e => setShowNotInstalled(e.target.checked)}
className="accent-accent-green"
/>
Show not installed
</label>
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
<input
className="input pl-7 text-xs w-44"
placeholder="Search packages..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<button onClick={loadInstalled} disabled={loading} className="btn-ghost text-xs">
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
Scan
</button>
</div>
{/* Warning */}
<div className="flex items-start gap-2 bg-warn/5 border-b border-warn/20 px-4 py-2 shrink-0">
<AlertTriangle size={13} className="text-warn shrink-0 mt-0.5" />
<p className="text-xs text-warn/80">
<span className="font-medium">Always prefer Disable over Uninstall.</span> Never remove packages marked <span className="text-danger font-medium">Keep</span> they will break your device. Source: Universal Android Debloater (UAD-ng), 2157 packages.
</p>
</div>
{/* Action bar */}
{selected.size > 0 && (
<div className="flex items-center gap-2 px-4 py-2 bg-accent-green/5 border-b border-accent-green/20 shrink-0">
<span className="text-xs text-accent-green font-medium">{selected.size} selected</span>
<div className="flex-1" />
<button
onClick={() => batchOp('Disabling', DisableMultiplePackages,
`Disable ${selected.size} package(s)?\n\nThis is reversible — you can re-enable later.`)}
disabled={operating}
className="btn-warn text-xs"
>
<PowerOff size={12} /> Disable ({selected.size})
</button>
<button
onClick={() => batchOp('Uninstalling', UninstallMultiplePackages,
`Uninstall ${selected.size} package(s) for current user?\n\nUses pm uninstall -k --user 0. Package stays on system but is removed for your user.\nReversible via re-enable or factory reset.`)}
disabled={operating}
className="btn-danger text-xs"
>
<Trash2 size={12} /> Uninstall for user ({selected.size})
</button>
<button onClick={() => setSelected(new Set())} className="btn-ghost text-xs">
Clear
</button>
</div>
)}
{/* Package list */}
<div className="flex-1 overflow-auto">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && visibleCategories.length === 0 && (
<div className="flex flex-col items-center justify-center h-32 gap-2 text-text-muted">
<Shield size={24} className="opacity-30" />
<p className="text-sm">No packages match current filters</p>
{!showNotInstalled && totalInstalled === 0 && (
<p className="text-xs">Try clicking "Scan" to detect installed packages</p>
)}
</div>
)}
{!loading && visibleCategories.map(cat => {
const installedCount = cat.packages.filter(p => installed.has(p.pkg)).length
const isOpen = openCats.has(cat.name)
return (
<div key={cat.name} className="border-b border-bg-border/50">
{/* Category header */}
<button
onClick={() => toggleCat(cat.name)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-bg-raised transition-colors text-left"
>
{isOpen
? <ChevronDown size={13} className="text-accent-green shrink-0" />
: <ChevronRight size={13} className="text-text-muted shrink-0" />
}
<span className="text-xs font-medium text-text-primary">{cat.name}</span>
<span className="text-xs text-text-muted">
{installedCount} on device / {cat.packages.length} shown
</span>
<div className="flex-1" />
{installedCount > 0 && (
<span className="badge-green">{installedCount} installed</span>
)}
</button>
{/* Packages */}
{isOpen && cat.packages.map(p => {
const isInst = installed.has(p.pkg)
const isSel = selected.has(p.pkg)
const safety = SAFETY_CONFIG[p.safety]
return (
<div
key={p.pkg}
className={`
flex items-start gap-3 px-4 py-2 border-t border-bg-border/30 transition-colors
${isInst ? 'hover:bg-bg-raised cursor-pointer' : 'opacity-40'}
${isSel ? 'bg-accent-green/5' : ''}
`}
onClick={() => isInst && p.safety !== 'keep' && toggleSelect(p.pkg)}
>
<input
type="checkbox"
checked={isSel}
disabled={!isInst || p.safety === 'keep'}
onChange={() => toggleSelect(p.pkg)}
className="accent-accent-green mt-0.5 shrink-0"
onClick={e => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-text-primary">{p.label}</span>
<span className={`${safety.cls} flex items-center gap-1 text-xs`}>
{safety.icon} {safety.label}
</span>
{!isInst && <span className="badge-gray text-xs">not on device</span>}
{p.deps && p.deps.length > 0 && (
<span className="badge-gray text-xs" title={`Depends on: ${p.deps.join(', ')}`}>has deps</span>
)}
{p.neededBy && p.neededBy.length > 0 && (
<span className="badge-yellow text-xs" title={`Needed by: ${p.neededBy.join(', ')}`}>needed by others</span>
)}
</div>
<p className="mono text-xs text-text-muted mt-0.5">{p.pkg}</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{p.description}</p>
</div>
</div>
)
})}
</div>
)
})}
</div>
{/* Status bar */}
<div className="border-t border-bg-border px-4 py-1.5 flex items-center justify-between text-xs text-text-muted shrink-0">
<span>{totalInstalled} debloat candidates on device · {DEBLOAT_CATEGORIES.reduce((n,c)=>n+c.packages.length,0)} total in database</span>
<button onClick={selectAllVisible} className="hover:text-text-secondary transition-colors">
{selected.size > 0 ? 'Deselect all' : 'Select all safe+caution'}
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,322 @@
import { useState, useEffect, useCallback } from 'react'
import {
FolderOpen, File, ArrowLeft, RefreshCw, Upload,
Download, Trash2, FolderPlus, Edit3, Copy
} from 'lucide-react'
import {
ListFiles, PushFile, PullMultipleFiles, DeleteMultipleFiles,
CreateFolder, RenameFile, CopyFile, SelectFileForPush, CancelOperation
} from '../../lib/wails'
import { notify } from '../../lib/notify'
import type { FileEntry } from '../../lib/types'
export default function ViewFiles() {
const [path, setPath] = useState('/sdcard')
const [pathInput, setPathInput] = useState('/sdcard')
const [files, setFiles] = useState<FileEntry[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [renaming, setRenaming] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [newFolder, setNewFolder] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const loadFiles = useCallback(async (p: string) => {
setLoading(true)
setSelected(new Set())
try {
const result = await ListFiles(p)
setFiles(result || [])
} catch (e: any) {
notify.error(e)
setFiles([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadFiles(path) }, [path, loadFiles])
const navigate = (entry: FileEntry) => {
if (entry.type === 'Directory') {
const next = path.endsWith('/') ? path + entry.name : path + '/' + entry.name
setPath(next)
setPathInput(next)
}
}
const goUp = () => {
const parts = path.split('/').filter(Boolean)
if (parts.length === 0) return
parts.pop()
const next = '/' + parts.join('/')
setPath(next || '/')
setPathInput(next || '/')
}
const navigatePath = () => {
setPath(pathInput)
loadFiles(pathInput)
}
const toggleSelect = (name: string) => {
setSelected(prev => {
const next = new Set(prev)
next.has(name) ? next.delete(name) : next.add(name)
return next
})
}
const selectAll = () => {
if (selected.size === files.length) {
setSelected(new Set())
} else {
setSelected(new Set(files.map(f => f.name)))
}
}
const handlePush = async () => {
const local = await SelectFileForPush()
if (!local) return
const id = notify.loading('Pushing file...')
try {
const out = await PushFile(local, path)
notify.dismiss(id)
notify.success(out || 'File pushed')
loadFiles(path)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const handlePull = async () => {
if (selected.size === 0) { notify.error('Select files to pull'); return }
const paths = [...selected].map(name =>
path.endsWith('/') ? path + name : path + '/' + name
)
const id = notify.loading(`Pulling ${paths.length} item(s)...`)
try {
const out = await PullMultipleFiles(paths)
notify.dismiss(id)
notify.success(out)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const handleDelete = async () => {
if (selected.size === 0) { notify.error('Select files to delete'); return }
const paths = [...selected].map(name =>
path.endsWith('/') ? path + name : path + '/' + name
)
if (!confirm(`Delete ${paths.length} item(s)?`)) return
const id = notify.loading('Deleting...')
try {
const out = await DeleteMultipleFiles(paths)
notify.dismiss(id)
notify.success(out)
loadFiles(path)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return
const fullPath = path.endsWith('/') ? path + newFolderName : path + '/' + newFolderName
try {
await CreateFolder(fullPath)
notify.success('Folder created')
setNewFolder(false)
setNewFolderName('')
loadFiles(path)
} catch (e: any) {
notify.error(e)
}
}
const startRename = (name: string) => {
setRenaming(name)
setRenameValue(name)
}
const handleRename = async () => {
if (!renaming || !renameValue.trim() || renameValue === renaming) {
setRenaming(null)
return
}
const oldPath = path.endsWith('/') ? path + renaming : path + '/' + renaming
const newPath = path.endsWith('/') ? path + renameValue : path + '/' + renameValue
try {
await RenameFile(oldPath, newPath)
notify.success('Renamed')
setRenaming(null)
loadFiles(path)
} catch (e: any) {
notify.error(e)
}
}
const formatSize = (size: string) => {
const n = parseInt(size)
if (isNaN(n)) return size
if (n < 1024) return `${n} B`
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1048576).toFixed(1)} MB`
}
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">
<button onClick={goUp} className="btn-ghost p-1.5" title="Go up">
<ArrowLeft size={14} />
</button>
<input
className="input flex-1 text-xs mono"
value={pathInput}
onChange={e => setPathInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && navigatePath()}
placeholder="/sdcard"
/>
<button onClick={() => loadFiles(path)} disabled={loading} className="btn-ghost p-1.5">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
<div className="w-px h-5 bg-bg-border mx-1" />
<button onClick={handlePush} className="btn-ghost text-xs">
<Upload size={13} /> Push
</button>
<button onClick={handlePull} disabled={selected.size === 0} className="btn-ghost text-xs">
<Download size={13} /> Pull {selected.size > 0 ? `(${selected.size})` : ''}
</button>
<button onClick={() => setNewFolder(true)} className="btn-ghost text-xs">
<FolderPlus size={13} /> New Folder
</button>
<button onClick={handleDelete} disabled={selected.size === 0} className="btn-danger text-xs">
<Trash2 size={13} /> Delete {selected.size > 0 ? `(${selected.size})` : ''}
</button>
{loading && (
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
)}
</div>
{/* New folder input */}
{newFolder && (
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-raised">
<FolderPlus size={13} className="text-accent-green" />
<input
autoFocus
className="input flex-1 text-xs"
placeholder="Folder name"
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') { setNewFolder(false); setNewFolderName('') }
}}
/>
<button onClick={handleCreateFolder} className="btn-primary text-xs">Create</button>
<button onClick={() => { setNewFolder(false); setNewFolderName('') }} className="btn-ghost text-xs">Cancel</button>
</div>
)}
{/* File list header */}
<div className="grid grid-cols-[24px_1fr_80px_100px_120px] gap-2 px-4 py-1.5 border-b border-bg-border text-text-muted text-xs">
<input
type="checkbox"
checked={selected.size === files.length && files.length > 0}
onChange={selectAll}
className="accent-accent-green"
/>
<span>Name</span>
<span className="text-right">Size</span>
<span>Permissions</span>
<span>Modified</span>
</div>
{/* Files */}
<div className="flex-1 overflow-auto">
{loading && files.length === 0 && (
<div className="flex items-center justify-center h-32">
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && files.length === 0 && (
<div className="flex items-center justify-center h-32">
<p className="text-text-muted text-sm">Empty directory</p>
</div>
)}
{files.map(f => (
<div
key={f.name}
className={`
grid grid-cols-[24px_1fr_80px_100px_120px] gap-2 px-4 py-1.5
text-xs border-b border-bg-border/50 items-center
hover:bg-bg-raised transition-colors group
${selected.has(f.name) ? 'bg-accent-green/5' : ''}
`}
>
<input
type="checkbox"
checked={selected.has(f.name)}
onChange={() => toggleSelect(f.name)}
className="accent-accent-green"
/>
{/* Name */}
<div className="flex items-center gap-2 min-w-0">
{f.type === 'Directory'
? <FolderOpen size={13} className="text-accent-green shrink-0" />
: <File size={13} className="text-text-muted shrink-0" />
}
{renaming === f.name ? (
<input
autoFocus
className="input py-0 px-1 text-xs flex-1"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onBlur={handleRename}
onKeyDown={e => {
if (e.key === 'Enter') handleRename()
if (e.key === 'Escape') setRenaming(null)
}}
/>
) : (
<span
className={`truncate cursor-pointer ${f.type === 'Directory' ? 'text-text-primary' : 'text-text-secondary'}`}
onDoubleClick={() => navigate(f)}
>
{f.name}
</span>
)}
<button
onClick={() => startRename(f.name)}
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-secondary ml-auto shrink-0"
title="Rename"
>
<Edit3 size={11} />
</button>
</div>
<span className="text-right text-text-muted mono">
{f.type === 'Directory' ? '—' : formatSize(f.size)}
</span>
<span className="mono text-text-muted">{f.permissions}</span>
<span className="text-text-muted">{f.date} {f.time}</span>
</div>
))}
</div>
{/* Status bar */}
<div className="border-t border-bg-border px-4 py-1.5 flex items-center justify-between text-xs text-text-muted">
<span className="mono">{path}</span>
<span>{files.length} items{selected.size > 0 ? `, ${selected.size} selected` : ''}</span>
</div>
</div>
)
}

View file

@ -0,0 +1,195 @@
import { useState, useCallback } from 'react'
import { Zap, RefreshCw, AlertTriangle } from 'lucide-react'
import { GetFastbootDevices, FlashPartition, FastbootGetVar, SelectFileForFlash, SideloadPackage, SelectFileForInstall } from '../../lib/wails'
import { notify } from '../../lib/notify'
import type { Device } from '../../lib/types'
const PARTITIONS = [
'boot', 'recovery', 'system', 'vendor', 'userdata',
'dtbo', 'vbmeta', 'super', 'product', 'odm', 'radio'
]
export default function ViewFlasher() {
const [devices, setDevices] = useState<Device[]>([])
const [loadingDevices, setLoadingDevices] = useState(false)
const [partition, setPartition] = useState('boot')
const [selectedFile, setSelectedFile] = useState('')
const [flashing, setFlashing] = useState(false)
const [getvarKey, setGetvarKey] = useState('all')
const [getvarResult, setGetvarResult] = useState('')
const refreshDevices = useCallback(async () => {
setLoadingDevices(true)
try {
const devs = await GetFastbootDevices()
setDevices(devs || [])
} catch (e: any) {
notify.error(e)
setDevices([])
} finally {
setLoadingDevices(false)
}
}, [])
const handleSelectFile = async () => {
const path = await SelectFileForFlash()
if (path) setSelectedFile(path)
}
const handleFlash = async () => {
if (!selectedFile) { notify.error('Select an image file first'); return }
if (!confirm(`Flash ${selectedFile} to ${partition}?\n\nThis will overwrite the ${partition} partition. Make sure you know what you're doing.`)) return
setFlashing(true)
const id = notify.loading(`Flashing ${partition}...`)
try {
const out = await FlashPartition(partition, selectedFile)
notify.dismiss(id)
notify.success(out || `${partition} flashed successfully`)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
} finally {
setFlashing(false)
}
}
const handleGetvar = async () => {
try {
const out = await FastbootGetVar(getvarKey)
setGetvarResult(out)
} catch (e: any) {
setGetvarResult(String(e))
}
}
const handleSideload = async () => {
const path = await SelectFileForInstall()
if (!path) return
if (!confirm('Sideload requires device to be in sideload mode (adb sideload). Continue?')) return
const id = notify.loading('Sideloading...')
try {
const out = await SideloadPackage(path)
notify.dismiss(id)
notify.success(out || 'Sideload complete')
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
return (
<div className="p-4 space-y-4 h-full overflow-auto">
<h1 className="text-base font-medium text-text-primary">Flasher</h1>
{/* Warning */}
<div className="flex items-start gap-3 bg-warn/5 border border-warn/20 rounded-lg px-4 py-3">
<AlertTriangle size={16} className="text-warn shrink-0 mt-0.5" />
<div className="text-xs text-warn/90">
<p className="font-medium mb-1">Fastboot operations are destructive and irreversible.</p>
<p className="text-warn/70">Wrong partition or wrong image = bricked device. Only partition names in the safe list are permitted. Make sure your device bootloader is unlocked before flashing.</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{/* Fastboot devices */}
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="section-title">Fastboot Devices</p>
<button onClick={refreshDevices} disabled={loadingDevices} className="btn-ghost text-xs">
<RefreshCw size={12} className={loadingDevices ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{devices.length === 0 ? (
<p className="text-text-muted text-sm text-center py-4">
No fastboot devices. Boot device to bootloader with:<br />
<span className="mono text-xs text-text-secondary">adb reboot bootloader</span>
</p>
) : (
<div className="space-y-2">
{devices.map(d => (
<div key={d.serial} className="flex items-center justify-between bg-bg-raised rounded px-3 py-2">
<span className="mono text-xs text-text-primary">{d.serial}</span>
<span className="badge-green">{d.status}</span>
</div>
))}
</div>
)}
</div>
{/* Flash partition */}
<div className="card p-4 space-y-3">
<p className="section-title">Flash Partition</p>
<div className="space-y-2">
<div>
<label className="text-xs text-text-muted mb-1 block">Partition</label>
<select
className="input text-xs"
value={partition}
onChange={e => setPartition(e.target.value)}
>
{PARTITIONS.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Image file</label>
<div className="flex gap-2">
<input
className="input text-xs flex-1 mono"
value={selectedFile}
readOnly
placeholder="No file selected"
/>
<button onClick={handleSelectFile} className="btn-ghost text-xs shrink-0">Browse</button>
</div>
</div>
<button
onClick={handleFlash}
disabled={flashing || !selectedFile || devices.length === 0}
className="btn-danger w-full justify-center"
>
<Zap size={14} />
{flashing ? 'Flashing...' : `Flash ${partition}`}
</button>
{devices.length === 0 && (
<p className="text-text-muted text-xs text-center">Connect a device in fastboot mode to flash</p>
)}
</div>
</div>
{/* Getvar */}
<div className="card p-4 space-y-3">
<p className="section-title">Fastboot Getvar</p>
<div className="flex gap-2">
<input
className="input text-xs flex-1"
value={getvarKey}
onChange={e => setGetvarKey(e.target.value)}
placeholder="all"
/>
<button onClick={handleGetvar} className="btn-ghost text-xs">Query</button>
</div>
{getvarResult && (
<pre className="bg-bg-raised rounded p-3 text-xs mono text-text-secondary whitespace-pre-wrap max-h-48 overflow-auto">
{getvarResult}
</pre>
)}
</div>
{/* Sideload */}
<div className="card p-4 space-y-3">
<p className="section-title">ADB Sideload</p>
<p className="text-xs text-text-muted">
Sideload a ZIP (OTA update) to a device in sideload mode. Boot to recovery then select "Apply update from ADB".
</p>
<button onClick={handleSideload} className="btn-ghost w-full justify-center text-xs">
<Zap size={13} /> Select ZIP and Sideload
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,293 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Play, Square, Trash2, Download, Filter, ChevronDown } from 'lucide-react'
import { StartLogcat, StopLogcat, ClearLogcat } from '../../lib/wails'
import { notify } from '../../lib/notify'
import type { LogcatLine } from '../../lib/types'
// @ts-ignore
const { EventsOn, EventsOff } = window['runtime'] || {}
const LEVEL_COLORS: Record<string, string> = {
V: 'text-text-muted',
D: 'text-blue-400',
I: 'text-accent-green',
W: 'text-warn',
E: 'text-danger',
F: 'text-red-300',
S: 'text-text-muted',
}
const LEVEL_BG: Record<string, string> = {
E: 'bg-danger/5',
F: 'bg-red-900/20',
W: 'bg-warn/5',
}
const BUFFERS = ['main', 'radio', 'events', 'crash', 'all']
const MAX_LINES = 5000
export default function ViewLogcat() {
const [lines, setLines] = useState<LogcatLine[]>([])
const [running, setRunning] = useState(false)
const [filter, setFilter] = useState('')
const [tagFilter, setTagFilter] = useState('')
const [levelFilter, setLevelFilter] = useState<string[]>([])
const [buffer, setBuffer] = useState('main')
const [autoScroll, setAutoScroll] = useState(true)
const [search, setSearch] = useState('')
const [showFilters, setShowFilters] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Wails runtime event bridge
const useWailsEvent = (event: string, handler: (data: any) => void) => {
useEffect(() => {
// @ts-ignore
const cleanup = window['runtime']?.EventsOn?.(event, handler)
return () => {
// @ts-ignore
window['runtime']?.EventsOff?.(event)
cleanup?.()
}
}, [event, handler])
}
const handleLine = useCallback((line: LogcatLine) => {
setLines(prev => {
const next = [...prev, line]
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next
})
}, [])
const handleStopped = useCallback(() => {
setRunning(false)
}, [])
useEffect(() => {
// @ts-ignore
window['runtime']?.EventsOn?.('logcat:line', handleLine)
// @ts-ignore
window['runtime']?.EventsOn?.('logcat:stopped', handleStopped)
return () => {
// @ts-ignore
window['runtime']?.EventsOff?.('logcat:line')
// @ts-ignore
window['runtime']?.EventsOff?.('logcat:stopped')
}
}, [handleLine, handleStopped])
useEffect(() => {
if (autoScroll && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [lines, autoScroll])
const handleScroll = () => {
if (!containerRef.current) return
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
const atBottom = scrollHeight - scrollTop - clientHeight < 100
setAutoScroll(atBottom)
}
const start = async () => {
try {
setLines([])
await StartLogcat(filter, buffer)
setRunning(true)
} catch (e: any) {
notify.error(e)
}
}
const stop = () => {
StopLogcat()
setRunning(false)
}
const clear = async () => {
try {
await ClearLogcat()
setLines([])
notify.success('Logcat cleared')
} catch (e: any) {
notify.error(e)
}
}
const saveLog = () => {
const text = lines.map(l => l.raw).join('\n')
const blob = new Blob([text], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `logcat_${Date.now()}.txt`
a.click()
URL.revokeObjectURL(url)
}
const filteredLines = lines.filter(line => {
if (levelFilter.length > 0 && !levelFilter.includes(line.level)) return false
if (tagFilter && !line.tag.toLowerCase().includes(tagFilter.toLowerCase())) return false
if (search && !line.raw.toLowerCase().includes(search.toLowerCase())) return false
return true
})
const toggleLevel = (level: string) => {
setLevelFilter(prev =>
prev.includes(level) ? prev.filter(l => l !== level) : [...prev, level]
)
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Toolbar */}
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 shrink-0 flex-wrap">
{/* Buffer selector */}
<div className="relative">
<select
className="input text-xs w-24 py-1"
value={buffer}
onChange={e => setBuffer(e.target.value)}
disabled={running}
>
{BUFFERS.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
{/* Start/Stop */}
{!running ? (
<button onClick={start} className="btn-primary text-xs">
<Play size={12} /> Start
</button>
) : (
<button onClick={stop} className="btn-danger text-xs">
<Square size={12} /> Stop
</button>
)}
<button onClick={clear} className="btn-ghost text-xs">
<Trash2 size={12} /> Clear
</button>
<button onClick={saveLog} disabled={lines.length === 0} className="btn-ghost text-xs">
<Download size={12} /> Save
</button>
<div className="w-px h-5 bg-bg-border" />
{/* Search */}
<input
className="input text-xs w-48"
placeholder="Search output..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<button
onClick={() => setShowFilters(v => !v)}
className={`btn-ghost text-xs ${showFilters ? 'text-accent-green' : ''}`}
>
<Filter size={12} /> Filters
<ChevronDown size={10} className={showFilters ? 'rotate-180' : ''} />
</button>
<div className="flex-1" />
{/* Status */}
<div className="flex items-center gap-2 text-xs text-text-muted">
{running && <span className="status-dot status-dot-green" />}
<span>{filteredLines.length} lines{search || tagFilter || levelFilter.length > 0 ? ' (filtered)' : ''}</span>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} className="accent-accent-green" />
Auto-scroll
</label>
</div>
</div>
{/* Filter panel */}
{showFilters && (
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-4 bg-bg-raised shrink-0 flex-wrap">
{/* Level filter */}
<div className="flex items-center gap-1">
<span className="text-xs text-text-muted">Level:</span>
{['V', 'D', 'I', 'W', 'E', 'F'].map(level => (
<button
key={level}
onClick={() => toggleLevel(level)}
className={`w-6 h-6 rounded text-xs font-mono font-bold transition-colors ${
levelFilter.includes(level)
? 'bg-accent-green/20 text-accent-green'
: `${LEVEL_COLORS[level]} hover:bg-bg-raised`
}`}
>
{level}
</button>
))}
</div>
{/* Tag filter */}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">Tag:</span>
<input
className="input text-xs w-40"
placeholder="Filter by tag..."
value={tagFilter}
onChange={e => setTagFilter(e.target.value)}
/>
</div>
{/* ADB filter string */}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">ADB filter:</span>
<input
className="input text-xs w-48"
placeholder="e.g. ActivityManager:I *:S"
value={filter}
onChange={e => setFilter(e.target.value)}
disabled={running}
/>
</div>
{(levelFilter.length > 0 || tagFilter || search) && (
<button
onClick={() => { setLevelFilter([]); setTagFilter(''); setSearch('') }}
className="btn-ghost text-xs"
>
Clear filters
</button>
)}
</div>
)}
{/* Log output */}
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-auto bg-bg-base p-2 font-mono text-xs"
>
{filteredLines.length === 0 && (
<div className="flex items-center justify-center h-32 text-text-muted">
{running ? 'Waiting for log output...' : 'Press Start to begin streaming logcat'}
</div>
)}
{filteredLines.map((line, i) => (
<div
key={i}
className={`flex gap-2 px-1 py-0.5 rounded leading-relaxed hover:bg-bg-raised ${LEVEL_BG[line.level] || ''}`}
>
<span className="text-text-muted shrink-0 w-20 truncate">{line.time}</span>
<span className="text-text-muted shrink-0 w-10 truncate">{line.pid}</span>
<span className={`shrink-0 w-4 font-bold ${LEVEL_COLORS[line.level] || 'text-text-muted'}`}>
{line.level}
</span>
<span className="text-warn shrink-0 w-32 truncate">{line.tag}</span>
<span className={`flex-1 break-all ${LEVEL_COLORS[line.level] || 'text-text-secondary'}`}>
{line.message || line.raw}
</span>
</div>
))}
<div ref={bottomRef} />
</div>
</div>
)
}

View file

@ -0,0 +1,256 @@
import { useState, useCallback, useMemo } from 'react'
import { Package, RefreshCw, Search, Download, Trash2, Power, PowerOff, X } from 'lucide-react'
import {
ListPackages, UninstallMultiplePackages, DisableMultiplePackages,
EnableMultiplePackages, PullApk, ClearData, ForceStopPackage,
SelectFileForInstall, InstallPackage
} from '../../lib/wails'
import { notify } from '../../lib/notify'
import type { PackageInfo } from '../../lib/types'
type Filter = 'all' | 'user' | 'system'
export default function ViewPackages() {
const [packages, setPackages] = useState<PackageInfo[]>([])
const [loading, setLoading] = useState(false)
const [filter, setFilter] = useState<Filter>('user')
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [activeCtx, setActiveCtx] = useState<string | null>(null)
const load = useCallback(async (f: Filter) => {
setLoading(true)
setSelected(new Set())
setPackages([])
try {
const pkgs = await ListPackages(f)
setPackages(pkgs || [])
} catch (e: any) {
notify.error(e)
} finally {
setLoading(false)
}
}, [])
const filtered = useMemo(() =>
packages
.filter(p => p.packageName.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => a.packageName.localeCompare(b.packageName)),
[packages, search]
)
const toggleSelect = (pkg: string) => {
setSelected(prev => {
const next = new Set(prev)
next.has(pkg) ? next.delete(pkg) : next.add(pkg)
return next
})
}
const selectAll = () => {
if (selected.size === filtered.length) {
setSelected(new Set())
} else {
setSelected(new Set(filtered.map(p => p.packageName)))
}
}
const batchOp = async (label: string, op: (pkgs: string[]) => Promise<string>) => {
if (selected.size === 0) { notify.error('Select packages first'); return }
const id = notify.loading(`${label} ${selected.size} package(s)...`)
try {
const out = await op([...selected])
notify.dismiss(id)
notify.success(out)
load(filter)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const handleInstall = async () => {
const path = await SelectFileForInstall()
if (!path) return
const id = notify.loading('Installing APK...')
try {
const out = await InstallPackage(path)
notify.dismiss(id)
notify.success(out || 'Installed successfully')
load(filter)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const handlePullApk = async (pkg: string) => {
const id = notify.loading(`Pulling APK for ${pkg}...`)
try {
const out = await PullApk(pkg)
notify.dismiss(id)
notify.success(out)
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
setActiveCtx(null)
}
const handleClearData = async (pkg: string) => {
if (!confirm(`Clear data for ${pkg}?`)) return
try {
const out = await ClearData(pkg)
notify.success(out)
} catch (e: any) {
notify.error(e)
}
setActiveCtx(null)
}
const handleForceStop = async (pkg: string) => {
try {
const out = await ForceStopPackage(pkg)
notify.success(out)
} catch (e: any) {
notify.error(e)
}
setActiveCtx(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">
{/* Filter tabs */}
<div className="flex gap-1 bg-bg-raised rounded p-0.5">
{(['user', 'system', 'all'] as Filter[]).map(f => (
<button
key={f}
onClick={() => { setFilter(f); load(f) }}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
filter === f ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
}`}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
<button onClick={() => load(filter)} disabled={loading} className="btn-ghost text-xs">
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
{loading ? 'Loading...' : 'Load'}
</button>
<div className="relative flex-1 min-w-[180px]">
<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 packages..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="w-px h-5 bg-bg-border" />
<button onClick={handleInstall} className="btn-primary text-xs">
<Package size={13} /> Install APK
</button>
{selected.size > 0 && (
<>
<button onClick={() => batchOp('Uninstalling', UninstallMultiplePackages)} className="btn-danger text-xs">
<Trash2 size={13} /> Uninstall ({selected.size})
</button>
<button onClick={() => batchOp('Disabling', DisableMultiplePackages)} className="btn-warn text-xs">
<PowerOff size={13} /> Disable ({selected.size})
</button>
<button onClick={() => batchOp('Enabling', EnableMultiplePackages)} className="btn-ghost text-xs">
<Power size={13} /> Enable ({selected.size})
</button>
</>
)}
</div>
{/* Package list header */}
<div className="grid grid-cols-[24px_1fr_80px_100px] 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}
className="accent-accent-green"
/>
<span>Package</span>
<span>State</span>
<span>Actions</span>
</div>
{/* Package list */}
<div className="flex-1 overflow-auto relative">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && packages.length === 0 && (
<div className="flex flex-col items-center justify-center h-32 gap-2">
<Package size={24} className="text-text-muted" />
<p className="text-text-muted text-sm">Click "Load" to fetch packages</p>
</div>
)}
{filtered.map(pkg => (
<div
key={pkg.packageName}
className={`
grid grid-cols-[24px_1fr_80px_100px] gap-2 px-4 py-2
border-b border-bg-border/50 items-center text-xs
hover:bg-bg-raised transition-colors
${selected.has(pkg.packageName) ? 'bg-accent-green/5' : ''}
`}
>
<input
type="checkbox"
checked={selected.has(pkg.packageName)}
onChange={() => toggleSelect(pkg.packageName)}
className="accent-accent-green"
/>
<span className="mono text-text-secondary truncate">{pkg.packageName}</span>
<span>
<span className={pkg.isEnabled ? 'badge-green' : 'badge-red'}>
{pkg.isEnabled ? 'enabled' : 'disabled'}
</span>
</span>
<div className="relative">
<button
onClick={() => setActiveCtx(activeCtx === pkg.packageName ? null : pkg.packageName)}
className="btn-ghost text-xs py-0.5 px-2"
>
···
</button>
{activeCtx === pkg.packageName && (
<div className="absolute right-0 top-full mt-1 bg-bg-raised border border-bg-border rounded shadow-xl z-10 min-w-[160px]">
<button onClick={() => handlePullApk(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-text-secondary hover:text-text-primary hover:bg-bg-surface">
<Download size={12} /> Pull APK
</button>
<button onClick={() => handleClearData(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-warn hover:bg-bg-surface">
<X size={12} /> Clear Data
</button>
<button onClick={() => handleForceStop(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-danger hover:bg-bg-surface">
<PowerOff size={12} /> Force Stop
</button>
</div>
)}
</div>
</div>
))}
</div>
{/* Status bar */}
<div className="border-t border-bg-border px-4 py-1.5 flex items-center justify-between text-xs text-text-muted">
<span>{filtered.length} packages{search ? ` matching "${search}"` : ''}</span>
{selected.size > 0 && <span>{selected.size} selected</span>}
</div>
</div>
)
}

View file

@ -0,0 +1,582 @@
import { useState, useRef } from 'react'
import { Zap, AlertTriangle, FolderOpen, Check, X, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react'
import { GetFastbootDevices, Reboot } from '../../lib/wails'
import { notify } from '../../lib/notify'
import type { Device } from '../../lib/types'
type StepStatus = 'waiting' | 'running' | 'done' | 'error' | 'skipped'
interface FlashStep {
id: string
label: string
description: string
status: StepStatus
output?: string
}
interface FlashOptions {
wipeData: boolean
disableVerity: boolean
disableVerification: boolean
force: boolean
flashBothSlots: boolean
}
const DEFAULT_OPTS: FlashOptions = {
wipeData: true,
disableVerity: false,
disableVerification: false,
force: false,
flashBothSlots: false,
}
// Parse flash-all.sh lines into structured steps
interface ParsedStep {
type: 'flash' | 'reboot' | 'update' | 'sleep'
partition?: string
file?: string
wipe?: boolean
}
function parseFlashAllSh(content: string): ParsedStep[] {
const steps: ParsedStep[] = []
for (const raw of content.split('\n')) {
const line = raw.trim()
if (!line || line.startsWith('#') || line.startsWith('#!/')) continue
if (!line.startsWith('fastboot')) continue
const parts = line.split(/\s+/)
if (parts[1] === 'flash' && parts[2] && parts[3]) {
steps.push({ type: 'flash', partition: parts[2], file: parts[3] })
} else if (parts[1] === 'reboot-bootloader') {
steps.push({ type: 'reboot' })
} else if (parts[1] === '-w' && parts[2] === 'update') {
steps.push({ type: 'update', wipe: true, file: parts[3] })
} else if (parts[1] === 'update') {
steps.push({ type: 'update', wipe: false, file: parts[2] })
}
}
return steps
}
function buildSteps(parsed: ParsedStep[], opts: FlashOptions): FlashStep[] {
const steps: FlashStep[] = []
let rebootCount = 0
for (const p of parsed) {
if (p.type === 'flash') {
const flags: string[] = []
if (opts.disableVerity) flags.push('--disable-verity')
if (opts.disableVerification) flags.push('--disable-verification')
if (opts.force) flags.push('--force')
if (opts.flashBothSlots) flags.push('--slot all')
const flagStr = flags.length ? flags.join(' ') + ' ' : ''
steps.push({
id: `flash_${p.partition}_${steps.length}`,
label: `Flash ${p.partition}`,
description: `fastboot ${flagStr}flash ${p.partition} ${p.file}`,
status: 'waiting',
})
} else if (p.type === 'reboot') {
rebootCount++
steps.push({
id: `reboot_${rebootCount}`,
label: 'Reboot to bootloader',
description: 'fastboot reboot-bootloader',
status: 'waiting',
})
} else if (p.type === 'update') {
const flags: string[] = []
if (opts.disableVerity) flags.push('--disable-verity')
if (opts.disableVerification) flags.push('--disable-verification')
if (opts.force) flags.push('--force')
const wipeFlag = opts.wipeData ? '-w ' : ''
const flagStr = flags.length ? flags.join(' ') + ' ' : ''
steps.push({
id: 'update_image',
label: opts.wipeData ? 'Flash image zip (wipe data)' : 'Flash image zip',
description: `fastboot ${flagStr}${wipeFlag}update ${p.file}`,
status: 'waiting',
})
}
}
steps.push({
id: 'reboot_system',
label: 'Reboot to system',
description: 'fastboot reboot',
status: 'waiting',
})
return steps
}
export default function ViewPixelFlasher() {
const [devices, setDevices] = useState<Device[]>([])
const [loadingDevices, setLoading] = useState(false)
const [factoryZip, setFactoryZip] = useState('')
const [opts, setOpts] = useState<FlashOptions>(DEFAULT_OPTS)
const [parsedSteps, setParsedSteps] = useState<ParsedStep[]>([])
const [steps, setSteps] = useState<FlashStep[]>([])
const [flashing, setFlashing] = useState(false)
const [done, setDone] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [log, setLog] = useState<string[]>([])
const logRef = useRef<HTMLDivElement>(null)
const addLog = (msg: string) => {
setLog(prev => [...prev, `${new Date().toLocaleTimeString()} ${msg}`])
setTimeout(() => logRef.current?.scrollTo(0, logRef.current.scrollHeight), 50)
}
const updateStep = (id: string, status: StepStatus, output?: string) =>
setSteps(prev => prev.map(s => s.id === id ? { ...s, status, output } : s))
const refreshDevices = async () => {
setLoading(true)
try {
const devs = await GetFastbootDevices()
setDevices(devs || [])
} catch (e: any) {
notify.error(e)
} finally {
setLoading(false)
}
}
const handleSelectZip = async () => {
try {
// @ts-ignore
const path: string = await window['go']['main']['App']['SelectFileForFlash']()
if (!path) return
setFactoryZip(path)
setSteps([])
setParsedSteps([])
setLog([])
setDone(false)
// Read flash-all.sh from inside the zip using Go backend
try {
// @ts-ignore
const content: string = await window['go']['main']['App']['ReadFileFromZip'](path, 'flash-all.sh')
if (content) {
const parsed = parseFlashAllSh(content)
setParsedSteps(parsed)
setSteps(buildSteps(parsed, opts))
addLog(`Parsed flash-all.sh: ${parsed.length} steps found`)
} else {
addLog('Warning: flash-all.sh not found in zip — is this a valid Pixel factory image?')
}
} catch {
addLog('Could not read flash-all.sh from zip. Make sure this is an extracted factory image folder or valid zip.')
}
} catch (e: any) {
notify.error('Could not open file dialog')
}
}
const updateOpts = (newOpts: FlashOptions) => {
setOpts(newOpts)
if (parsedSteps.length > 0) {
setSteps(buildSteps(parsedSteps, newOpts))
}
}
const runFastboot = async (args: string): Promise<string> => {
// @ts-ignore
return await window['go']['main']['App']['RunAdbHostCommand']('fastboot ' + args) as string
}
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
const waitForFastboot = async (timeoutMs = 45000): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const devs = await GetFastbootDevices()
if (devs && devs.length > 0) return true
} catch {}
await sleep(2000)
}
return false
}
const startFlash = async () => {
if (!factoryZip) { notify.error('Select a factory image zip first'); return }
if (devices.length === 0) { notify.error('No fastboot device detected — boot to bootloader first'); return }
if (parsedSteps.length === 0) { notify.error('No flash steps parsed — check the factory image zip'); return }
const flags: string[] = []
if (opts.disableVerity) flags.push('--disable-verity')
if (opts.disableVerification) flags.push('--disable-verification')
if (opts.force) flags.push('--force')
if (opts.flashBothSlots) flags.push('--slot all')
const flagSummary = flags.length ? flags.join(' ') : 'none'
if (!confirm(
`Flash Pixel factory image?\n\n` +
`File: ${factoryZip}\n` +
`Wipe userdata: ${opts.wipeData ? 'YES — all data erased' : 'No'}\n` +
`Extra flags: ${flagSummary}\n\n` +
`This is irreversible. Confirm you have the correct image for your device.`
)) return
const freshSteps = buildSteps(parsedSteps, opts)
setSteps(freshSteps)
setLog([])
setFlashing(true)
setDone(false)
// Extract zip directory for file references
const zipDir = factoryZip.substring(0, factoryZip.lastIndexOf('/'))
try {
for (const step of freshSteps) {
updateStep(step.id, 'running')
addLog(`${step.description}`)
if (step.id === 'reboot_system') {
try {
await runFastboot('reboot')
addLog('Device rebooting to system...')
updateStep(step.id, 'done')
} catch (e: any) {
updateStep(step.id, 'error', String(e))
addLog(`ERROR: ${e}`)
}
continue
}
if (step.id.startsWith('reboot_')) {
try {
await runFastboot('reboot-bootloader')
addLog('Waiting for device to return to fastboot...')
await sleep(5000)
const back = await waitForFastboot()
if (!back) throw new Error('Device did not return to fastboot within 45s')
addLog('Device back in fastboot')
updateStep(step.id, 'done')
} catch (e: any) {
updateStep(step.id, 'error', String(e))
addLog(`ERROR: ${e}`)
setFlashing(false)
return
}
continue
}
if (step.id === 'update_image') {
// fastboot update uses the image-*.zip inside the factory zip
// The file path in flash-all.sh is relative — resolve against zip dir
const parsed = parsedSteps.find(p => p.type === 'update')
const imageZipName = parsed?.file || ''
const imageZipPath = `${zipDir}/${imageZipName}`
const flags2: string[] = []
if (opts.disableVerity) flags2.push('--disable-verity')
if (opts.disableVerification) flags2.push('--disable-verification')
if (opts.force) flags2.push('--force')
const wipeFlag = opts.wipeData ? '-w ' : ''
const flagStr = flags2.length ? flags2.join(' ') + ' ' : ''
const cmd = `${flagStr}${wipeFlag}update ${imageZipPath}`
try {
const out = await runFastboot(cmd)
addLog(out || 'Image flashed')
updateStep(step.id, 'done', out)
} catch (e: any) {
updateStep(step.id, 'error', String(e))
addLog(`ERROR: ${e}`)
setFlashing(false)
return
}
continue
}
// Regular flash step
if (step.id.startsWith('flash_')) {
const parsed = parsedSteps.find(p => p.type === 'flash' &&
step.label === `Flash ${p.partition}`)
const fileName = parsed?.file || ''
const filePath = `${zipDir}/${fileName}`
const flags2: string[] = []
if (opts.disableVerity) flags2.push('--disable-verity')
if (opts.disableVerification) flags2.push('--disable-verification')
if (opts.force) flags2.push('--force')
if (opts.flashBothSlots) flags2.push('--slot all')
const flagStr = flags2.length ? flags2.join(' ') + ' ' : ''
const partition = parsed?.partition || ''
const cmd = `${flagStr}flash ${partition} ${filePath}`
try {
const out = await runFastboot(cmd)
addLog(out || `${partition} flashed`)
updateStep(step.id, 'done', out)
} catch (e: any) {
// Some partitions may not exist on all devices — skip with warning
addLog(`WARN: ${partition}: ${e} — skipping`)
updateStep(step.id, 'skipped', String(e))
}
continue
}
}
addLog('✓ Flash complete!')
setDone(true)
notify.success('Flash complete — device is rebooting')
} catch (e: any) {
addLog(`FATAL: ${e}`)
notify.error(`Flash failed: ${e}`)
} finally {
setFlashing(false)
}
}
const statusIcon = (s: StepStatus) => {
switch (s) {
case 'done': return <Check size={13} className="text-accent-green" />
case 'error': return <X size={13} className="text-danger" />
case 'skipped': return <span className="text-text-muted text-xs font-mono"></span>
case 'running': return <div className="w-3 h-3 border border-accent-green border-t-transparent rounded-full animate-spin" />
default: return <div className="w-3 h-3 rounded-full border border-bg-border" />
}
}
return (
<div className="flex flex-col h-full overflow-auto p-4 space-y-4">
<h1 className="text-base font-medium text-text-primary">Pixel Factory Flash</h1>
{/* Warning */}
<div className="flex items-start gap-3 bg-danger/5 border border-danger/20 rounded-lg px-4 py-3 shrink-0">
<AlertTriangle size={16} className="text-danger shrink-0 mt-0.5" />
<div className="text-xs text-danger/90 space-y-1">
<p className="font-medium">This will completely overwrite your device firmware.</p>
<p className="text-danger/70">
Download the correct factory image for your Pixel from{' '}
<span className="mono">developers.google.com/android/images</span>.
Extract the outer zip first then select the inner factory zip (e.g.{' '}
<span className="mono">cheetah-ap2a.240905.003-factory-*.zip</span>).
Bootloader must be unlocked.
</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{/* Left: config */}
<div className="space-y-4">
{/* Device */}
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="section-title">Fastboot Device</p>
<button onClick={refreshDevices} disabled={loadingDevices} className="btn-ghost text-xs">
<RefreshCw size={11} className={loadingDevices ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{devices.length === 0 ? (
<p className="text-xs text-text-muted bg-bg-raised rounded px-3 py-2">
No device in fastboot run: <span className="mono">adb reboot bootloader</span>
</p>
) : (
<div className="bg-bg-raised rounded px-3 py-2 mono text-xs text-accent-green">
{devices[0].serial} {devices[0].status}
</div>
)}
</div>
{/* Factory image */}
<div className="card p-4 space-y-3">
<p className="section-title">Factory Image Zip</p>
<p className="text-xs text-text-muted">
Extract the outer zip from Google, then select the inner <span className="mono">device-build-factory-*.zip</span>
</p>
<div className="flex gap-2">
<input
className="input text-xs flex-1 mono"
value={factoryZip}
readOnly
placeholder="Select factory image zip..."
/>
<button onClick={handleSelectZip} className="btn-ghost text-xs shrink-0">
<FolderOpen size={13} /> Browse
</button>
</div>
{parsedSteps.length > 0 && (
<p className="text-xs text-accent-green flex items-center gap-1.5">
<Check size={12} /> flash-all.sh parsed {parsedSteps.length} steps detected
</p>
)}
</div>
{/* Flash options */}
<div className="card p-4 space-y-3">
<p className="section-title">Flash Options</p>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={opts.wipeData}
onChange={e => updateOpts({ ...opts, wipeData: e.target.checked })}
className="accent-accent-green mt-0.5" />
<div>
<p className="text-xs text-text-primary">Wipe userdata <span className="badge-red ml-1">Recommended for bug hunting</span></p>
<p className="text-xs text-text-muted">Adds <span className="mono">-w</span> to fastboot update clean state</p>
</div>
</label>
{/* Advanced toggle */}
<button
onClick={() => setShowAdvanced(v => !v)}
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary transition-colors"
>
{showAdvanced ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Advanced options
</button>
{showAdvanced && (
<div className="space-y-2 pl-2 border-l border-bg-border">
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={opts.disableVerity}
onChange={e => updateOpts({ ...opts, disableVerity: e.target.checked })}
className="accent-accent-green mt-0.5" />
<div>
<p className="text-xs text-text-primary">Disable verity <span className="badge-yellow ml-1">For Magisk/root</span></p>
<p className="text-xs text-text-muted">Adds <span className="mono">--disable-verity</span> to all flash commands</p>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={opts.disableVerification}
onChange={e => updateOpts({ ...opts, disableVerification: e.target.checked })}
className="accent-accent-green mt-0.5" />
<div>
<p className="text-xs text-text-primary">Disable verification <span className="badge-yellow ml-1">For Magisk/root</span></p>
<p className="text-xs text-text-muted">Adds <span className="mono">--disable-verification</span> usually paired with disable verity</p>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={opts.force}
onChange={e => updateOpts({ ...opts, force: e.target.checked })}
className="accent-accent-green mt-0.5" />
<div>
<p className="text-xs text-text-primary">Force flash <span className="badge-red ml-1">Dangerous</span></p>
<p className="text-xs text-text-muted">
Adds <span className="mono">--force</span> bypasses anti-rollback protection.
Use only when downgrading. Can brick if used incorrectly.
</p>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={opts.flashBothSlots}
onChange={e => updateOpts({ ...opts, flashBothSlots: e.target.checked })}
className="accent-accent-green mt-0.5" />
<div>
<p className="text-xs text-text-primary">Flash both slots <span className="badge-yellow ml-1">A/B devices</span></p>
<p className="text-xs text-text-muted">Adds <span className="mono">--slot all</span> flashes both A and B slots</p>
</div>
</label>
</div>
)}
{/* Command preview */}
{parsedSteps.length > 0 && (
<div className="bg-bg-base rounded p-2 border border-bg-border">
<p className="text-xs text-text-muted mb-1">Command preview:</p>
{(() => {
const flags: string[] = []
if (opts.disableVerity) flags.push('--disable-verity')
if (opts.disableVerification) flags.push('--disable-verification')
if (opts.force) flags.push('--force')
const flagStr = flags.length ? flags.join(' ') + ' ' : ''
const wipe = opts.wipeData ? '-w ' : ''
return (
<p className="mono text-xs text-accent-green break-all">
fastboot {flagStr}flash bootloader bootloader-*.img<br/>
fastboot reboot-bootloader<br/>
fastboot {flagStr}flash radio radio-*.img<br/>
fastboot reboot-bootloader<br/>
fastboot {flagStr}{wipe}update image-*.zip
</p>
)
})()}
</div>
)}
<button
onClick={startFlash}
disabled={flashing || !factoryZip || devices.length === 0 || parsedSteps.length === 0}
className="btn-danger w-full justify-center"
>
<Zap size={14} />
{flashing ? 'Flashing...' : 'Start Flash'}
</button>
{done && (
<div className="flex items-center gap-2 text-accent-green text-xs bg-accent-green/10 rounded px-3 py-2">
<Check size={13} /> Flash complete device rebooting
</div>
)}
</div>
</div>
{/* Right: steps + log */}
<div className="space-y-4">
{/* Steps */}
<div className="card p-4 space-y-2">
<p className="section-title">
Flash Steps {steps.length > 0 ? `(${steps.length})` : '— select a factory zip to populate'}
</p>
{steps.length === 0 && (
<p className="text-xs text-text-muted text-center py-4">
Select a factory image zip to see the flash sequence
</p>
)}
<div className="space-y-1 max-h-80 overflow-auto">
{steps.map(step => (
<div
key={step.id}
className={`flex items-start gap-3 px-3 py-2 rounded transition-colors ${
step.status === 'running' ? 'bg-accent-green/5 border border-accent-green/20' :
step.status === 'error' ? 'bg-danger/5 border border-danger/20' :
step.status === 'done' ? 'bg-bg-raised' : ''
}`}
>
<div className="mt-0.5 shrink-0">{statusIcon(step.status)}</div>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium ${
step.status === 'running' ? 'text-accent-green' :
step.status === 'error' ? 'text-danger' :
step.status === 'done' ? 'text-text-primary' : 'text-text-muted'
}`}>
{step.label}
</p>
<p className="mono text-xs text-text-muted truncate">{step.description}</p>
{step.output && step.status === 'error' && (
<p className="mono text-xs text-danger mt-0.5 truncate">{step.output}</p>
)}
</div>
</div>
))}
</div>
</div>
{/* Log */}
{log.length > 0 && (
<div className="card p-4 space-y-2">
<p className="section-title">Flash Log</p>
<div
ref={logRef}
className="bg-bg-base rounded p-3 h-48 overflow-auto mono text-xs space-y-0.5 border border-bg-border"
>
{log.map((line, i) => (
<div key={i} className={
line.includes('ERROR') || line.includes('FATAL') ? 'text-danger' :
line.includes('WARN') ? 'text-warn' :
line.includes('✓') ? 'text-accent-green' :
'text-text-secondary'
}>
{line}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,241 @@
import { useState, useEffect, useMemo } from 'react'
import { RefreshCw, Search, Edit3, Check, X, ChevronDown, ChevronRight } from 'lucide-react'
import { GetAllProps, SetProp } from '../../lib/wails'
import { notify } from '../../lib/notify'
import type { PropEntry } from '../../lib/types'
export default function ViewProps() {
const [props, setProps] = useState<PropEntry[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [catFilter, setCatFilter] = useState('All')
const [editing, setEditing] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const [openCats, setOpenCats] = useState<Set<string>>(new Set(['Build', 'Product', 'Boot']))
const load = async () => {
setLoading(true)
try {
const data = await GetAllProps()
setProps(data || [])
} catch (e: any) {
notify.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
const categories = useMemo(() => {
const cats = [...new Set(props.map(p => p.category))].sort()
return ['All', ...cats]
}, [props])
const filtered = useMemo(() =>
props.filter(p => {
if (catFilter !== 'All' && p.category !== catFilter) return false
if (search) {
const q = search.toLowerCase()
return p.key.toLowerCase().includes(q) || p.value.toLowerCase().includes(q)
}
return true
}),
[props, search, catFilter]
)
const grouped = useMemo(() => {
const groups: Record<string, PropEntry[]> = {}
for (const p of filtered) {
if (!groups[p.category]) groups[p.category] = []
groups[p.category].push(p)
}
return groups
}, [filtered])
const toggleCat = (cat: string) => setOpenCats(prev => {
const next = new Set(prev)
next.has(cat) ? next.delete(cat) : next.add(cat)
return next
})
const startEdit = (prop: PropEntry) => {
setEditing(prop.key)
setEditValue(prop.value)
}
const saveEdit = async (key: string) => {
if (!editing) return
const id = notify.loading(`Setting ${key}...`)
try {
const out = await SetProp(key, editValue)
notify.dismiss(id)
notify.success(out)
setEditing(null)
// Update local state immediately
setProps(prev => prev.map(p => p.key === key ? { ...p, value: editValue } : p))
} catch (e: any) {
notify.dismiss(id)
notify.error(e)
}
}
const cancelEdit = () => {
setEditing(null)
setEditValue('')
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Toolbar */}
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 shrink-0 flex-wrap">
<div className="relative flex-1 min-w-0">
<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="Search properties..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<select
className="input text-xs w-36 py-1 shrink-0"
value={catFilter}
onChange={e => setCatFilter(e.target.value)}
>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<button onClick={load} disabled={loading} className="btn-ghost text-xs shrink-0">
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
{loading ? 'Loading...' : 'Refresh'}
</button>
<span className="text-xs text-text-muted shrink-0">
{filtered.length} / {props.length} props
</span>
</div>
{/* Props list */}
<div className="flex-1 overflow-auto">
{loading && props.length === 0 && (
<div className="flex items-center justify-center h-32">
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && props.length === 0 && (
<div className="flex items-center justify-center h-32 text-text-muted text-sm">
Connect a device and click Refresh
</div>
)}
{catFilter === 'All'
? Object.entries(grouped).map(([cat, catProps]) => (
<div key={cat} className="border-b border-bg-border/50">
<button
onClick={() => toggleCat(cat)}
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-bg-raised transition-colors"
>
{openCats.has(cat)
? <ChevronDown size={12} className="text-accent-green shrink-0" />
: <ChevronRight size={12} className="text-text-muted shrink-0" />
}
<span className="text-xs font-medium text-text-primary">{cat}</span>
<span className="text-xs text-text-muted">{catProps.length}</span>
</button>
{openCats.has(cat) && catProps.map(p => (
<PropRow
key={p.key}
prop={p}
editing={editing === p.key}
editValue={editValue}
onEdit={startEdit}
onSave={saveEdit}
onCancel={cancelEdit}
onEditValueChange={setEditValue}
/>
))}
</div>
))
: filtered.map(p => (
<PropRow
key={p.key}
prop={p}
editing={editing === p.key}
editValue={editValue}
onEdit={startEdit}
onSave={saveEdit}
onCancel={cancelEdit}
onEditValueChange={setEditValue}
/>
))
}
</div>
</div>
)
}
interface PropRowProps {
prop: PropEntry
editing: boolean
editValue: string
onEdit: (p: PropEntry) => void
onSave: (key: string) => void
onCancel: () => void
onEditValueChange: (v: string) => void
}
function PropRow({ prop, editing, editValue, onEdit, onSave, onCancel, onEditValueChange }: PropRowProps) {
const isReadOnly = prop.key.startsWith('ro.')
return (
<div className={`flex items-center gap-3 px-4 py-1.5 border-t border-bg-border/30 group hover:bg-bg-raised transition-colors ${editing ? 'bg-accent-green/5' : ''}`}>
<div className="flex-1 min-w-0 grid grid-cols-2 gap-4">
<span className="mono text-xs text-text-secondary truncate" title={prop.key}>
{prop.key}
</span>
{editing ? (
<input
autoFocus
className="input text-xs py-0.5"
value={editValue}
onChange={e => onEditValueChange(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') onSave(prop.key)
if (e.key === 'Escape') onCancel()
}}
/>
) : (
<span className="mono text-xs text-text-primary truncate" title={prop.value}>
{prop.value || '(empty)'}
</span>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
{isReadOnly && <span className="badge-gray text-xs">ro</span>}
{editing ? (
<>
<button onClick={() => onSave(prop.key)} className="text-accent-green hover:text-accent-dim">
<Check size={13} />
</button>
<button onClick={onCancel} className="text-text-muted hover:text-danger">
<X size={13} />
</button>
</>
) : (
<button
onClick={() => onEdit(prop)}
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-secondary transition-opacity"
title={isReadOnly ? 'Read-only (may require root)' : 'Edit value'}
>
<Edit3 size={11} />
</button>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,142 @@
import { useState, useEffect } from 'react'
import { Shield, RefreshCw, Check, AlertTriangle } from 'lucide-react'
import { GetBinaryInfo, SetAdbPath, SetFastbootPath } from '../../lib/wails'
import { notify } from '../../lib/notify'
export default function ViewSettings() {
const [binaryInfo, setBinaryInfo] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
const [adbPath, setAdbPath] = useState('')
const [fastbootPath, setFastbootPath] = useState('')
const loadBinaryInfo = async () => {
setLoading(true)
try {
const info = await GetBinaryInfo()
setBinaryInfo(info)
} catch (e: any) {
notify.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => { loadBinaryInfo() }, [])
const handleSetAdb = async () => {
try {
await SetAdbPath(adbPath)
notify.success('ADB path updated')
loadBinaryInfo()
} catch (e: any) {
notify.error(e)
}
}
const handleSetFastboot = async () => {
try {
await SetFastbootPath(fastbootPath)
notify.success('Fastboot path updated')
loadBinaryInfo()
} catch (e: any) {
notify.error(e)
}
}
return (
<div className="p-4 space-y-4 h-full overflow-auto max-w-2xl">
<h1 className="text-base font-medium text-text-primary">Settings</h1>
{/* Binary trust section */}
<div className="card p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield size={14} className="text-accent-green" />
<p className="section-title">Binary Verification</p>
</div>
<button onClick={loadBinaryInfo} disabled={loading} className="btn-ghost text-xs">
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
<div className="bg-accent-green/5 border border-accent-green/20 rounded p-3 text-xs text-text-secondary space-y-1">
<p className="text-accent-green font-medium flex items-center gap-1.5">
<Check size={12} /> ATK uses YOUR system ADB/Fastboot no bundled binaries
</p>
<p>
By default ATK uses whatever <span className="mono">adb</span> and <span className="mono">fastboot</span> are on your PATH (installed via <span className="mono">apt install adb fastboot</span> or Android SDK). You can verify the SHA-256 hashes below against Google's published platform-tools checksums.
</p>
</div>
{/* Binary info display */}
{Object.entries(binaryInfo).map(([name, info]) => (
<div key={name} className="space-y-1">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">{name}</p>
<pre className="bg-bg-raised rounded p-3 text-xs mono text-text-muted whitespace-pre-wrap break-all border border-bg-border">
{info}
</pre>
</div>
))}
<div className="text-xs text-text-muted space-y-1">
<p className="flex items-center gap-1.5">
<AlertTriangle size={11} className="text-warn shrink-0" />
Verify SHA-256 against Google's official platform-tools:
</p>
<p className="mono pl-4">https://developer.android.com/tools/releases/platform-tools</p>
</div>
</div>
{/* Custom binary paths */}
<div className="card p-4 space-y-4">
<p className="section-title">Custom Binary Paths</p>
<p className="text-xs text-text-muted">
Override the auto-detected binary paths. Leave empty to use system PATH. Changes take effect immediately.
</p>
<div className="space-y-3">
<div>
<label className="text-xs text-text-muted mb-1.5 block">ADB binary path</label>
<div className="flex gap-2">
<input
className="input text-xs flex-1 mono"
placeholder="/usr/bin/adb (or leave blank for auto-detect)"
value={adbPath}
onChange={e => setAdbPath(e.target.value)}
/>
<button onClick={handleSetAdb} disabled={!adbPath} className="btn-ghost text-xs shrink-0">
Set
</button>
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block">Fastboot binary path</label>
<div className="flex gap-2">
<input
className="input text-xs flex-1 mono"
placeholder="/usr/bin/fastboot (or leave blank for auto-detect)"
value={fastbootPath}
onChange={e => setFastbootPath(e.target.value)}
/>
<button onClick={handleSetFastboot} disabled={!fastbootPath} className="btn-ghost text-xs shrink-0">
Set
</button>
</div>
</div>
</div>
</div>
{/* About */}
<div className="card p-4 space-y-2">
<p className="section-title">About</p>
<div className="text-xs text-text-muted space-y-1">
<p>ATK (Android Toolkit) an all-in-one ADB GUI for Android power users and bug hunters</p>
<p>All commands use discrete argument passing no shell string building, no injection vectors</p>
<p>Built with Wails v2 (Go + React) · github.com/jegly/ATK</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react'
import { Terminal, Trash2, ChevronRight } from 'lucide-react'
import { RunShellCommand, RunAdbHostCommand } from '../../lib/wails'
interface HistoryEntry {
cmd: string
output: string
error?: boolean
mode: 'shell' | 'adb'
}
export default function ViewShell() {
const [history, setHistory] = useState<HistoryEntry[]>([
{ cmd: '', output: 'ADBKit Shell — commands run via adb shell (no pipes/redirects — args are split directly, no shell injection)\nSwitch to "adb" mode to run adb host commands (e.g. adb devices, adb logcat)', mode: 'shell' }
])
const [input, setInput] = useState('')
const [mode, setMode] = useState<'shell' | 'adb'>('shell')
const [loading, setLoading] = useState(false)
const [cmdHistory, setCmdHistory] = useState<string[]>([])
const [historyIdx, setHistoryIdx] = useState(-1)
const bottomRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [history])
const run = async () => {
const cmd = input.trim()
if (!cmd) return
setCmdHistory(prev => [cmd, ...prev.slice(0, 99)])
setHistoryIdx(-1)
setInput('')
setLoading(true)
try {
let output: string
if (mode === 'shell') {
output = await RunShellCommand(cmd)
} else {
output = await RunAdbHostCommand(cmd)
}
setHistory(prev => [...prev, { cmd, output: output || '(no output)', mode }])
} catch (e: any) {
setHistory(prev => [...prev, { cmd, output: String(e), error: true, mode }])
} finally {
setLoading(false)
}
}
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
run()
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
const next = Math.min(historyIdx + 1, cmdHistory.length - 1)
setHistoryIdx(next)
setInput(cmdHistory[next] || '')
}
if (e.key === 'ArrowDown') {
e.preventDefault()
const next = Math.max(historyIdx - 1, -1)
setHistoryIdx(next)
setInput(next === -1 ? '' : cmdHistory[next])
}
}
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-3 shrink-0">
<Terminal size={14} className="text-accent-green" />
<span className="text-xs text-text-muted">Mode:</span>
<div className="flex gap-1 bg-bg-raised rounded p-0.5">
{(['shell', 'adb'] as const).map(m => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${
mode === m ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
}`}
>
{m === 'shell' ? 'adb shell' : 'adb host'}
</button>
))}
</div>
<div className="flex-1" />
<button
onClick={() => setHistory([{ cmd: '', output: 'Terminal cleared.', mode }])}
className="btn-ghost text-xs"
>
<Trash2 size={12} /> Clear
</button>
</div>
{/* Output */}
<div
className="flex-1 overflow-auto p-4 font-mono text-xs space-y-3 bg-bg-base cursor-text"
onClick={() => inputRef.current?.focus()}
>
{history.map((entry, i) => (
<div key={i}>
{entry.cmd && (
<div className="flex items-center gap-2 text-accent-green mb-1">
<span className="text-text-muted">[{entry.mode}]$</span>
<span>{entry.cmd}</span>
</div>
)}
<pre
className={`whitespace-pre-wrap break-words leading-relaxed ${
entry.error ? 'text-danger' : 'text-text-secondary'
}`}
>
{entry.output}
</pre>
</div>
))}
{loading && (
<div className="flex items-center gap-2 text-text-muted">
<span className="animate-pulse"></span>
<span>Running...</span>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="border-t border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-surface shrink-0">
<span className="text-accent-green font-mono text-xs shrink-0">[{mode}]$</span>
<ChevronRight size={12} className="text-text-muted shrink-0" />
<input
ref={inputRef}
autoFocus
className="flex-1 bg-transparent text-text-primary font-mono text-xs focus:outline-none placeholder:text-text-muted"
placeholder={mode === 'shell' ? 'ls /sdcard' : 'devices'}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKeyDown}
disabled={loading}
/>
{loading && (
<div className="w-3 h-3 border border-accent-green border-t-transparent rounded-full animate-spin shrink-0" />
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
// Simple toast state management - used with sonner
import { toast } from 'sonner'
export const notify = {
success: (msg: string) => toast.success(msg, { duration: 3000 }),
error: (msg: string) => toast.error(msg, { duration: 5000 }),
info: (msg: string) => toast(msg, { duration: 3000 }),
loading: (msg: string) => toast.loading(msg),
dismiss: (id?: string | number) => toast.dismiss(id),
}

117
frontend/src/lib/types.ts Normal file
View file

@ -0,0 +1,117 @@
export interface Device {
serial: string
status: string
}
export interface DeviceInfo {
model: string
androidVersion: string
buildNumber: string
batteryLevel: string
serial: string
ipAddress: string
rootStatus: string
codename: string
ramTotal: string
storageInfo: string
brand: string
deviceName: string
securityPatch: string
uptime: string
bootloaderStatus: string
screenResolution: string
basebandVersion: string
kernelVersion: string
cpuArch: string
}
export interface FileEntry {
name: string
type: 'File' | 'Directory' | 'Symlink'
size: string
permissions: string
date: string
time: string
}
export interface PackageInfo {
packageName: string
isEnabled: boolean
}
export interface LogcatLine {
raw: string
level: string
tag: string
message: string
pid: string
time: string
}
export interface AppInspection {
packageName: string
versionName: string
versionCode: string
targetSdk: string
minSdk: string
installPath: string
dataDir: string
installer: string
firstInstall: string
lastUpdated: string
isSystem: boolean
isEnabled: boolean
isDebuggable: boolean
uid: string
permissions: string[]
activities: string[]
services: string[]
receivers: string[]
providers: string[]
nativeLibs: string[]
certSubject: string
certIssuer: string
certExpiry: string
certSha256: string
manifestDump: string
}
export interface CertInfo {
filename: string
subject: string
issuer: string
expiry: string
fingerprint: string
isUser: boolean
isSystem: boolean
}
export interface PropEntry {
key: string
value: string
category: string
}
export interface BackupOptions {
includeApks: boolean
includeShared: boolean
includeSystem: boolean
packages: string[]
allApps: boolean
}
export type View =
| 'dashboard'
| 'files'
| 'packages'
| 'debloater'
| 'shell'
| 'logcat'
| 'appinspect'
| 'certs'
| 'backup'
| 'props'
| 'flasher'
| 'pixelflasher'
| 'utilities'
| 'settings'

142
frontend/src/lib/wails.ts Normal file
View file

@ -0,0 +1,142 @@
// Wails runtime bridge
// This file is auto-generated by wails but we provide a typed version
// The actual runtime is injected by Wails at build time
// @ts-ignore
export const GetDevices = () => window['go']['main']['App']['GetDevices']()
// @ts-ignore
export const GetDeviceInfo = () => window['go']['main']['App']['GetDeviceInfo']()
// @ts-ignore
export const GetDeviceMode = () => window['go']['main']['App']['GetDeviceMode']()
// @ts-ignore
export const Reboot = (mode: string) => window['go']['main']['App']['Reboot'](mode)
// @ts-ignore
export const GetBinaryInfo = () => window['go']['main']['App']['GetBinaryInfo']()
// @ts-ignore
export const CheckSystemRequirements = () => window['go']['main']['App']['CheckSystemRequirements']()
// @ts-ignore
export const SetAdbPath = (path: string) => window['go']['main']['App']['SetAdbPath'](path)
// @ts-ignore
export const SetFastbootPath = (path: string) => window['go']['main']['App']['SetFastbootPath'](path)
// @ts-ignore
export const CancelOperation = () => window['go']['main']['App']['CancelOperation']()
// File ops
// @ts-ignore
export const ListFiles = (path: string) => window['go']['main']['App']['ListFiles'](path)
// @ts-ignore
export const PushFile = (local: string, remote: string) => window['go']['main']['App']['PushFile'](local, remote)
// @ts-ignore
export const PullFile = (remote: string, local: string) => window['go']['main']['App']['PullFile'](remote, local)
// @ts-ignore
export const CreateFolder = (path: string) => window['go']['main']['App']['CreateFolder'](path)
// @ts-ignore
export const DeleteFile = (path: string) => window['go']['main']['App']['DeleteFile'](path)
// @ts-ignore
export const DeleteMultipleFiles = (paths: string[]) => window['go']['main']['App']['DeleteMultipleFiles'](paths)
// @ts-ignore
export const RenameFile = (oldPath: string, newPath: string) => window['go']['main']['App']['RenameFile'](oldPath, newPath)
// @ts-ignore
export const CopyFile = (src: string, dst: string) => window['go']['main']['App']['CopyFile'](src, dst)
// @ts-ignore
export const PullMultipleFiles = (paths: string[]) => window['go']['main']['App']['PullMultipleFiles'](paths)
// @ts-ignore
export const SelectFileForPush = () => window['go']['main']['App']['SelectFileForPush']()
// Package ops
// @ts-ignore
export const ListPackages = (filter: string) => window['go']['main']['App']['ListPackages'](filter)
// @ts-ignore
export const InstallPackage = (path: string) => window['go']['main']['App']['InstallPackage'](path)
// @ts-ignore
export const UninstallPackage = (pkg: string) => window['go']['main']['App']['UninstallPackage'](pkg)
// @ts-ignore
export const DisablePackage = (pkg: string) => window['go']['main']['App']['DisablePackage'](pkg)
// @ts-ignore
export const EnablePackage = (pkg: string) => window['go']['main']['App']['EnablePackage'](pkg)
// @ts-ignore
export const ClearData = (pkg: string) => window['go']['main']['App']['ClearData'](pkg)
// @ts-ignore
export const PullApk = (pkg: string) => window['go']['main']['App']['PullApk'](pkg)
// @ts-ignore
export const ForceStopPackage = (pkg: string) => window['go']['main']['App']['ForceStopPackage'](pkg)
// @ts-ignore
export const GetPackageInfo = (pkg: string) => window['go']['main']['App']['GetPackageInfo'](pkg)
// @ts-ignore
export const UninstallMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['UninstallMultiplePackages'](pkgs)
// @ts-ignore
export const DisableMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['DisableMultiplePackages'](pkgs)
// @ts-ignore
export const EnableMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['EnableMultiplePackages'](pkgs)
// @ts-ignore
export const SelectFileForInstall = () => window['go']['main']['App']['SelectFileForInstall']()
// @ts-ignore
export const SideloadPackage = (path: string) => window['go']['main']['App']['SideloadPackage'](path)
// Shell
// @ts-ignore
export const RunShellCommand = (cmd: string) => window['go']['main']['App']['RunShellCommand'](cmd)
// @ts-ignore
export const RunAdbHostCommand = (args: string) => window['go']['main']['App']['RunAdbHostCommand'](args)
// Wireless
// @ts-ignore
export const EnableWirelessAdb = (port: string) => window['go']['main']['App']['EnableWirelessAdb'](port)
// @ts-ignore
export const ConnectWirelessAdb = (ip: string, port: string) => window['go']['main']['App']['ConnectWirelessAdb'](ip, port)
// @ts-ignore
export const DisconnectWirelessAdb = (ip: string, port: string) => window['go']['main']['App']['DisconnectWirelessAdb'](ip, port)
// Fastboot
// @ts-ignore
export const GetFastbootDevices = () => window['go']['main']['App']['GetFastbootDevices']()
// @ts-ignore
export const FlashPartition = (partition: string, file: string) => window['go']['main']['App']['FlashPartition'](partition, file)
// @ts-ignore
export const FastbootGetVar = (variable: string) => window['go']['main']['App']['FastbootGetVar'](variable)
// @ts-ignore
export const SelectFileForFlash = () => window['go']['main']['App']['SelectFileForFlash']()
// Logcat
// @ts-ignore
export const StartLogcat = (filter: string, buffer: string) => window['go']['main']['App']['StartLogcat'](filter, buffer)
// @ts-ignore
export const StopLogcat = () => window['go']['main']['App']['StopLogcat']()
// @ts-ignore
export const ClearLogcat = () => window['go']['main']['App']['ClearLogcat']()
// App inspection
// @ts-ignore
export const InspectApp = (pkg: string) => window['go']['main']['App']['InspectApp'](pkg)
// @ts-ignore
export const CheckPinning = (pkg: string) => window['go']['main']['App']['CheckPinning'](pkg)
// Certificates
// @ts-ignore
export const ListSystemCerts = () => window['go']['main']['App']['ListSystemCerts']()
// @ts-ignore
export const ListUserCerts = () => window['go']['main']['App']['ListUserCerts']()
// @ts-ignore
export const InstallUserCert = (path: string) => window['go']['main']['App']['InstallUserCert'](path)
// @ts-ignore
export const RemoveUserCert = (filename: string) => window['go']['main']['App']['RemoveUserCert'](filename)
// @ts-ignore
export const SelectCertFile = () => window['go']['main']['App']['SelectCertFile']()
// Backup
// @ts-ignore
export const StartBackup = (opts: any, path: string) => window['go']['main']['App']['StartBackup'](opts, path)
// @ts-ignore
export const RestoreBackup = (path: string) => window['go']['main']['App']['RestoreBackup'](path)
// @ts-ignore
export const SelectBackupFile = () => window['go']['main']['App']['SelectBackupFile']()
// @ts-ignore
export const BackupSingleApp = (pkg: string, apk: boolean) => window['go']['main']['App']['BackupSingleApp'](pkg, apk)
// Props
// @ts-ignore
export const GetAllProps = () => window['go']['main']['App']['GetAllProps']()
// @ts-ignore
export const SetProp = (key: string, value: string) => window['go']['main']['App']['SetProp'](key, value)
// @ts-ignore
export const GetProp = (key: string) => window['go']['main']['App']['GetProp'](key)

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View file

@ -0,0 +1,137 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
background: #0a0a0f;
color: #e8e8f0;
font-family: 'IBM Plex Sans', sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
user-select: none;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #252530;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #333344;
}
}
@layer components {
.card {
@apply bg-bg-surface border border-bg-border rounded-lg;
}
.btn {
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium
transition-all duration-150 cursor-pointer select-none
disabled:opacity-40 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-accent-green text-bg-base hover:bg-accent-dim active:scale-95;
}
.btn-ghost {
@apply btn bg-transparent text-text-secondary border border-bg-border
hover:bg-bg-raised hover:text-text-primary active:scale-95;
}
.btn-danger {
@apply btn bg-danger/10 text-danger border border-danger/20
hover:bg-danger/20 active:scale-95;
}
.btn-warn {
@apply btn bg-warn/10 text-warn border border-warn/20
hover:bg-warn/20 active:scale-95;
}
.input {
@apply bg-bg-raised border border-bg-border rounded px-3 py-1.5
text-sm text-text-primary placeholder:text-text-muted
focus:outline-none focus:border-accent-green/50
transition-colors duration-150 w-full;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
}
.badge-green {
@apply badge bg-accent-green/10 text-accent-green;
}
.badge-red {
@apply badge bg-danger/10 text-danger;
}
.badge-yellow {
@apply badge bg-warn/10 text-warn;
}
.badge-gray {
@apply badge bg-bg-border text-text-secondary;
}
.section-title {
@apply text-xs font-medium uppercase tracking-widest text-text-muted;
}
.mono {
@apply font-mono text-sm;
}
}
/* Glow effect on accent elements */
.glow {
box-shadow: 0 0 12px rgba(0, 255, 136, 0.15);
}
/* Animated scan line for the terminal */
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
/* Pulse animation for status dots */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
animation: pulse-dot 2s ease-in-out infinite;
}
.status-dot-green { background: #00ff88; box-shadow: 0 0 6px #00ff8888; }
.status-dot-red { background: #ff4444; box-shadow: 0 0 6px #ff444488; }
.status-dot-gray { background: #44445a; animation: none; }

View file

@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
bg: {
base: '#0a0a0f',
surface: '#111118',
raised: '#18181f',
border: '#252530',
},
accent: {
green: '#00ff88',
dim: '#00cc6a',
muted: '#003322',
},
text: {
primary: '#e8e8f0',
secondary: '#8888aa',
muted: '#44445a',
},
danger: '#ff4444',
warn: '#ffaa00',
},
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
sans: ['"IBM Plex Sans"', 'sans-serif'],
},
},
},
plugins: [],
}

24
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

9
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
},
})

43
go.mod Normal file
View file

@ -0,0 +1,43 @@
module github.com/jegly/ATK
go 1.23
require (
github.com/ncruces/zenity v0.10.14
github.com/wailsapp/wails/v2 v2.11.0
)
require (
github.com/akavel/rsrc v0.10.2 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/josephspurrier/goversioninfo v1.4.1 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/image v0.20.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

39
main.go Normal file
View file

@ -0,0 +1,39 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/linux"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "ATK — Android Toolkit",
Width: 1280,
Height: 800,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 10, G: 10, B: 15, A: 1},
OnStartup: app.Startup,
Bind: []interface{}{
app,
},
Linux: &linux.Options{
WindowIsTranslucent: false,
WebviewGpuPolicy: linux.WebviewGpuPolicyOnDemand,
},
})
if err != nil {
println("Error:", err.Error())
}
}

32
nfpm.yaml Normal file
View file

@ -0,0 +1,32 @@
name: "atk"
arch: "amd64"
platform: "linux"
version: "1.0.0"
section: "utils"
priority: "optional"
maintainer: "jegly <https://github.com/jegly>"
description: |
ATK (Android Toolkit) — an all-in-one ADB GUI for Android power users,
security researchers, and bug hunters. Includes a debloater (2157 packages),
live logcat, app inspector, certificate manager, Pixel factory flasher,
prop editor, device backup, and 487 utility commands.
vendor: "jegly"
homepage: "https://github.com/jegly/ATK"
license: "GPL-3.0"
contents:
- src: "build/bin/ATK"
dst: "/opt/atk/ATK"
- src: "/opt/atk/ATK"
dst: "/usr/bin/atk"
type: "symlink"
- src: "build/atk.desktop"
dst: "/usr/share/applications/atk.desktop"
- src: "build/appicon.png"
dst: "/usr/share/icons/hicolor/256x256/apps/atk.png"
depends:
- adb
- fastboot
- libgtk-3-0
- libwebkit2gtk-4.1-0

344
package_service.go Normal file
View file

@ -0,0 +1,344 @@
package main
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
// ListPackages returns all installed packages filtered by type.
// filterType: "user", "system", or "all"
func (a *App) ListPackages(filterType string) ([]PackageInfo, error) {
var wg sync.WaitGroup
var mu sync.Mutex
var enabledPkgs, disabledPkgs []string
var errEnabled, errDisabled error
// Build base args - all discrete
buildArgs := func(stateFlag string) []string {
args := []string{"pm", "list", "packages", stateFlag}
switch filterType {
case "user":
args = append(args, "-3")
case "system":
args = append(args, "-s")
}
return args
}
wg.Add(2)
go func() {
defer wg.Done()
output, err := a.runAdbShell(buildArgs("-e")...)
mu.Lock()
defer mu.Unlock()
if err != nil {
errEnabled = err
return
}
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if pkg := strings.TrimPrefix(line, "package:"); pkg != line {
enabledPkgs = append(enabledPkgs, strings.TrimSpace(pkg))
}
}
}()
go func() {
defer wg.Done()
output, err := a.runAdbShell(buildArgs("-d")...)
mu.Lock()
defer mu.Unlock()
if err != nil {
errDisabled = err
return
}
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if pkg := strings.TrimPrefix(line, "package:"); pkg != line {
disabledPkgs = append(disabledPkgs, strings.TrimSpace(pkg))
}
}
}()
wg.Wait()
if errEnabled != nil {
return nil, fmt.Errorf("failed to list packages: %w", errEnabled)
}
// Don't fail if disabled list fails - some devices restrict it
_ = errDisabled
pkgMap := make(map[string]PackageInfo)
for _, p := range enabledPkgs {
pkgMap[p] = PackageInfo{PackageName: p, IsEnabled: true}
}
for _, p := range disabledPkgs {
pkgMap[p] = PackageInfo{PackageName: p, IsEnabled: false}
}
packages := make([]PackageInfo, 0, len(pkgMap))
for _, pkg := range pkgMap {
packages = append(packages, pkg)
}
return packages, nil
}
// InstallPackage installs an APK from a local file path.
// filePath is a discrete arg - safe.
func (a *App) InstallPackage(filePath string) (string, error) {
ctx, cancel := a.beginCancellableOp(15 * time.Minute)
defer cancel()
// adb install -r <path> - all discrete args
output, err := a.runCommandContext(ctx, "adb", "install", "-r", filePath)
if err != nil {
if strings.Contains(err.Error(), "cancelled") {
return "", fmt.Errorf("installation cancelled")
}
return "", fmt.Errorf("install failed: %w", err)
}
return output, nil
}
// UninstallPackage uninstalls a package by name.
// packageName is a discrete arg - safe.
func (a *App) UninstallPackage(packageName string) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
// pm uninstall <pkg> - all discrete args
output, err := a.runAdbShell("pm", "uninstall", packageName)
if err != nil {
return "", fmt.Errorf("uninstall failed for %s: %w", packageName, err)
}
return output, nil
}
// DisablePackage disables a package for user 0.
// packageName is a discrete arg - safe.
func (a *App) DisablePackage(packageName string) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
// pm disable-user --user 0 <pkg> - all discrete args
output, err := a.runAdbShell("pm", "disable-user", "--user", "0", packageName)
if err != nil {
return "", fmt.Errorf("disable failed for %s: %w", packageName, err)
}
// Accept any "new state:" response as success
if strings.Contains(output, "new state:") {
return output, nil
}
return "", fmt.Errorf("disable failed for %s: %s", packageName, output)
}
// EnablePackage enables a previously disabled package.
// packageName is a discrete arg - safe.
func (a *App) EnablePackage(packageName string) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
// pm enable --user 0 <pkg> - all discrete args
output, err := a.runAdbShell("pm", "enable", "--user", "0", packageName)
if err != nil {
return "", fmt.Errorf("enable failed for %s: %w", packageName, err)
}
// Accept "new state: enabled" or "new state: enabled-user" (Android version variants)
if strings.Contains(output, "new state: enabled") {
return output, nil
}
return "", fmt.Errorf("enable failed for %s: %s", packageName, output)
}
// ClearData clears app data for a package.
func (a *App) ClearData(packageName string) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
output, err := a.runAdbShell("pm", "clear", packageName)
if err != nil {
return "", fmt.Errorf("clear data failed for %s: %w", packageName, err)
}
if strings.Contains(output, "Failed") {
return "", fmt.Errorf("clear data failed for %s: %s", packageName, output)
}
return "Data cleared successfully", nil
}
// PullApk pulls an installed APK from the device to a user-chosen local path.
func (a *App) PullApk(packageName string) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
// Get remote APK path - pm path <pkg> - discrete args
pathOutput, err := a.runAdbShell("pm", "path", packageName)
if err != nil {
return "", fmt.Errorf("cannot find APK for %s: %w", packageName, err)
}
remotePath := strings.TrimPrefix(strings.TrimSpace(pathOutput), "package:")
remotePath = strings.TrimSpace(remotePath)
if remotePath == "" {
return "", fmt.Errorf("could not parse APK path from: %s", pathOutput)
}
localPath, err := a.SelectSaveFile(packageName + ".apk")
if err != nil {
return "", fmt.Errorf("save dialog failed: %w", err)
}
if localPath == "" {
return "APK pull cancelled.", nil
}
ctx, cancel := a.beginCancellableOp(10 * time.Minute)
defer cancel()
// adb pull <remote> <local> - all discrete args
_, err = a.runCommandContext(ctx, "adb", "pull", remotePath, localPath)
if err != nil {
if strings.Contains(err.Error(), "cancelled") {
return "", fmt.Errorf("pull cancelled")
}
return "", fmt.Errorf("pull failed: %w", err)
}
return fmt.Sprintf("APK saved to %s", localPath), nil
}
// UninstallMultiplePackages uninstalls a list of packages.
func (a *App) UninstallMultiplePackages(packageNames []string) (string, error) {
return a.batchPackageOp("uninstall", packageNames, a.UninstallPackage)
}
// DisableMultiplePackages disables a list of packages.
func (a *App) DisableMultiplePackages(packageNames []string) (string, error) {
return a.batchPackageOp("disable", packageNames, a.DisablePackage)
}
// EnableMultiplePackages enables a list of packages.
func (a *App) EnableMultiplePackages(packageNames []string) (string, error) {
return a.batchPackageOp("enable", packageNames, a.EnablePackage)
}
// batchPackageOp runs a package operation on multiple packages and summarises results.
func (a *App) batchPackageOp(opName string, packageNames []string, op func(string) (string, error)) (string, error) {
if len(packageNames) == 0 {
return "", fmt.Errorf("no packages selected")
}
var successCount, failCount int
var errDetails strings.Builder
for _, pkg := range packageNames {
if _, err := op(pkg); err != nil {
failCount++
errDetails.WriteString(fmt.Sprintf("• %s: %v\n", pkg, err))
} else {
successCount++
}
}
summary := fmt.Sprintf("Successfully %sd %d package(s).", opName, successCount)
if failCount > 0 {
summary += fmt.Sprintf("\nFailed: %d\n%s", failCount, errDetails.String())
}
return summary, nil
}
// ForceStopPackage force stops a running app.
func (a *App) ForceStopPackage(packageName string) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
_, err := a.runAdbShell("am", "force-stop", packageName)
if err != nil {
return "", fmt.Errorf("force stop failed: %w", err)
}
return fmt.Sprintf("Force stopped %s", packageName), nil
}
// GetPackageInfo returns detailed info about a package.
func (a *App) GetPackageInfo(packageName string) (string, error) {
if err := validatePackageName(packageName); err != nil {
return "", err
}
// dumpsys package <pkg> - discrete args
output, err := a.runAdbShellTimeout(10*time.Second, "dumpsys", "package", packageName)
if err != nil {
return "", fmt.Errorf("failed to get package info: %w", err)
}
return output, nil
}
// SideloadPackage sideloads a package via adb sideload (for OTA updates in recovery).
func (a *App) SideloadPackage(filePath string) (string, error) {
ctx, cancel := a.beginCancellableOp(0) // No timeout - user cancellable
defer cancel()
output, err := a.runCommandContext(ctx, "adb", "sideload", filePath)
if err != nil {
return "", fmt.Errorf("sideload failed: %w", err)
}
return output, nil
}
// validatePackageName checks that a package name looks like a valid Android package.
// Android packages are dot-separated identifiers: com.example.app
// This prevents passing arbitrary strings as package names.
func validatePackageName(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("package name cannot be empty")
}
// Basic sanity: must not contain shell metacharacters
// Since we pass as discrete args this is belt-and-suspenders,
// but good to validate inputs regardless
for _, ch := range name {
if ch == ';' || ch == '&' || ch == '|' || ch == '`' || ch == '$' || ch == '\n' || ch == '\r' {
return fmt.Errorf("invalid character in package name: %q", ch)
}
}
return nil
}
// RunAdbHostCommand runs a raw adb command from the terminal view.
// args is split on spaces and each element passed as a discrete arg.
// This is intentionally permissive since it's the shell terminal feature.
func (a *App) RunAdbHostCommand(rawArgs string) (string, error) {
if rawArgs == "" {
return "", fmt.Errorf("command cannot be empty")
}
args := strings.Fields(rawArgs)
if len(args) == 0 {
return "", fmt.Errorf("no arguments provided")
}
// Restrict to known safe adb subcommands in the terminal
// The shell view still allows full adb usage, this just prevents
// passing arbitrary binaries
ctx, cancel := context.WithTimeout(context.Background(), DefaultCommandTimeout)
defer cancel()
return a.runCommandContext(ctx, "adb", args...)
}
// RunShellCommand runs an adb shell command from the terminal view.
// The command string is split on spaces - NO shell interpretation.
// This means pipes/redirects won't work, but it's much safer.
func (a *App) RunShellCommand(command string) (string, error) {
if command == "" {
return "", fmt.Errorf("command cannot be empty")
}
// Split into discrete args - no shell, no injection
args := strings.Fields(command)
return a.runAdbShell(args...)
}

17
sysproc_linux.go Normal file
View file

@ -0,0 +1,17 @@
//go:build !windows
package main
import (
"os/exec"
"syscall"
)
// setCommandSysProcAttr sets platform-specific process attributes.
// On Linux: sets a new process group so child processes don't receive
// terminal signals meant for the parent, and enables proper cleanup.
func setCommandSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
}

13
wails.json Normal file
View file

@ -0,0 +1,13 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "ATK",
"outputfilename": "ATK",
"frontend:install": "pnpm install",
"frontend:build": "pnpm run build",
"frontend:dev:watcher": "pnpm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "jegly",
"email": ""
}
}

199
wireless_service.go Normal file
View file

@ -0,0 +1,199 @@
package main
import (
"fmt"
"regexp"
"strings"
)
// EnableWirelessAdb switches ADB to TCP/IP mode on the given port.
func (a *App) EnableWirelessAdb(port string) (string, error) {
if port == "" {
port = "5555"
}
if err := validatePort(port); err != nil {
return "", err
}
// adb tcpip <port> - all discrete args
output, err := a.runCommand("adb", "tcpip", port)
if err != nil {
return "", fmt.Errorf("failed to enable wireless ADB (is device connected via USB?): %w", err)
}
return output, nil
}
// ConnectWirelessAdb connects to a device over TCP/IP.
func (a *App) ConnectWirelessAdb(ipAddress, port string) (string, error) {
if port == "" {
port = "5555"
}
if err := validateIP(ipAddress); err != nil {
return "", err
}
if err := validatePort(port); err != nil {
return "", err
}
address := ipAddress + ":" + port
// adb connect <address> - discrete args
output, err := a.runCommand("adb", "connect", address)
if err != nil {
return "", fmt.Errorf("connect failed: %w", err)
}
clean := strings.TrimSpace(output)
if strings.Contains(clean, "connected to") || strings.Contains(clean, "already connected") {
return clean, nil
}
if clean == "" {
return "", fmt.Errorf("no response from device — check IP and port")
}
return "", fmt.Errorf("%s", clean)
}
// DisconnectWirelessAdb disconnects from a TCP/IP ADB connection.
func (a *App) DisconnectWirelessAdb(ipAddress, port string) (string, error) {
if port == "" {
port = "5555"
}
if err := validateIP(ipAddress); err != nil {
return "", err
}
address := ipAddress + ":" + port
// adb disconnect <address> - discrete args
output, err := a.runCommand("adb", "disconnect", address)
if err != nil {
// Try without port
output, err = a.runCommand("adb", "disconnect", ipAddress)
if err != nil {
return "", fmt.Errorf("disconnect failed: %w", err)
}
}
clean := strings.TrimSpace(output)
if clean == "" {
return fmt.Sprintf("Disconnected from %s", address), nil
}
return clean, nil
}
// GetFastbootDevices lists devices connected in fastboot mode.
func (a *App) GetFastbootDevices() ([]Device, error) {
output, err := a.runCommand("fastboot", "devices")
if err != nil {
return nil, err
}
var devices []Device
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
devices = append(devices, Device{
Serial: parts[0],
Status: parts[1],
})
}
}
return devices, nil
}
// FlashPartition flashes an image file to a named partition via fastboot.
// partition is validated against known partition names.
// filePath is a discrete arg.
func (a *App) FlashPartition(partition, filePath string) (string, error) {
if err := validatePartitionName(partition); err != nil {
return "", err
}
// fastboot flash <partition> <file> - all discrete args
output, err := a.runCommand("fastboot", "flash", partition, filePath)
if err != nil {
return "", fmt.Errorf("flash failed: %w", err)
}
return output, nil
}
// FastbootOemCommand runs an OEM-specific fastboot command.
// Used for device-specific unlock/lock operations.
func (a *App) FastbootOemCommand(subcommand string) (string, error) {
validOemCmds := map[string]bool{
"unlock": true,
"lock": true,
"device-info": true,
"get-identifier": true,
}
if !validOemCmds[strings.ToLower(subcommand)] {
return "", fmt.Errorf("unsupported OEM command: %q", subcommand)
}
output, err := a.runCommand("fastboot", "oem", subcommand)
if err != nil {
return "", fmt.Errorf("oem %s failed: %w", subcommand, err)
}
return output, nil
}
// FastbootGetVar retrieves a fastboot variable.
func (a *App) FastbootGetVar(variable string) (string, error) {
output, err := a.runCommand("fastboot", "getvar", variable)
if err != nil {
return "", fmt.Errorf("getvar %s failed: %w", variable, err)
}
return output, nil
}
// validateIP checks that an IP address looks valid.
func validateIP(ip string) error {
ip = strings.TrimSpace(ip)
if ip == "" {
return fmt.Errorf("IP address cannot be empty")
}
re := regexp.MustCompile(`^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`)
if !re.MatchString(ip) {
return fmt.Errorf("invalid IP address: %q", ip)
}
return nil
}
// validatePort checks that a port string is a valid port number.
func validatePort(port string) error {
port = strings.TrimSpace(port)
if port == "" {
return fmt.Errorf("port cannot be empty")
}
re := regexp.MustCompile(`^\d{1,5}$`)
if !re.MatchString(port) {
return fmt.Errorf("invalid port: %q", port)
}
return nil
}
// validatePartitionName checks that a partition name is in the known safe list.
// This prevents flashing to arbitrary "partition" names that could be shell tricks.
func validatePartitionName(name string) error {
knownPartitions := map[string]bool{
"boot": true,
"recovery": true,
"system": true,
"vendor": true,
"vendor_boot": true,
"userdata": true,
"cache": true,
"dtbo": true,
"vbmeta": true,
"vbmeta_system": true,
"super": true,
"product": true,
"odm": true,
"radio": true,
"bootloader": true,
"modem": true,
}
if !knownPartitions[strings.ToLower(name)] {
return fmt.Errorf("unknown partition %q — not in safe list", name)
}
return nil
}

43
zip_service.go Normal file
View file

@ -0,0 +1,43 @@
package main
import (
"archive/zip"
"fmt"
"io"
"strings"
)
// ReadFileFromZip reads a named file from inside a zip archive.
// Used to read flash-all.sh from a Pixel factory image zip.
func (a *App) ReadFileFromZip(zipPath string, fileName string) (string, error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return "", fmt.Errorf("cannot open zip: %w", err)
}
defer r.Close()
for _, f := range r.File {
// Match by filename only (ignore directory prefix)
name := f.Name
if idx := strings.LastIndex(name, "/"); idx >= 0 {
name = name[idx+1:]
}
if name != fileName {
continue
}
rc, err := f.Open()
if err != nil {
return "", fmt.Errorf("cannot open %s in zip: %w", fileName, err)
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return "", fmt.Errorf("cannot read %s: %w", fileName, err)
}
return string(data), nil
}
return "", fmt.Errorf("%s not found in zip", fileName)
}