Initial release — ATK Android Toolkit v1.0.0
This commit is contained in:
commit
f45c0991c5
53 changed files with 10445 additions and 0 deletions
69
.github/workflows/build.yml
vendored
Normal file
69
.github/workflows/build.yml
vendored
Normal 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
39
.gitignore
vendored
Normal 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
60
LICENSE
Normal 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
193
README.md
Normal 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
93
app.go
Normal 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
183
backend_appinspect.go
Normal 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
121
backend_backup.go
Normal 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
179
backend_cert.go
Normal 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
141
backend_logcat.go
Normal 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
137
backend_props.go
Normal 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
37
build/README.md
Normal 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
11
build/atk.desktop
Normal 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
373
device_service.go
Normal 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
86
dialog_service.go
Normal 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
328
executor.go
Normal 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
263
file_service.go
Normal 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
15
frontend/index.html
Normal 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
27
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
85
frontend/src/App.tsx
Normal file
85
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/layout/Sidebar.tsx
Normal file
78
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
278
frontend/src/components/views/ViewAppInspect.tsx
Normal file
278
frontend/src/components/views/ViewAppInspect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
225
frontend/src/components/views/ViewBackup.tsx
Normal file
225
frontend/src/components/views/ViewBackup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
frontend/src/components/views/ViewCerts.tsx
Normal file
160
frontend/src/components/views/ViewCerts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
frontend/src/components/views/ViewDashboard.tsx
Normal file
248
frontend/src/components/views/ViewDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
314
frontend/src/components/views/ViewDebloater.tsx
Normal file
314
frontend/src/components/views/ViewDebloater.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
322
frontend/src/components/views/ViewFiles.tsx
Normal file
322
frontend/src/components/views/ViewFiles.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
frontend/src/components/views/ViewFlasher.tsx
Normal file
195
frontend/src/components/views/ViewFlasher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
frontend/src/components/views/ViewLogcat.tsx
Normal file
293
frontend/src/components/views/ViewLogcat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
256
frontend/src/components/views/ViewPackages.tsx
Normal file
256
frontend/src/components/views/ViewPackages.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
582
frontend/src/components/views/ViewPixelFlasher.tsx
Normal file
582
frontend/src/components/views/ViewPixelFlasher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
frontend/src/components/views/ViewProps.tsx
Normal file
241
frontend/src/components/views/ViewProps.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
frontend/src/components/views/ViewSettings.tsx
Normal file
142
frontend/src/components/views/ViewSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
frontend/src/components/views/ViewShell.tsx
Normal file
150
frontend/src/components/views/ViewShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1041
frontend/src/components/views/ViewUtilities.tsx
Normal file
1041
frontend/src/components/views/ViewUtilities.tsx
Normal file
File diff suppressed because it is too large
Load diff
2249
frontend/src/lib/debloat_db.ts
Normal file
2249
frontend/src/lib/debloat_db.ts
Normal file
File diff suppressed because it is too large
Load diff
10
frontend/src/lib/notify.ts
Normal file
10
frontend/src/lib/notify.ts
Normal 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
117
frontend/src/lib/types.ts
Normal 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
142
frontend/src/lib/wails.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
137
frontend/src/styles/global.css
Normal file
137
frontend/src/styles/global.css
Normal 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; }
|
||||
36
frontend/tailwind.config.js
Normal file
36
frontend/tailwind.config.js
Normal 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
24
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
9
frontend/vite.config.ts
Normal 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
43
go.mod
Normal 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
39
main.go
Normal 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
32
nfpm.yaml
Normal 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
344
package_service.go
Normal 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
17
sysproc_linux.go
Normal 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
13
wails.json
Normal 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
199
wireless_service.go
Normal 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
43
zip_service.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue