Merge dev: GSI Loader, Intent Lab, Firmware, Screen Mirror, App Lock, privileged uninstall helper (v1.2.0)
This commit is contained in:
commit
4da6a69964
25 changed files with 2399 additions and 116 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -37,3 +37,10 @@ ATK_*.log
|
|||
# Temp
|
||||
*.tmp
|
||||
/tmp/
|
||||
|
||||
# ── PROPRIETARY — Logcat visual map. NEVER publish to GitHub. ──
|
||||
# (Builds into the shipped binary as obfuscated JS + native Go; source stays private.)
|
||||
/proprietary/
|
||||
/backend_logcatpatterns.go
|
||||
/frontend/src/lib/logcatgraph.ts
|
||||
/frontend/src/components/views/LogcatMap.tsx
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -2,13 +2,14 @@
|
|||
<img src="assets/appicon.png" alt="ATK" width="132" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ATK · Android Tool kit</h1>
|
||||
<h1 align="center">ATK · Android Toolkit</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>An all-in-one Android command centre with a real-time system-map debugging engine.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/downloads/jegly/ATK/total?style=for-the-badge&color=50FA7B&label=Downloads" alt="Downloads" />
|
||||
<img src="https://img.shields.io/badge/License-GPLv3-BD93F9?style=for-the-badge" alt="License GPLv3" />
|
||||
<img src="https://img.shields.io/badge/Platform-Linux-50FA7B?style=for-the-badge&logo=linux&logoColor=282A36" alt="Linux" />
|
||||
<img src="https://img.shields.io/badge/Go%20+%20React%20(Wails)-8BE9FD?style=for-the-badge&color=8BE9FD&logoColor=282A36" alt="Go + React via Wails" />
|
||||
|
|
@ -53,11 +54,11 @@ ATK builds on these open-source projects. Go star them:
|
|||
- **[PixelFlasher](https://github.com/badabing2005/PixelFlasher)** (badabing2005): Pixel flash-sequence reference.
|
||||
- **[Wails](https://wails.io)**: Go and Web application framework.
|
||||
- **[Lucide](https://lucide.dev)**: icon set.
|
||||
- **[adb-gui-kit](https://github.com/Drenzzz/adb-gui-kit)** (Drenzzz): early base ADB GUI groundwork this project started from.
|
||||
- **[adb-gui-kit](https://github.com/Drenzzz/adb-gui-kit)** (Drenzzz): early ADB GUI groundwork this project started from.
|
||||
|
||||
### 🗺️ See the Live System Map in action
|
||||
|
||||
Real-time demos of the map engine showing live device telemetry: You may have seen this viral on X, it is getting around ! Yes it came from here, This is the original implementation.
|
||||
Real-time demos of the map engine showing live device telemetry:
|
||||
|
||||
**▶️ Demo 1**
|
||||
|
||||
|
|
@ -105,6 +106,10 @@ https://github.com/user-attachments/assets/47a3590a-11f8-416f-b972-0e89d933419c
|
|||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> The four demos above are hosted on GitHub's attachment CDN, so they play inline
|
||||
> here. The copies in `screenshot/*.mp4` are no longer needed for playback and you
|
||||
> can delete them to keep the repo small.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -326,6 +331,11 @@ wails build -tags webkit2_41
|
|||
|
||||
**Dev mode (hot reload):** `wails dev -tags webkit2_41`
|
||||
|
||||
> [!NOTE]
|
||||
> **About the Live System Map.** The map engine is the one closed-source part of
|
||||
> ATK, and its sources are not in this public repo. The **pre-built releases ship
|
||||
> the complete app**, map included, and that is the supported way to run ATK with
|
||||
> the map. Building from this repo gives you the full toolkit minus the map module.
|
||||
|
||||
**Package as .deb**
|
||||
```bash
|
||||
|
|
|
|||
302
backend_gsi.go
Normal file
302
backend_gsi.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package main
|
||||
|
||||
// GSI Loader backend — two ways to run a Generic System Image on the device:
|
||||
//
|
||||
// 1. DSU (temporary): install a GSI as a guest OS via Dynamic System Updates.
|
||||
// Non-destructive, no unlock, no wipe. Follows the exact adb flow from the
|
||||
// Android DSU docs (see dsi_info.txt): gzip the raw image, push it to
|
||||
// /storage/emulated/0/Download, then fire the START_INSTALL intent at
|
||||
// com.android.dynsystem. Managed afterwards with gsi_tool.
|
||||
//
|
||||
// 2. GSI flash (permanent): fastboot-flash a GSI to the system partition.
|
||||
// Destructive; DANGER-gated (App Lock). Sequences the documented fastboot
|
||||
// steps with a dry-run preview.
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dsuDefaultUserdata int64 = 8589934592 // 8 GiB
|
||||
const dsuRemoteDir = "/storage/emulated/0/Download"
|
||||
|
||||
// GsiCompat reports whether the device can run a GSI and which one.
|
||||
type GsiCompat struct {
|
||||
TrebleEnabled bool `json:"trebleEnabled"`
|
||||
Abi string `json:"abi"` // ro.product.cpu.abi, e.g. arm64-v8a
|
||||
GsiArch string `json:"gsiArch"` // derived: arm64 / x86_64 / arm / x86
|
||||
VndkIsolated bool `json:"vndkIsolated"` // true => any newer GSI ok; false => same-version only
|
||||
AndroidRelease string `json:"androidRelease"`
|
||||
Sdk string `json:"sdk"`
|
||||
DsuStatus string `json:"dsuStatus"`
|
||||
}
|
||||
|
||||
// GsiCompat runs the documented compatibility checks (getprop + ld.config).
|
||||
func (a *App) GsiCompat() (GsiCompat, error) {
|
||||
c := GsiCompat{}
|
||||
if v, err := a.runAdbShell("getprop", "ro.treble.enabled"); err == nil {
|
||||
c.TrebleEnabled = strings.TrimSpace(v) == "true"
|
||||
}
|
||||
if v, err := a.runAdbShell("getprop", "ro.product.cpu.abi"); err == nil {
|
||||
c.Abi = strings.TrimSpace(v)
|
||||
c.GsiArch = gsiArchForAbi(c.Abi)
|
||||
}
|
||||
if v, err := a.runAdbShell("getprop", "ro.build.version.release"); err == nil {
|
||||
c.AndroidRelease = strings.TrimSpace(v)
|
||||
}
|
||||
if v, err := a.runAdbShell("getprop", "ro.build.version.sdk"); err == nil {
|
||||
c.Sdk = strings.TrimSpace(v)
|
||||
}
|
||||
if v, err := a.runAdbShell("cat", "/system/etc/ld.config.version_identifier.txt"); err == nil {
|
||||
c.VndkIsolated = vendorNamespaceIsolated(v)
|
||||
}
|
||||
if v, err := a.runAdbShell("gsi_tool", "status"); err == nil {
|
||||
c.DsuStatus = strings.TrimSpace(v)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func gsiArchForAbi(abi string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(abi, "arm64"):
|
||||
return "arm64"
|
||||
case strings.HasPrefix(abi, "x86_64"):
|
||||
return "x86_64"
|
||||
case strings.HasPrefix(abi, "x86"):
|
||||
return "x86"
|
||||
case strings.HasPrefix(abi, "arm"):
|
||||
return "arm"
|
||||
}
|
||||
return abi
|
||||
}
|
||||
|
||||
// vendorNamespaceIsolated parses ld.config for the [vendor] section and reports
|
||||
// whether namespace.default.isolated is true (full VNDK => any newer GSI works).
|
||||
func vendorNamespaceIsolated(ld string) bool {
|
||||
inVendor := false
|
||||
for _, line := range strings.Split(ld, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
|
||||
inVendor = t == "[vendor]"
|
||||
continue
|
||||
}
|
||||
if inVendor && strings.Contains(t, "namespace.default.isolated") {
|
||||
return strings.Contains(strings.ToLower(t), "true")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- gsi_tool management ---------------------------------------------------
|
||||
|
||||
func (a *App) GsiDsuStatus() (string, error) { return a.runAdbShell("gsi_tool", "status") }
|
||||
func (a *App) DsuEnable() (string, error) { return a.runAdbShell("gsi_tool", "enable") }
|
||||
func (a *App) DsuDisable() (string, error) { return a.runAdbShell("gsi_tool", "disable") }
|
||||
func (a *App) DsuWipe() (string, error) { return a.runAdbShell("gsi_tool", "wipe") }
|
||||
|
||||
// --- DSU install -----------------------------------------------------------
|
||||
|
||||
// InstallDsu prepares and installs a temporary DSU from a GSI image, then fires
|
||||
// the DynamicSystemInstallationService intent. `systemSize` is the UNCOMPRESSED
|
||||
// raw image size in bytes (auto = file size for a raw .img; REQUIRED for a .gz).
|
||||
// `userdataSize` defaults to 8 GiB when <= 0.
|
||||
func (a *App) InstallDsu(imagePath string, systemSize int64, userdataSize int64) (string, error) {
|
||||
imagePath = strings.TrimSpace(imagePath)
|
||||
if imagePath == "" {
|
||||
return "", fmt.Errorf("no GSI image selected")
|
||||
}
|
||||
info, err := os.Stat(imagePath)
|
||||
if err != nil || info.IsDir() {
|
||||
return "", fmt.Errorf("image not found: %s", imagePath)
|
||||
}
|
||||
if isSparseImage(imagePath) {
|
||||
return "", fmt.Errorf("this looks like a SPARSE image — DSU needs an unsparsed raw image. Convert first:\n simg2img system.img system_raw.img\nthen select the raw .img (or a .gz you made from it).")
|
||||
}
|
||||
|
||||
if userdataSize <= 0 {
|
||||
userdataSize = dsuDefaultUserdata
|
||||
}
|
||||
|
||||
var gzPath string
|
||||
var sysSize int64
|
||||
isGz := strings.HasSuffix(strings.ToLower(imagePath), ".gz")
|
||||
if isGz {
|
||||
gzPath = imagePath
|
||||
if systemSize <= 0 {
|
||||
return "", fmt.Errorf("for a .gz image, provide the uncompressed system image size (bytes) — DSU needs KEY_SYSTEM_SIZE")
|
||||
}
|
||||
sysSize = systemSize
|
||||
} else {
|
||||
// Raw image: size is exact; gzip it host-side as the docs require.
|
||||
sysSize = info.Size()
|
||||
if systemSize > 0 {
|
||||
sysSize = systemSize
|
||||
}
|
||||
gzPath = filepath.Join(os.TempDir(), "atk-dsu.gz")
|
||||
if err := gzipFile(imagePath, gzPath); err != nil {
|
||||
return "", fmt.Errorf("failed to gzip image: %w", err)
|
||||
}
|
||||
defer os.Remove(gzPath)
|
||||
}
|
||||
|
||||
// Push the gzipped image to the device (progress via transfer:* events).
|
||||
if _, err := a.PushWithProgress(gzPath, dsuRemoteDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
remote := dsuRemoteDir + "/" + filepath.Base(gzPath)
|
||||
|
||||
// Fire the DSU install intent — verbatim from the Android docs.
|
||||
out, err := a.runAdbShell(
|
||||
"am", "start-activity",
|
||||
"-n", "com.android.dynsystem/com.android.dynsystem.VerificationActivity",
|
||||
"-a", "android.os.image.action.START_INSTALL",
|
||||
"-d", "file://"+remote,
|
||||
"--el", "KEY_SYSTEM_SIZE", strconv.FormatInt(sysSize, 10),
|
||||
"--el", "KEY_USERDATA_SIZE", strconv.FormatInt(userdataSize, 10),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to launch DSU install: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return fmt.Sprintf("DSU install started (system %s, userdata %s). On the device, tap Restart in the notification to boot the GSI, or Discard to cancel. Use 'gsi_tool enable' for sticky mode.",
|
||||
humanBytes(sysSize), humanBytes(userdataSize)), nil
|
||||
}
|
||||
|
||||
// isSparseImage checks the Android sparse-image magic (0xed26ff3a, little-endian).
|
||||
func isSparseImage(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
var b [4]byte
|
||||
if _, err := io.ReadFull(f, b[:]); err != nil {
|
||||
return false
|
||||
}
|
||||
return b[0] == 0x3a && b[1] == 0xff && b[2] == 0x26 && b[3] == 0xed
|
||||
}
|
||||
|
||||
func gzipFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
zw := gzip.NewWriter(out)
|
||||
if _, err := io.Copy(zw, in); err != nil {
|
||||
zw.Close()
|
||||
return err
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const u = 1024
|
||||
if n < u {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(u), 0
|
||||
for x := n / u; x >= u; x /= u {
|
||||
div *= u
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// --- GSI permanent flash (fastboot) ----------------------------------------
|
||||
|
||||
type GsiFlashOpts struct {
|
||||
Fastbootd bool `json:"fastbootd"` // reboot fastboot (fastbootd) first
|
||||
WipeData bool `json:"wipeData"` // fastboot -w
|
||||
DisableVerity bool `json:"disableVerity"` // flash vbmeta --disable-verification
|
||||
DeleteProduct bool `json:"deleteProduct"` // free space: delete product_<slot>
|
||||
Slot string `json:"slot"` // "a" / "b" / "" (for delete-logical-partition)
|
||||
VbmetaPath string `json:"vbmetaPath"` // required if DisableVerity
|
||||
DryRun bool `json:"dryRun"`
|
||||
}
|
||||
|
||||
type gsiStep struct {
|
||||
desc string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (a *App) gsiFlashSteps(imagePath string, opts GsiFlashOpts) []gsiStep {
|
||||
var steps []gsiStep
|
||||
if opts.Fastbootd {
|
||||
steps = append(steps, gsiStep{"Reboot to fastbootd", []string{"reboot", "fastboot"}})
|
||||
}
|
||||
if opts.DeleteProduct {
|
||||
part := "product"
|
||||
if opts.Slot != "" {
|
||||
part += "_" + opts.Slot
|
||||
}
|
||||
steps = append(steps, gsiStep{"Free space: delete " + part, []string{"delete-logical-partition", part}})
|
||||
}
|
||||
steps = append(steps,
|
||||
gsiStep{"Erase system", []string{"erase", "system"}},
|
||||
gsiStep{"Flash system", []string{"flash", "system", imagePath}},
|
||||
)
|
||||
if opts.WipeData {
|
||||
steps = append(steps, gsiStep{"Wipe userdata", []string{"-w"}})
|
||||
}
|
||||
if opts.DisableVerity && strings.TrimSpace(opts.VbmetaPath) != "" {
|
||||
steps = append(steps, gsiStep{"Flash vbmeta (disable verification)", []string{"--disable-verification", "flash", "vbmeta", opts.VbmetaPath}})
|
||||
}
|
||||
steps = append(steps, gsiStep{"Reboot", []string{"reboot"}})
|
||||
return steps
|
||||
}
|
||||
|
||||
// FlashGsiSystem flashes a GSI to the system partition via fastboot. With
|
||||
// DryRun, it returns the command list without executing. Otherwise it runs each
|
||||
// step, gated behind the App Lock danger check.
|
||||
func (a *App) FlashGsiSystem(imagePath string, opts GsiFlashOpts) (string, error) {
|
||||
imagePath = strings.TrimSpace(imagePath)
|
||||
if imagePath == "" {
|
||||
return "", fmt.Errorf("no GSI system image selected")
|
||||
}
|
||||
if !opts.DryRun {
|
||||
if info, err := os.Stat(imagePath); err != nil || info.IsDir() {
|
||||
return "", fmt.Errorf("image not found: %s", imagePath)
|
||||
}
|
||||
}
|
||||
steps := a.gsiFlashSteps(imagePath, opts)
|
||||
|
||||
if opts.DryRun {
|
||||
var b strings.Builder
|
||||
for _, s := range steps {
|
||||
b.WriteString("fastboot " + strings.Join(s.args, " ") + "\n")
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
if err := a.requireDangerUnlocked(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for _, s := range steps {
|
||||
out.WriteString("$ fastboot " + strings.Join(s.args, " ") + "\n")
|
||||
res, err := a.runCommandTimeout(10*time.Minute, "fastboot", s.args...)
|
||||
if strings.TrimSpace(res) != "" {
|
||||
out.WriteString(res + "\n")
|
||||
}
|
||||
if err != nil {
|
||||
return out.String(), fmt.Errorf("%s failed: %w", s.desc, err)
|
||||
}
|
||||
// fastbootd takes a few seconds to come up before it accepts commands.
|
||||
if len(s.args) == 2 && s.args[0] == "reboot" && s.args[1] == "fastboot" {
|
||||
time.Sleep(8 * time.Second)
|
||||
}
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
123
backend_intent.go
Normal file
123
backend_intent.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package main
|
||||
|
||||
// Intent Lab — list an app's launchable (exported) activities and start them via
|
||||
// `am start`, plus a free-form implicit-intent launcher. Lets a user reach hidden
|
||||
// settings menus / internal screens that aren't on the launcher.
|
||||
//
|
||||
// Activity discovery uses `dumpsys package <pkg>`: components that appear in the
|
||||
// Activity Resolver Table have an intent filter, so they're launchable by the
|
||||
// shell user. Non-filtered/exported=false activities generally can't be started
|
||||
// from adb; the launcher surfaces the real `am start` result either way.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type IntentActivity struct {
|
||||
Name string `json:"name"` // activity class, relative to the package
|
||||
Component string `json:"component"` // full "package/activity" target for am start
|
||||
Exported bool `json:"exported"` // appears in the resolver table (has an intent filter)
|
||||
}
|
||||
|
||||
// componentRe guards the `am start -n <component>` target against shell injection
|
||||
// on the device side (adb shell reparses the command).
|
||||
var componentRe = regexp.MustCompile(`^[A-Za-z0-9_.]+/[A-Za-z0-9_.$]+$`)
|
||||
var actionRe = regexp.MustCompile(`^[A-Za-z0-9_.]+$`)
|
||||
|
||||
// ListActivities returns the launchable activities of an installed package.
|
||||
func (a *App) ListActivities(packageName string) ([]IntentActivity, error) {
|
||||
if err := validatePackageName(packageName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query package: %w", err)
|
||||
}
|
||||
|
||||
prefix := packageName + "/"
|
||||
seen := map[string]bool{}
|
||||
var acts []IntentActivity
|
||||
inActivities := false
|
||||
|
||||
for _, line := range strings.Split(dump, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.Contains(t, "Activity Resolver Table"):
|
||||
inActivities = true
|
||||
continue
|
||||
case strings.Contains(t, "Receiver Resolver Table"),
|
||||
strings.Contains(t, "Service Resolver Table"),
|
||||
strings.Contains(t, "Provider Resolver Table"),
|
||||
strings.Contains(t, "Preferred Activities"),
|
||||
strings.Contains(t, "Key Set Manager"):
|
||||
inActivities = false
|
||||
}
|
||||
if !inActivities {
|
||||
continue
|
||||
}
|
||||
for _, tok := range strings.Fields(t) {
|
||||
if strings.HasPrefix(tok, prefix) && len(tok) > len(prefix) && !seen[tok] {
|
||||
seen[tok] = true
|
||||
acts = append(acts, IntentActivity{
|
||||
Name: strings.TrimPrefix(tok, prefix),
|
||||
Component: tok,
|
||||
Exported: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(acts, func(i, j int) bool { return acts[i].Name < acts[j].Name })
|
||||
return acts, nil
|
||||
}
|
||||
|
||||
// StartActivity launches an explicit component ("package/activity").
|
||||
func (a *App) StartActivity(component string) (string, error) {
|
||||
component = strings.TrimSpace(component)
|
||||
if !componentRe.MatchString(component) {
|
||||
return "", fmt.Errorf("invalid component, expected package/activity: %s", component)
|
||||
}
|
||||
out, err := a.runAdbShell("am", "start", "-n", component)
|
||||
return interpretAmResult(out, err)
|
||||
}
|
||||
|
||||
// StartIntentAction launches an implicit intent by action, with an optional data URI.
|
||||
func (a *App) StartIntentAction(action, data string) (string, error) {
|
||||
action = strings.TrimSpace(action)
|
||||
if action == "" || !actionRe.MatchString(action) {
|
||||
return "", fmt.Errorf("invalid or empty action")
|
||||
}
|
||||
args := []string{"am", "start", "-a", action}
|
||||
if data = strings.TrimSpace(data); data != "" {
|
||||
// Reject shell metacharacters — adb shell reparses this on the device.
|
||||
if strings.ContainsAny(data, " \t\n\r;&|`$<>()\"'\\") {
|
||||
return "", fmt.Errorf("data URI contains disallowed characters")
|
||||
}
|
||||
args = append(args, "-d", data)
|
||||
}
|
||||
out, err := a.runAdbShell(args...)
|
||||
return interpretAmResult(out, err)
|
||||
}
|
||||
|
||||
// interpretAmResult normalises `am start` output — it often prints errors to
|
||||
// stdout with a zero exit, so inspect the text as well as err.
|
||||
func interpretAmResult(out string, err error) (string, error) {
|
||||
out = strings.TrimSpace(out)
|
||||
if err != nil {
|
||||
if out != "" {
|
||||
return "", fmt.Errorf("%s", firstLine(out))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(out, "Error:") || strings.Contains(out, "Exception") ||
|
||||
strings.Contains(out, "does not exist") || strings.Contains(out, "Permission Denial") {
|
||||
return "", fmt.Errorf("%s", firstLine(out))
|
||||
}
|
||||
if out == "" {
|
||||
out = "Started."
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
263
backend_privacy.go
Normal file
263
backend_privacy.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
package main
|
||||
|
||||
// Privacy & Tracker Scanner.
|
||||
//
|
||||
// Pulls an installed app's base APK, scans its DEX bytecode for known
|
||||
// third-party tracker/analytics/ad SDK signatures, cross-references declared
|
||||
// dangerous permissions, and derives a 0-100 Privacy Score (A-F grade). This
|
||||
// reuses the APK auditor's DEX tracker matcher (matchTrackers /
|
||||
// trackerSignatures) and the shared dangerousPermissions set, and additionally
|
||||
// enriches the shared tracker DB below (which also improves the full auditor).
|
||||
//
|
||||
// Data source: a bundled static signature list (Exodus-Privacy-style code
|
||||
// signatures = Java package prefixes as they appear in classes*.dex). No runtime
|
||||
// network — works fully offline on any device.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// extraTrackerSignatures widens the built-in trackerSignatures set with more
|
||||
// well-known Exodus-catalogued SDKs. Merged into the shared maps at init so both
|
||||
// the Privacy Scanner and the APK auditor see them. Signatures are conservative
|
||||
// package prefixes chosen to avoid false positives.
|
||||
var extraTrackerSignatures = map[string][]string{
|
||||
"Google Tag Manager": {"com/google/android/gms/tagmanager"},
|
||||
"Amazon Mobile Ads": {"com/amazon/device/ads", "com/amazon/aps"},
|
||||
"AdColony": {"com/adcolony"},
|
||||
"Startapp": {"com/startapp"},
|
||||
"Mintegral": {"com/mbridge", "com/mintegral"},
|
||||
"Pangle (ByteDance)": {"com/bytedance/sdk/openadsdk", "com/bytedance/pangle"},
|
||||
"ByteDance AppLog": {"com/bytedance/applog"},
|
||||
"PubMatic": {"com/pubmatic"},
|
||||
"Criteo": {"com/criteo"},
|
||||
"Smaato": {"com/smaato"},
|
||||
"Fyber": {"com/fyber"},
|
||||
"Taboola": {"com/taboola"},
|
||||
"Outbrain": {"com/outbrain"},
|
||||
"CleverTap": {"com/clevertap"},
|
||||
"MoEngage": {"com/moengage"},
|
||||
"Airship": {"com/urbanairship"},
|
||||
"Leanplum": {"com/leanplum"},
|
||||
"Batch": {"com/batch/android"},
|
||||
"Iterable": {"com/iterable"},
|
||||
"Pushwoosh": {"com/pushwoosh"},
|
||||
"Swrve": {"com/swrve"},
|
||||
"Optimizely": {"com/optimizely"},
|
||||
"Adobe Experience": {"com/adobe/marketing/mobile", "com/adobe/mobile"},
|
||||
"New Relic": {"com/newrelic"},
|
||||
"Datadog": {"com/datadog/android"},
|
||||
"Instabug": {"com/instabug"},
|
||||
"Embrace": {"io/embrace/android"},
|
||||
"Countly": {"ly/count/android"},
|
||||
"Matomo": {"org/matomo", "org/piwik"},
|
||||
"Snowplow": {"com/snowplowanalytics"},
|
||||
"Smartlook": {"com/smartlook"},
|
||||
"Nielsen": {"com/nielsen/app"},
|
||||
"Mapbox Telemetry": {"com/mapbox/android/telemetry"},
|
||||
"Foursquare": {"com/foursquare"},
|
||||
"Gimbal": {"com/gimbal"},
|
||||
"Radar": {"io/radar/sdk"},
|
||||
}
|
||||
|
||||
var extraTrackerCategory = map[string]string{
|
||||
"Google Tag Manager": "Analytics", "Amazon Mobile Ads": "Advertising", "AdColony": "Advertising",
|
||||
"Startapp": "Advertising", "Mintegral": "Advertising", "Pangle (ByteDance)": "Advertising",
|
||||
"ByteDance AppLog": "Analytics", "PubMatic": "Advertising", "Criteo": "Advertising",
|
||||
"Smaato": "Advertising", "Fyber": "Advertising", "Taboola": "Advertising", "Outbrain": "Advertising",
|
||||
"CleverTap": "Analytics", "MoEngage": "Marketing", "Airship": "Marketing", "Leanplum": "Marketing",
|
||||
"Batch": "Marketing", "Iterable": "Marketing", "Pushwoosh": "Push/Analytics", "Swrve": "Marketing",
|
||||
"Optimizely": "Analytics", "Adobe Experience": "Analytics", "New Relic": "Analytics",
|
||||
"Datadog": "Analytics", "Instabug": "Crash reporting", "Embrace": "Crash reporting",
|
||||
"Countly": "Analytics", "Matomo": "Analytics", "Snowplow": "Analytics", "Smartlook": "Analytics",
|
||||
"Nielsen": "Analytics", "Mapbox Telemetry": "Location", "Foursquare": "Location",
|
||||
"Gimbal": "Location", "Radar": "Location",
|
||||
}
|
||||
|
||||
func init() {
|
||||
for name, sigs := range extraTrackerSignatures {
|
||||
if _, exists := trackerSignatures[name]; !exists {
|
||||
trackerSignatures[name] = sigs
|
||||
}
|
||||
}
|
||||
for name, cat := range extraTrackerCategory {
|
||||
if _, exists := trackerCategory[name]; !exists {
|
||||
trackerCategory[name] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// privacyCategoryWeight is the score penalty per unique tracker of a category.
|
||||
// Advertising / attribution / location are weighted heaviest (most invasive);
|
||||
// crash reporting is light (usually operational, not surveillance).
|
||||
var privacyCategoryWeight = map[string]int{
|
||||
"Advertising": 12,
|
||||
"Location": 12,
|
||||
"Attribution": 8,
|
||||
"Marketing": 8,
|
||||
"Analytics": 7,
|
||||
"Push/Analytics": 6,
|
||||
"Crash reporting": 3,
|
||||
}
|
||||
|
||||
const defaultTrackerWeight = 6
|
||||
|
||||
type PrivacyTracker struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Matches int `json:"matches"`
|
||||
}
|
||||
|
||||
type PrivacyReport struct {
|
||||
PackageName string `json:"packageName"`
|
||||
Score int `json:"score"` // 0-100, higher = more private
|
||||
Grade string `json:"grade"` // A-F
|
||||
TrackerCount int `json:"trackerCount"`
|
||||
Trackers []PrivacyTracker `json:"trackers"`
|
||||
DangerousPermissions []string `json:"dangerousPermissions"`
|
||||
ApkSize int64 `json:"apkSize"`
|
||||
}
|
||||
|
||||
// ScanAppPrivacy pulls the base APK of an installed package, scans it for
|
||||
// tracker SDKs, collects its declared dangerous permissions, and scores it.
|
||||
func (a *App) ScanAppPrivacy(packageName string) (PrivacyReport, error) {
|
||||
if err := validatePackageName(packageName); err != nil {
|
||||
return PrivacyReport{}, err
|
||||
}
|
||||
report := PrivacyReport{PackageName: packageName}
|
||||
|
||||
// Locate the base APK on the device.
|
||||
out, err := a.runAdbShell("pm", "path", packageName)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("could not locate package on device: %w", err)
|
||||
}
|
||||
var remote string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
p := strings.TrimPrefix(strings.TrimSpace(line), "package:")
|
||||
if strings.HasSuffix(p, "base.apk") {
|
||||
remote = p
|
||||
break
|
||||
}
|
||||
if remote == "" && strings.HasSuffix(p, ".apk") {
|
||||
remote = p
|
||||
}
|
||||
}
|
||||
if remote == "" {
|
||||
return report, fmt.Errorf("no APK path found for %s", packageName)
|
||||
}
|
||||
|
||||
// Pull to a temp file; unlike the auditor we don't need to keep it around.
|
||||
tmp := filepath.Join(os.TempDir(), "atk-privacy-"+sanitizeFileToken(packageName)+".apk")
|
||||
if _, err := a.runCommandTimeout(auditCommandTimeout, "adb", "pull", remote, tmp); err != nil {
|
||||
return report, fmt.Errorf("failed to pull APK: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
if info, statErr := os.Stat(tmp); statErr == nil {
|
||||
report.ApkSize = info.Size()
|
||||
}
|
||||
|
||||
// Scan DEX bytecode for tracker signatures.
|
||||
trackerHits := map[string]int{}
|
||||
if zr, zerr := zip.OpenReader(tmp); zerr == nil {
|
||||
for _, f := range zr.File {
|
||||
if !strings.HasPrefix(f.Name, "classes") || !strings.HasSuffix(f.Name, ".dex") {
|
||||
continue
|
||||
}
|
||||
if f.UncompressedSize64 > maxDexBytes {
|
||||
continue
|
||||
}
|
||||
if data := readZipEntry(f); data != nil {
|
||||
matchTrackers(data, trackerHits)
|
||||
}
|
||||
}
|
||||
zr.Close()
|
||||
} else {
|
||||
return report, fmt.Errorf("could not open pulled APK: %w", zerr)
|
||||
}
|
||||
for name, n := range trackerHits {
|
||||
report.Trackers = append(report.Trackers, PrivacyTracker{
|
||||
Name: name, Category: trackerCategory[name], Matches: n,
|
||||
})
|
||||
}
|
||||
// Heaviest categories first, then alphabetical.
|
||||
sort.Slice(report.Trackers, func(i, j int) bool {
|
||||
wi, wj := privacyCategoryWeight[report.Trackers[i].Category], privacyCategoryWeight[report.Trackers[j].Category]
|
||||
if wi != wj {
|
||||
return wi > wj
|
||||
}
|
||||
return report.Trackers[i].Name < report.Trackers[j].Name
|
||||
})
|
||||
report.TrackerCount = len(report.Trackers)
|
||||
|
||||
// Declared dangerous permissions (a permission named anywhere in the package
|
||||
// dump is declared/involved for this package).
|
||||
report.DangerousPermissions = a.declaredDangerousPermissions(packageName)
|
||||
|
||||
report.Score, report.Grade = computePrivacyScore(report.Trackers, report.DangerousPermissions)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// declaredDangerousPermissions returns the dangerous permissions the package
|
||||
// declares, read from its dumpsys output.
|
||||
func (a *App) declaredDangerousPermissions(packageName string) []string {
|
||||
dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
|
||||
if err != nil || dump == "" {
|
||||
return nil
|
||||
}
|
||||
var found []string
|
||||
for perm := range dangerousPermissions {
|
||||
if strings.Contains(dump, perm) {
|
||||
found = append(found, perm)
|
||||
}
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found
|
||||
}
|
||||
|
||||
// computePrivacyScore derives a 0-100 score (higher = more private) and an A-F
|
||||
// grade from the detected trackers and declared dangerous permissions. Trackers
|
||||
// dominate; permissions are a secondary, capped penalty.
|
||||
func computePrivacyScore(trackers []PrivacyTracker, dangerousPerms []string) (int, string) {
|
||||
score := 100
|
||||
for _, t := range trackers {
|
||||
w, ok := privacyCategoryWeight[t.Category]
|
||||
if !ok {
|
||||
w = defaultTrackerWeight
|
||||
}
|
||||
score -= w
|
||||
}
|
||||
// Permissions: -2 each, capped at -24 so a permission-heavy but tracker-free
|
||||
// app (e.g. a camera app) isn't punished as hard as a tracker-laden one.
|
||||
permPenalty := len(dangerousPerms) * 2
|
||||
if permPenalty > 24 {
|
||||
permPenalty = 24
|
||||
}
|
||||
score -= permPenalty
|
||||
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
var grade string
|
||||
switch {
|
||||
case score >= 85:
|
||||
grade = "A"
|
||||
case score >= 70:
|
||||
grade = "B"
|
||||
case score >= 55:
|
||||
grade = "C"
|
||||
case score >= 40:
|
||||
grade = "D"
|
||||
default:
|
||||
grade = "F"
|
||||
}
|
||||
return score, grade
|
||||
}
|
||||
|
|
@ -13,11 +13,13 @@ import ViewDebloater from './components/views/ViewDebloater'
|
|||
import ViewShell from './components/views/ViewShell'
|
||||
import ViewLogcat from './components/views/ViewLogcat'
|
||||
import ViewAppInspect from './components/views/ViewAppInspect'
|
||||
import ViewIntentLab from './components/views/ViewIntentLab'
|
||||
import ViewApkAudit from './components/views/ViewApkAudit'
|
||||
import ViewCerts from './components/views/ViewCerts'
|
||||
import ViewBackup from './components/views/ViewBackup'
|
||||
import ViewProps from './components/views/ViewProps'
|
||||
import ViewFlasher from './components/views/ViewFlasher'
|
||||
import ViewGsiLoader from './components/views/ViewGsiLoader'
|
||||
import ViewUtilities from './components/views/ViewUtilities'
|
||||
import ViewSettings from './components/views/ViewSettings'
|
||||
import { CheckSystemRequirements } from './lib/wails'
|
||||
|
|
@ -57,11 +59,13 @@ export default function App() {
|
|||
case 'shell': return <ViewShell />
|
||||
case 'logcat': return <ViewLogcat />
|
||||
case 'appinspect': return <ViewAppInspect />
|
||||
case 'intentlab': return <ViewIntentLab />
|
||||
case 'apkaudit': return <ViewApkAudit />
|
||||
case 'certs': return <ViewCerts />
|
||||
case 'backup': return <ViewBackup />
|
||||
case 'props': return <ViewProps />
|
||||
case 'flasher': return <ViewFlasher />
|
||||
case 'gsiloader': return <ViewGsiLoader />
|
||||
case 'utilities': return <ViewUtilities />
|
||||
case 'settings': return <ViewSettings />
|
||||
default: return <ViewDashboard />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
LayoutDashboard, FolderOpen, Package, Terminal,
|
||||
Zap, Wrench, Settings, Shield,
|
||||
ScrollText, Search, Lock, Archive, SlidersHorizontal, ScanSearch, MonitorSmartphone
|
||||
ScrollText, Search, Lock, Archive, SlidersHorizontal, ScanSearch, MonitorSmartphone, Rocket, HardDriveDownload
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import type { View } from '../../lib/types'
|
||||
|
|
@ -26,12 +26,14 @@ const navItems: NavItem[] = [
|
|||
{ view: 'shell', icon: <Terminal size={17} />, label: 'Shell' },
|
||||
{ view: 'logcat', icon: <ScrollText size={17} />, label: 'Logcat', dividerBefore: true },
|
||||
{ view: 'appinspect', icon: <Search size={17} />, label: 'App Inspector' },
|
||||
{ view: 'intentlab', icon: <Rocket size={17} />, label: 'Intent Lab' },
|
||||
{ view: 'apkaudit', icon: <ScanSearch size={17} />, label: 'APK Audit' },
|
||||
{ view: 'certs', icon: <Lock size={17} />, label: 'Certificates' },
|
||||
{ view: 'backup', icon: <Archive size={17} />, label: 'Backup' },
|
||||
{ view: 'props', icon: <SlidersHorizontal size={17}/>, label: 'Prop Editor' },
|
||||
{ view: 'utilities', icon: <Wrench size={17} />, label: 'Utilities', dividerBefore: true },
|
||||
{ view: 'flasher', icon: <Zap size={17} />, label: 'Flasher' },
|
||||
{ view: 'gsiloader', icon: <HardDriveDownload size={17} />, label: 'GSI Loader' },
|
||||
]
|
||||
|
||||
interface DragProps {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
ReadAPKEntry, ExportAudit,
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { CodeView, detectLang } from '../../lib/syntax'
|
||||
import type { APKAudit, APKAuditFinding, APKEntryContent, PackageInfo } from '../../lib/types'
|
||||
|
||||
type Tab = 'overview' | 'findings' | 'manifest' | 'components' | 'cert' | 'explorer'
|
||||
|
|
@ -531,7 +532,11 @@ export default function ViewApkAudit() {
|
|||
</div>
|
||||
)}
|
||||
{entry?.kind === 'text' && (
|
||||
<pre className="mono text-[11px] text-text-secondary whitespace-pre-wrap break-words leading-relaxed p-3">{entry.text}</pre>
|
||||
<CodeView
|
||||
code={entry.text || ''}
|
||||
lang={detectLang(entry.name || entryPath, entry.text || '')}
|
||||
className="mono text-[11px] text-text-secondary whitespace-pre-wrap break-words leading-relaxed p-3"
|
||||
/>
|
||||
)}
|
||||
{entry?.kind === 'binary' && (
|
||||
<pre className="mono text-[11px] text-text-secondary whitespace-pre p-3 leading-snug">{entry.hex}</pre>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { useState } from 'react'
|
||||
import { Search, Package, Shield, Activity, Server, Database, Cpu, FileCode, AlertTriangle } from 'lucide-react'
|
||||
import { InspectApp, CheckPinning, ListPackages } from '../../lib/wails'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Package, Shield, ShieldCheck, Activity, Server, Database, Cpu, FileCode, AlertTriangle, Radar } from 'lucide-react'
|
||||
import { InspectApp, CheckPinning, ListPackages, ScanAppPrivacy } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { AppInspection, PackageInfo } from '../../lib/types'
|
||||
import { CodeView } from '../../lib/syntax'
|
||||
import type { AppInspection, PackageInfo, PrivacyReport } from '../../lib/types'
|
||||
|
||||
// Privacy grade -> tailwind text/badge colour.
|
||||
const GRADE_COLOR: Record<string, string> = {
|
||||
A: 'text-accent-green', B: 'text-accent-green', C: 'text-warn', D: 'text-warn', F: 'text-danger',
|
||||
}
|
||||
|
||||
export default function ViewAppInspect() {
|
||||
const [search, setSearch] = useState('')
|
||||
|
|
@ -13,6 +19,8 @@ export default function ViewAppInspect() {
|
|||
const [pinning, setPinning] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [showManifest, setShowManifest] = useState(false)
|
||||
const [privacy, setPrivacy] = useState<PrivacyReport | null>(null)
|
||||
const [privacyLoading, setPrivacyLoading] = useState(false)
|
||||
// Width of the package picker rail. Draggable so long package names (which
|
||||
// truncate at the old fixed 256px) can be read in full. Persisted.
|
||||
const [panelW, setPanelW] = useState(() => {
|
||||
|
|
@ -49,11 +57,16 @@ export default function ViewAppInspect() {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
// Populate the picker as soon as the view opens (so it isn't empty until the
|
||||
// search box is focused). Safe with no device — it just stays empty.
|
||||
useEffect(() => { loadPackages() }, [])
|
||||
|
||||
const inspect = async (pkg: string) => {
|
||||
if (!pkg.trim()) return
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
setPinning('')
|
||||
setPrivacy(null)
|
||||
setActiveTab('overview')
|
||||
try {
|
||||
const data = await InspectApp(pkg.trim())
|
||||
|
|
@ -65,6 +78,19 @@ export default function ViewAppInspect() {
|
|||
}
|
||||
}
|
||||
|
||||
const scanPrivacy = async () => {
|
||||
if (!result) return
|
||||
setPrivacyLoading(true)
|
||||
try {
|
||||
const rep = await ScanAppPrivacy(result.packageName)
|
||||
setPrivacy(rep)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setPrivacyLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkPinning = async () => {
|
||||
if (!result) return
|
||||
try {
|
||||
|
|
@ -81,6 +107,7 @@ export default function ViewAppInspect() {
|
|||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: <Package size={12} /> },
|
||||
{ id: 'privacy', label: privacy ? `Privacy (${privacy.grade})` : 'Privacy', icon: <ShieldCheck size={12} /> },
|
||||
{ id: 'permissions', label: `Permissions (${result?.permissions?.length || 0})`, icon: <Shield size={12} /> },
|
||||
{ id: 'components', label: 'Components', icon: <Activity size={12} /> },
|
||||
{ id: 'libs', label: 'Native Libs', icon: <Cpu size={12} /> },
|
||||
|
|
@ -218,6 +245,100 @@ export default function ViewAppInspect() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<div className="space-y-4">
|
||||
{!privacy && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8 text-center">
|
||||
<Radar size={28} className="text-text-muted opacity-40" />
|
||||
<p className="text-xs text-text-muted max-w-sm">
|
||||
Scans the app's bytecode for known tracker / analytics / ad SDKs and
|
||||
cross-references dangerous permissions to compute a privacy score.
|
||||
Pulls the APK off the device — may take a few seconds.
|
||||
</p>
|
||||
<button onClick={scanPrivacy} disabled={privacyLoading} className="btn-primary text-xs">
|
||||
{privacyLoading
|
||||
? <><div className="w-3 h-3 border-2 border-bg-base border-t-transparent rounded-full animate-spin" /> Scanning...</>
|
||||
: <><Radar size={12} /> Scan privacy</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{privacy && (
|
||||
<>
|
||||
{/* Score header */}
|
||||
<div className="flex items-center gap-4 rounded border border-bg-border bg-bg-raised p-4">
|
||||
<div className={`text-4xl font-bold ${GRADE_COLOR[privacy.grade] || 'text-text-primary'}`}>
|
||||
{privacy.grade}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-lg font-semibold ${GRADE_COLOR[privacy.grade] || 'text-text-primary'}`}>{privacy.score}</span>
|
||||
<span className="text-xs text-text-muted">/ 100 privacy score</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{privacy.trackerCount} tracker{privacy.trackerCount === 1 ? '' : 's'} ·{' '}
|
||||
{privacy.dangerousPermissions.length} dangerous permission{privacy.dangerousPermissions.length === 1 ? '' : 's'}
|
||||
{privacy.apkSize > 0 && <> · {(privacy.apkSize / 1048576).toFixed(1)} MB APK</>}
|
||||
</p>
|
||||
{/* Score bar */}
|
||||
<div className="mt-2 h-1.5 rounded-full bg-bg-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${privacy.score >= 70 ? 'bg-accent-green' : privacy.score >= 40 ? 'bg-warn' : 'bg-danger'}`}
|
||||
style={{ width: `${privacy.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={scanPrivacy} disabled={privacyLoading} className="btn-ghost text-xs shrink-0">
|
||||
<Radar size={12} /> {privacyLoading ? 'Scanning...' : 'Rescan'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Trackers */}
|
||||
<div>
|
||||
<p className="section-title mb-2">Trackers ({privacy.trackerCount})</p>
|
||||
{privacy.trackerCount === 0 && (
|
||||
<p className="text-xs text-accent-green flex items-center gap-1.5">
|
||||
<ShieldCheck size={12} /> No known trackers detected in bytecode.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{privacy.trackers.map(t => (
|
||||
<div key={t.name} className="flex items-center gap-2 rounded border border-bg-border bg-bg-surface px-2.5 py-1.5" title={`${t.matches} signature match${t.matches === 1 ? '' : 'es'}`}>
|
||||
<Radar size={11} className="text-danger shrink-0" />
|
||||
<span className="text-xs text-text-primary">{t.name}</span>
|
||||
<span className="badge-gray text-xs">{t.category}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dangerous permissions */}
|
||||
<div>
|
||||
<p className="section-title mb-2">Dangerous permissions ({privacy.dangerousPermissions.length})</p>
|
||||
{privacy.dangerousPermissions.length === 0 && (
|
||||
<p className="text-xs text-text-muted">None declared.</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{privacy.dangerousPermissions.map(p => (
|
||||
<div key={p} className="flex items-center gap-2 py-1 border-b border-bg-border/30">
|
||||
<AlertTriangle size={11} className="text-warn shrink-0" />
|
||||
<span className="mono text-xs text-text-secondary">{p}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted leading-relaxed pt-1">
|
||||
Heuristic: matches known SDK package signatures in DEX bytecode (string
|
||||
constants aren't decrypted, so obfuscated/encrypted trackers may be missed)
|
||||
and counts declared Android "dangerous" permissions. A lower score means more
|
||||
trackers / invasive permissions.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'permissions' && (
|
||||
<div className="space-y-1">
|
||||
{result.permissions?.length === 0 && (
|
||||
|
|
@ -295,9 +416,11 @@ export default function ViewAppInspect() {
|
|||
{showManifest ? 'Hide' : 'Show'} full package dump ({result.manifestDump?.split('\n').length} lines)
|
||||
</button>
|
||||
{showManifest && (
|
||||
<pre className="mono text-xs text-text-secondary whitespace-pre-wrap break-words leading-relaxed bg-bg-raised rounded p-3 border border-bg-border max-h-[60vh] overflow-auto">
|
||||
{result.manifestDump}
|
||||
</pre>
|
||||
<CodeView
|
||||
code={result.manifestDump || ''}
|
||||
lang="log"
|
||||
className="mono text-xs text-text-secondary whitespace-pre-wrap break-words leading-relaxed bg-bg-raised rounded p-3 border border-bg-border max-h-[60vh] overflow-auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Shield, RefreshCw, Search, Trash2, PowerOff, Zap, RotateCcw, AlertTriangle, Check, X, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Shield, RefreshCw, Search, Trash2, PowerOff, Zap, RotateCcw, AlertTriangle, Check, X, HelpCircle, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ListPackages, DisableMultiplePackages, UninstallMultiplePackages, UninstallAndDisableMultiplePackages, RestoreMultiplePackages } from '../../lib/wails'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import { notify } from '../../lib/notify'
|
||||
|
|
@ -8,10 +8,42 @@ import { DEBLOAT_CATEGORIES } from '../../lib/debloat_db'
|
|||
import type { Safety } from '../../lib/debloat_db'
|
||||
import type { PackageInfo } from '../../lib/types'
|
||||
|
||||
const SAFETY_CONFIG: Record<Safety, { label: string; cls: string; icon: React.ReactNode }> = {
|
||||
// Display safety includes 'unknown' for device packages not in the UAD database.
|
||||
type RowSafety = Safety | 'unknown'
|
||||
|
||||
const UNCATEGORIZED = 'Uncategorized'
|
||||
|
||||
const SAFETY_CONFIG: Record<RowSafety, { label: string; cls: string; icon: React.ReactNode }> = {
|
||||
safe: { label: 'Safe', cls: 'badge-green', icon: <Check size={10} /> },
|
||||
caution: { label: 'Caution', cls: 'badge-yellow', icon: <AlertTriangle size={10} /> },
|
||||
keep: { label: 'Keep', cls: 'badge-red', icon: <X size={10} /> },
|
||||
unknown: { label: 'Unknown', cls: 'badge-gray', icon: <HelpCircle size={10} /> },
|
||||
}
|
||||
|
||||
// A single package row shown in the list. Device packages are enriched from the
|
||||
// UAD database where a match exists; unmatched device packages fall into
|
||||
// 'Uncategorized' with 'unknown' safety.
|
||||
interface Row {
|
||||
pkg: string
|
||||
label: string
|
||||
description: string
|
||||
safety: RowSafety
|
||||
category: string
|
||||
deps?: string[]
|
||||
neededBy?: string[]
|
||||
isInstalled: boolean
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
// Order categories appear in: the UAD categories in their defined order, then
|
||||
// the catch-all Uncategorized group last.
|
||||
const CATEGORY_ORDER = [...DEBLOAT_CATEGORIES.map(c => c.name), UNCATEGORIZED]
|
||||
|
||||
// Derive a readable label from a bare package name for uncategorized packages,
|
||||
// e.g. "com.sec.android.app.launcher" -> "Launcher".
|
||||
function shortLabel(pkg: string): string {
|
||||
const seg = pkg.split('.').filter(Boolean).pop() || pkg
|
||||
return seg.charAt(0).toUpperCase() + seg.slice(1)
|
||||
}
|
||||
|
||||
export default function ViewDebloater() {
|
||||
|
|
@ -20,12 +52,29 @@ export default function ViewDebloater() {
|
|||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [safetyFilter, setSafety] = useState<Safety | 'all'>('all')
|
||||
const [safetyFilter, setSafety] = useState<RowSafety | 'all'>('all')
|
||||
const [mfrFilter, setMfrFilter] = useState('all')
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
||||
const [operating, setOperating] = useState(false)
|
||||
const [stateFilter, setStateFilter] = useState<'installed' | 'enabled' | 'disabled' | 'notinstalled' | 'all'>('installed')
|
||||
|
||||
// pkg -> UAD database entry (with its category). Built once; first match wins.
|
||||
const dbIndex = useMemo(() => {
|
||||
const m = new Map<string, Row>()
|
||||
for (const cat of DEBLOAT_CATEGORIES) {
|
||||
for (const p of cat.packages) {
|
||||
if (!m.has(p.pkg)) {
|
||||
m.set(p.pkg, {
|
||||
pkg: p.pkg, label: p.label, description: p.description, safety: p.safety,
|
||||
category: cat.name, deps: p.deps, neededBy: p.neededBy,
|
||||
isInstalled: false, isDisabled: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}, [])
|
||||
|
||||
const loadInstalled = async () => {
|
||||
setLoading(true)
|
||||
setInstalled(new Set())
|
||||
|
|
@ -36,11 +85,9 @@ export default function ViewDebloater() {
|
|||
const names = new Set<string>((pkgs || []).map((p: PackageInfo) => p.packageName))
|
||||
setInstalled(names)
|
||||
setDisabled(new Set<string>((pkgs || []).filter((p: PackageInfo) => !p.isEnabled).map((p: PackageInfo) => p.packageName)))
|
||||
// Auto-open categories that have installed packages
|
||||
// Auto-open every category that has at least one package on the device.
|
||||
const withInstalled = new Set<string>()
|
||||
DEBLOAT_CATEGORIES.forEach(cat => {
|
||||
if (cat.packages.some(p => names.has(p.pkg))) withInstalled.add(cat.name)
|
||||
})
|
||||
names.forEach(name => withInstalled.add(dbIndex.get(name)?.category ?? UNCATEGORIZED))
|
||||
setOpenCats(withInstalled)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
|
|
@ -51,38 +98,62 @@ export default function ViewDebloater() {
|
|||
|
||||
useEffect(() => { loadInstalled() }, [])
|
||||
|
||||
const manufacturers = useMemo(() => ['all', ...DEBLOAT_CATEGORIES.map(c => c.name)], [])
|
||||
const manufacturers = useMemo(() => ['all', ...CATEGORY_ORDER], [])
|
||||
|
||||
// The unified row set: every device package (enriched or uncategorized), plus
|
||||
// database-only packages so the "Not installed" / "All" filters can browse the
|
||||
// full UAD catalogue.
|
||||
const allRows = useMemo(() => {
|
||||
const rows: Row[] = []
|
||||
installed.forEach(name => {
|
||||
const e = dbIndex.get(name)
|
||||
if (e) {
|
||||
rows.push({ ...e, isInstalled: true, isDisabled: disabled.has(name) })
|
||||
} else {
|
||||
rows.push({
|
||||
pkg: name, label: shortLabel(name),
|
||||
description: 'Not in the debloat database — likely an OEM, carrier, or region-specific package. Safety unknown; research before removing.',
|
||||
safety: 'unknown', category: UNCATEGORIZED,
|
||||
isInstalled: true, isDisabled: disabled.has(name),
|
||||
})
|
||||
}
|
||||
})
|
||||
dbIndex.forEach((e, pkg) => {
|
||||
if (!installed.has(pkg)) rows.push({ ...e, isInstalled: false, isDisabled: false })
|
||||
})
|
||||
return rows
|
||||
}, [installed, disabled, dbIndex])
|
||||
|
||||
const visibleCategories = useMemo(() => {
|
||||
return DEBLOAT_CATEGORIES
|
||||
.filter(cat => mfrFilter === 'all' || cat.name === mfrFilter)
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
packages: cat.packages.filter(p => {
|
||||
if (safetyFilter !== 'all' && p.safety !== safetyFilter) return false
|
||||
const inst = installed.has(p.pkg)
|
||||
const dis = disabled.has(p.pkg)
|
||||
switch (stateFilter) {
|
||||
case 'installed': if (!inst) return false; break
|
||||
case 'enabled': if (!inst || dis) return false; break
|
||||
case 'disabled': if (!dis) return false; break
|
||||
case 'notinstalled': if (inst) return false; break
|
||||
// 'all' → no state restriction
|
||||
}
|
||||
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, disabled, stateFilter])
|
||||
const q = search.toLowerCase()
|
||||
const byCat = new Map<string, Row[]>()
|
||||
for (const r of allRows) {
|
||||
if (mfrFilter !== 'all' && r.category !== mfrFilter) continue
|
||||
if (safetyFilter !== 'all' && r.safety !== safetyFilter) continue
|
||||
switch (stateFilter) {
|
||||
case 'installed': if (!r.isInstalled) continue; break
|
||||
case 'enabled': if (!r.isInstalled || r.isDisabled) continue; break
|
||||
case 'disabled': if (!r.isDisabled) continue; break
|
||||
case 'notinstalled': if (r.isInstalled) continue; break
|
||||
// 'all' → no state restriction
|
||||
}
|
||||
if (q && !(r.pkg.toLowerCase().includes(q) || r.label.toLowerCase().includes(q) || r.description.toLowerCase().includes(q))) continue
|
||||
if (!byCat.has(r.category)) byCat.set(r.category, [])
|
||||
byCat.get(r.category)!.push(r)
|
||||
}
|
||||
return CATEGORY_ORDER
|
||||
.filter(name => byCat.has(name))
|
||||
.map(name => ({ name, packages: byCat.get(name)!.sort((a, b) => a.pkg.localeCompare(b.pkg)) }))
|
||||
}, [allRows, search, safetyFilter, mfrFilter, stateFilter])
|
||||
|
||||
const totalInstalled = useMemo(() =>
|
||||
DEBLOAT_CATEGORIES.reduce((n, cat) => n + cat.packages.filter(p => installed.has(p.pkg)).length, 0),
|
||||
[installed]
|
||||
)
|
||||
// Device counts — mirror the Packages tab (deviceCount) and explain the gap.
|
||||
const deviceCount = installed.size
|
||||
const cataloguedCount = useMemo(() => {
|
||||
let n = 0
|
||||
installed.forEach(name => { if (dbIndex.has(name)) n++ })
|
||||
return n
|
||||
}, [installed, dbIndex])
|
||||
const uncategorizedCount = deviceCount - cataloguedCount
|
||||
|
||||
const toggleCat = (name: string) => setOpenCats(prev => {
|
||||
const next = new Set(prev)
|
||||
|
|
@ -97,11 +168,8 @@ export default function ViewDebloater() {
|
|||
})
|
||||
|
||||
const selectAllVisible = () => {
|
||||
const selectable = visibleCategories
|
||||
.flatMap(c => c.packages)
|
||||
.filter(p => installed.has(p.pkg) && p.safety !== 'keep')
|
||||
.map(p => p.pkg)
|
||||
if (selected.size === selectable.length) {
|
||||
const selectable = visibleCategories.flatMap(c => c.packages).map(p => p.pkg)
|
||||
if (selected.size > 0 && selected.size >= selectable.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(selectable))
|
||||
|
|
@ -134,24 +202,26 @@ export default function ViewDebloater() {
|
|||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 flex-wrap shrink-0 bg-bg-surface">
|
||||
<Shield size={14} className="text-accent-green shrink-0" />
|
||||
<span className="text-xs text-text-secondary">
|
||||
{loading ? 'Scanning device...' : `${totalInstalled} of ${DEBLOAT_CATEGORIES.reduce((n,c)=>n+c.packages.length,0)} packages found on device`}
|
||||
{loading
|
||||
? 'Scanning device...'
|
||||
: `${deviceCount} on device · ${cataloguedCount} catalogued · ${uncategorizedCount} uncategorized`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Manufacturer filter */}
|
||||
{/* Manufacturer / category filter */}
|
||||
<select
|
||||
className="input text-xs w-36 py-1"
|
||||
value={mfrFilter}
|
||||
onChange={e => setMfrFilter(e.target.value)}
|
||||
>
|
||||
{manufacturers.map(m => (
|
||||
<option key={m} value={m}>{m === 'all' ? 'All manufacturers' : m}</option>
|
||||
<option key={m} value={m}>{m === 'all' ? 'All categories' : m}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Safety filter */}
|
||||
<div className="flex gap-0.5 bg-bg-raised rounded p-0.5">
|
||||
{(['all', 'safe', 'caution', 'keep'] as const).map(f => (
|
||||
{(['all', 'safe', 'caution', 'keep', 'unknown'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setSafety(f)}
|
||||
|
|
@ -197,7 +267,7 @@ export default function ViewDebloater() {
|
|||
<DismissibleBanner id="warn-debloater" className="bg-warn/5 border-b border-warn/20 px-4 py-2 shrink-0 text-warn">
|
||||
<AlertTriangle size={13} className="text-warn shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-warn/80">
|
||||
<span className="font-medium">Always prefer Disable over Uninstall.</span> Never remove packages marked <span className="text-danger font-medium">Keep</span> — they will break your device. Source: Universal Android Debloater (UAD-ng), 5362 packages.
|
||||
<span className="font-medium">Always prefer Disable over Uninstall.</span> Packages marked <span className="text-danger font-medium">Keep</span> are device-critical — removing them can break your device. <span className="font-medium">Uncategorized</span> packages aren't in the debloat database; research before removing. Safety data: Universal Android Debloater (UAD-ng).
|
||||
</p>
|
||||
</DismissibleBanner>
|
||||
|
||||
|
|
@ -255,14 +325,14 @@ export default function ViewDebloater() {
|
|||
<div className="flex flex-col items-center justify-center h-32 gap-2 text-text-muted">
|
||||
<Shield size={24} className="opacity-30" />
|
||||
<p className="text-sm">No packages match current filters</p>
|
||||
{stateFilter !== 'notinstalled' && totalInstalled === 0 && (
|
||||
{stateFilter !== 'notinstalled' && deviceCount === 0 && (
|
||||
<p className="text-xs">Try clicking "Scan" to detect installed packages</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && visibleCategories.map(cat => {
|
||||
const installedCount = cat.packages.filter(p => installed.has(p.pkg)).length
|
||||
const installedCount = cat.packages.filter(p => p.isInstalled).length
|
||||
const isOpen = openCats.has(cat.name)
|
||||
|
||||
return (
|
||||
|
|
@ -288,8 +358,8 @@ export default function ViewDebloater() {
|
|||
|
||||
{/* Packages */}
|
||||
{isOpen && cat.packages.map(p => {
|
||||
const isInst = installed.has(p.pkg)
|
||||
const isDisabled = disabled.has(p.pkg)
|
||||
const isInst = p.isInstalled
|
||||
const isDisabled = p.isDisabled
|
||||
const isSel = selected.has(p.pkg)
|
||||
const safety = SAFETY_CONFIG[p.safety]
|
||||
|
||||
|
|
@ -298,16 +368,15 @@ export default function ViewDebloater() {
|
|||
key={p.pkg}
|
||||
className={`
|
||||
flex items-start gap-3 px-4 py-2 border-t border-bg-border/30 transition-colors
|
||||
${p.safety !== 'keep' ? 'hover:bg-bg-raised cursor-pointer' : ''}
|
||||
hover:bg-bg-raised cursor-pointer
|
||||
${!isInst ? 'opacity-60' : ''}
|
||||
${isSel ? 'bg-accent-green/5' : ''}
|
||||
`}
|
||||
onClick={() => p.safety !== 'keep' && toggleSelect(p.pkg)}
|
||||
onClick={() => toggleSelect(p.pkg)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSel}
|
||||
disabled={p.safety === 'keep'}
|
||||
onChange={() => toggleSelect(p.pkg)}
|
||||
className="accent-accent-green mt-0.5 shrink-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
|
|
@ -340,9 +409,9 @@ export default function ViewDebloater() {
|
|||
|
||||
{/* Status bar */}
|
||||
<div className="border-t border-bg-border px-4 py-1.5 flex items-center justify-between text-xs text-text-muted shrink-0">
|
||||
<span>{totalInstalled} debloat candidates on device · {DEBLOAT_CATEGORIES.reduce((n,c)=>n+c.packages.length,0)} total in database</span>
|
||||
<span>{deviceCount} on device · {cataloguedCount} catalogued · {uncategorizedCount} uncategorized · {DEBLOAT_CATEGORIES.reduce((n,c)=>n+c.packages.length,0)} in database</span>
|
||||
<button onClick={selectAllVisible} className="hover:text-text-secondary transition-colors">
|
||||
{selected.size > 0 ? 'Deselect all' : 'Select all safe+caution'}
|
||||
{selected.size > 0 ? 'Deselect all' : 'Select all visible'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
SelectFileForPush, CancelOperation
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { CodeView, detectLang } from '../../lib/syntax'
|
||||
import type { FileEntry } from '../../lib/types'
|
||||
|
||||
// Wails runtime is injected on window['runtime'] (same access as ViewLogcat).
|
||||
|
|
@ -41,6 +42,8 @@ export default function ViewFiles() {
|
|||
const [viewer, setViewer] = useState<string | null>(null) // image filename being viewed
|
||||
const [imgLoading, setImgLoading] = useState(false)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const [textView, setTextView] = useState<{ name: string; content: string } | null>(null)
|
||||
const [textLoading, setTextLoading] = useState(false)
|
||||
const progRef = useRef<{ label: string; t0: number } | null>(null)
|
||||
// Remembered path per source + the last device dir (push destination default).
|
||||
const remembered = useRef<Record<Source, string>>({ device: '/sdcard', local: '' })
|
||||
|
|
@ -260,6 +263,32 @@ export default function ViewFiles() {
|
|||
)
|
||||
const openViewer = (name: string) => { setViewer(name); setImgLoading(true); setImgError(false) }
|
||||
|
||||
// Text preview: fetch the file's bytes via the same /__file route the image
|
||||
// viewer uses (works for device and local), cap the size, and show highlighted.
|
||||
const openText = async (name: string) => {
|
||||
setTextView({ name, content: '' })
|
||||
setTextLoading(true)
|
||||
try {
|
||||
const res = await fetch(fileURL(name))
|
||||
let t = await res.text()
|
||||
if (t.length > 400000) t = t.slice(0, 400000) + '\n\n… (truncated at 400 KB)'
|
||||
setTextView({ name, content: t })
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
setTextView(null)
|
||||
} finally {
|
||||
setTextLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc closes the text viewer.
|
||||
useEffect(() => {
|
||||
if (!textView) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setTextView(null) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [textView])
|
||||
|
||||
const stepViewer = useCallback((delta: number) => {
|
||||
setViewer(cur => {
|
||||
if (!cur) return cur
|
||||
|
|
@ -288,6 +317,7 @@ export default function ViewFiles() {
|
|||
const open = (entry: FileEntry) => {
|
||||
if (entry.type === 'Directory' || entry.type === 'Symlink') navigate(entry)
|
||||
else if (isImage(entry.name)) openViewer(entry.name)
|
||||
else if (isText(entry.name)) openText(entry.name)
|
||||
}
|
||||
|
||||
const formatSize = (size: string) => {
|
||||
|
|
@ -602,6 +632,33 @@ export default function ViewFiles() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Text viewer — highlighted preview for text/code/config files */}
|
||||
{textView && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-black/85 backdrop-blur-sm" onClick={() => setTextView(null)}>
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs text-text-secondary shrink-0">
|
||||
<span className="mono truncate">{textView.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); setTextView(null) }} className="btn-ghost p-1.5" title="Close (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden px-4 pb-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="h-full overflow-auto bg-bg-surface border border-bg-border rounded">
|
||||
{textLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<CodeView
|
||||
code={textView.content}
|
||||
lang={detectLang(textView.name, textView.content)}
|
||||
className="mono text-xs text-text-secondary whitespace-pre-wrap break-words leading-relaxed p-3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="border-t border-bg-border px-4 py-1.5 flex items-center justify-between text-xs text-text-muted">
|
||||
<span className="mono flex items-center gap-1.5">
|
||||
|
|
@ -638,3 +695,7 @@ function formatEta(ms: number): string {
|
|||
function isImage(name: string): boolean {
|
||||
return /\.(png|jpe?g|gif|webp|bmp|ico)$/i.test(name)
|
||||
}
|
||||
|
||||
function isText(name: string): boolean {
|
||||
return /\.(txt|xml|json|prop|conf|cfg|ini|env|log|sh|bash|rc|smali|java|kt|gradle|ya?ml|md|csv|html?|css|js|ts|toml|properties|list)$/i.test(name)
|
||||
}
|
||||
|
|
|
|||
256
frontend/src/components/views/ViewGsiLoader.tsx
Normal file
256
frontend/src/components/views/ViewGsiLoader.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { HardDriveDownload, Boxes, Zap, RefreshCw, Check, X, AlertTriangle, FileUp, Play, Power, Trash2, RotateCcw } from 'lucide-react'
|
||||
import {
|
||||
GsiCompat, GsiDsuStatus, InstallDsu, DsuEnable, DsuDisable, DsuWipe,
|
||||
FlashGsiSystem, SelectFileForFlash, Reboot,
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import type { GsiCompat as GsiCompatT } from '../../lib/types'
|
||||
|
||||
const GIB = 1073741824
|
||||
|
||||
export default function ViewGsiLoader() {
|
||||
const [tab, setTab] = useState<'dsu' | 'flash'>('dsu')
|
||||
const [compat, setCompat] = useState<GsiCompatT | null>(null)
|
||||
const [compatLoading, setCompatLoading] = useState(false)
|
||||
|
||||
// DSU
|
||||
const [dsuImage, setDsuImage] = useState('')
|
||||
const [systemSize, setSystemSize] = useState(0) // bytes; 0 = auto for raw .img
|
||||
const [userdataGiB, setUserdataGiB] = useState(8)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [pushPct, setPushPct] = useState(-1)
|
||||
const [dsuStatus, setDsuStatus] = useState('')
|
||||
|
||||
// Flash
|
||||
const [flashImage, setFlashImage] = useState('')
|
||||
const [vbmeta, setVbmeta] = useState('')
|
||||
const [opts, setOpts] = useState({ fastbootd: true, wipeData: true, disableVerity: false, deleteProduct: false, slot: '' })
|
||||
const [dryRun, setDryRun] = useState('')
|
||||
const [flashing, setFlashing] = useState(false)
|
||||
const [flashOut, setFlashOut] = useState('')
|
||||
|
||||
const loadCompat = async () => {
|
||||
setCompatLoading(true)
|
||||
try { setCompat(await GsiCompat()) } catch (e: any) { notify.error(e) } finally { setCompatLoading(false) }
|
||||
}
|
||||
useEffect(() => { loadCompat() }, [])
|
||||
|
||||
// Push progress for the DSU image upload.
|
||||
useEffect(() => {
|
||||
const rt = () => (window as any)['runtime']
|
||||
const onProg = (t: any) => { if (t?.label?.includes('atk-dsu') || t?.kind === 'push') setPushPct(t.percent) }
|
||||
const onDone = () => setPushPct(-1)
|
||||
const off1 = rt()?.EventsOn?.('transfer:progress', onProg)
|
||||
const off2 = rt()?.EventsOn?.('transfer:done', onDone)
|
||||
return () => { rt()?.EventsOff?.('transfer:progress'); rt()?.EventsOff?.('transfer:done'); off1?.(); off2?.() }
|
||||
}, [])
|
||||
|
||||
const pickImage = async (setter: (p: string) => void) => {
|
||||
try { const p = await SelectFileForFlash(); if (p) setter(p) } catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const refreshStatus = async () => {
|
||||
try { setDsuStatus((await GsiDsuStatus()) || '(no status)') } catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (!dsuImage) { notify.error('Select a GSI image first'); return }
|
||||
setInstalling(true); setPushPct(0)
|
||||
const id = notify.loading('Preparing & pushing GSI (this can take a while)...')
|
||||
try {
|
||||
const out = await InstallDsu(dsuImage, systemSize, userdataGiB * GIB)
|
||||
notify.dismiss(id); notify.success('DSU install launched'); setDsuStatus(out)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) } finally { setInstalling(false); setPushPct(-1) }
|
||||
}
|
||||
|
||||
const gsiTool = async (fn: () => Promise<string>, label: string) => {
|
||||
try { const out = await fn(); notify.success(`${label}: ${out || 'ok'}`); refreshStatus() } catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const previewFlash = async () => {
|
||||
if (!flashImage) { notify.error('Select a GSI system image first'); return }
|
||||
try { setDryRun(await FlashGsiSystem(flashImage, { ...opts, vbmetaPath: vbmeta, dryRun: true })) } catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const doFlash = async () => {
|
||||
if (!flashImage) { notify.error('Select a GSI system image first'); return }
|
||||
if (!confirm('Permanently flash this GSI to the system partition?\n\nThis ERASES system, wipes userdata, and requires an unlocked bootloader. If the GSI is incompatible the device may not boot. Continue?')) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setFlashing(true)
|
||||
const id = notify.loading('Flashing GSI via fastboot...')
|
||||
try {
|
||||
const out = await FlashGsiSystem(flashImage, { ...opts, vbmetaPath: vbmeta, dryRun: false })
|
||||
notify.dismiss(id); notify.success('GSI flashed'); setFlashOut(out)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e); setFlashOut(String(e?.message || e)) } finally { setFlashing(false) }
|
||||
}
|
||||
|
||||
const trebleOk = compat?.trebleEnabled
|
||||
const baseName = (p: string) => p.split('/').pop() || p
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header + tabs */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 shrink-0">
|
||||
<Boxes size={15} className="text-accent-green" />
|
||||
<span className="text-sm font-medium text-text-primary">GSI Loader</span>
|
||||
<div className="flex rounded overflow-hidden border border-bg-border ml-2">
|
||||
<button onClick={() => setTab('dsu')} className={`px-3 py-1 text-xs flex items-center gap-1 ${tab === 'dsu' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}>
|
||||
<HardDriveDownload size={12} /> DSU (Temporary)
|
||||
</button>
|
||||
<button onClick={() => setTab('flash')} className={`px-3 py-1 text-xs flex items-center gap-1 ${tab === 'flash' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}>
|
||||
<Zap size={12} /> GSI Flasher (Permanent)
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button onClick={loadCompat} disabled={compatLoading} className="btn-ghost text-xs">
|
||||
<RefreshCw size={12} className={compatLoading ? 'animate-spin' : ''} /> Recheck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compatibility panel */}
|
||||
<div className="border-b border-bg-border px-4 py-2 shrink-0 bg-bg-surface flex items-center gap-4 flex-wrap text-xs">
|
||||
<span className="section-title">Compatibility</span>
|
||||
{!compat && <span className="text-text-muted">{compatLoading ? 'Checking…' : 'No device / unknown'}</span>}
|
||||
{compat && (
|
||||
<>
|
||||
<span className={`flex items-center gap-1 ${trebleOk ? 'text-accent-green' : 'text-danger'}`}>
|
||||
{trebleOk ? <Check size={12} /> : <X size={12} />} Treble {trebleOk ? 'enabled' : 'NOT enabled'}
|
||||
</span>
|
||||
<span className="text-text-secondary">ABI: <span className="mono text-text-primary">{compat.abi || '?'}</span> → use <span className="mono text-accent-green">{compat.gsiArch || '?'}</span> GSI</span>
|
||||
<span className="text-text-secondary">Android {compat.androidRelease || '?'} (SDK {compat.sdk || '?'})</span>
|
||||
<span className={compat.vndkIsolated ? 'text-accent-green' : 'text-warn'}>
|
||||
{compat.vndkIsolated ? 'VNDK isolated — any newer GSI' : 'not VNDK-isolated — same-version GSI only'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{compat && !trebleOk && (
|
||||
<span className="flex items-center gap-1 text-danger"><AlertTriangle size={12} /> Device may not support GSIs</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{tab === 'dsu' ? (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<p className="text-xs text-text-muted leading-relaxed">
|
||||
Installs a GSI as a temporary <span className="text-text-secondary">guest OS</span> via Dynamic System Updates — no unlock,
|
||||
no data wipe. Pick a <span className="text-text-secondary">raw</span> (unsparsed) GSI <span className="mono">system.img</span> or a
|
||||
<span className="mono"> .gz</span> you made from one. After install, tap <span className="text-accent-green">Restart</span> in the device notification to boot it.
|
||||
</p>
|
||||
|
||||
{/* Image picker */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => pickImage(setDsuImage)} className="btn-ghost text-xs"><FileUp size={12} /> Select GSI image</button>
|
||||
<span className="mono text-xs text-text-secondary truncate">{dsuImage ? baseName(dsuImage) : 'no file selected'}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1">Userdata size (GiB)</p>
|
||||
<input type="number" min={1} className="input text-xs w-full" value={userdataGiB} onChange={e => setUserdataGiB(Math.max(1, Number(e.target.value)))} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1">System size (bytes) — auto for raw .img, <span className="text-warn">required for .gz</span></p>
|
||||
<input type="number" min={0} className="input text-xs w-full" value={systemSize} onChange={e => setSystemSize(Math.max(0, Number(e.target.value)))} placeholder="0 = auto (raw .img)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pushPct >= 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-bg-border rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent-green transition-all duration-200" style={{ width: `${pushPct}%` }} />
|
||||
</div>
|
||||
<span className="mono text-xs text-text-muted w-10 text-right">{pushPct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={install} disabled={installing || !dsuImage} className="btn-primary text-xs">
|
||||
<Play size={12} /> {installing ? 'Installing…' : 'Install DSU'}
|
||||
</button>
|
||||
<button onClick={() => Reboot('')} className="btn-ghost text-xs" title="Cold reboot — boots the GSI if just installed, or back to the host OS"><RotateCcw size={12} /> Reboot</button>
|
||||
</div>
|
||||
|
||||
{/* gsi_tool management */}
|
||||
<div className="border-t border-bg-border pt-3 space-y-2">
|
||||
<p className="section-title">DSU management (gsi_tool)</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={refreshStatus} className="btn-ghost text-xs"><RefreshCw size={12} /> Status</button>
|
||||
<button onClick={() => gsiTool(DsuEnable, 'enabled (sticky)')} className="btn-ghost text-xs"><Power size={12} /> Enable sticky</button>
|
||||
<button onClick={() => gsiTool(DsuDisable, 'disabled')} className="btn-ghost text-xs"><Power size={12} /> Disable</button>
|
||||
<button onClick={() => gsiTool(DsuWipe, 'wiped')} className="btn-danger text-xs"><Trash2 size={12} /> Wipe DSU</button>
|
||||
</div>
|
||||
{dsuStatus && <pre className="mono text-[11px] text-text-secondary whitespace-pre-wrap bg-bg-raised rounded p-2 border border-bg-border max-h-40 overflow-auto">{dsuStatus}</pre>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-start gap-2 rounded border border-danger/30 bg-danger/5 p-3">
|
||||
<AlertTriangle size={14} className="text-danger shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-danger/90">
|
||||
<span className="font-medium">Destructive & permanent.</span> Erases the system partition, wipes userdata, and needs an
|
||||
<span className="font-medium"> unlocked bootloader</span>. An incompatible GSI can leave the device unbootable — keep the stock factory image to recover. GSIs don't support rollback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => pickImage(setFlashImage)} className="btn-ghost text-xs"><FileUp size={12} /> Select GSI system.img</button>
|
||||
<span className="mono text-xs text-text-secondary truncate">{flashImage ? baseName(flashImage) : 'no file selected'}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
['fastbootd', 'Reboot to fastbootd first (dynamic partitions)'],
|
||||
['wipeData', 'Wipe userdata (fastboot -w)'],
|
||||
['disableVerity', 'Disable Verified Boot (flash vbmeta)'],
|
||||
['deleteProduct', 'Delete product partition (free space)'],
|
||||
] as const).map(([key, label]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" checked={(opts as any)[key]} onChange={e => setOpts(o => ({ ...o, [key]: e.target.checked }))} className="accent-accent-green" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(opts.disableVerity) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => pickImage(setVbmeta)} className="btn-ghost text-xs"><FileUp size={12} /> Select vbmeta.img</button>
|
||||
<span className="mono text-xs text-text-secondary truncate">{vbmeta ? baseName(vbmeta) : 'required for disable-verity'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(opts.deleteProduct) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">Active slot suffix:</span>
|
||||
<select className="input text-xs w-24" value={opts.slot} onChange={e => setOpts(o => ({ ...o, slot: e.target.value }))}>
|
||||
<option value="">(none)</option>
|
||||
<option value="a">a</option>
|
||||
<option value="b">b</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={previewFlash} disabled={!flashImage} className="btn-ghost text-xs">Dry run (preview commands)</button>
|
||||
<button onClick={doFlash} disabled={flashing || !flashImage} className="btn-danger text-xs"><Zap size={12} /> {flashing ? 'Flashing…' : 'Flash GSI'}</button>
|
||||
</div>
|
||||
|
||||
{dryRun && (
|
||||
<div>
|
||||
<p className="section-title mb-1">Command preview</p>
|
||||
<pre className="mono text-[11px] text-accent-green whitespace-pre-wrap bg-bg-raised rounded p-2 border border-bg-border">{dryRun}</pre>
|
||||
</div>
|
||||
)}
|
||||
{flashOut && (
|
||||
<div>
|
||||
<p className="section-title mb-1">Output</p>
|
||||
<pre className="mono text-[11px] text-text-secondary whitespace-pre-wrap bg-bg-raised rounded p-2 border border-bg-border max-h-60 overflow-auto">{flashOut}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
frontend/src/components/views/ViewIntentLab.tsx
Normal file
216
frontend/src/components/views/ViewIntentLab.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Search, Rocket, Play, Terminal, Package } from 'lucide-react'
|
||||
import { ListActivities, StartActivity, StartIntentAction, ListPackages } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { IntentActivity, PackageInfo } from '../../lib/types'
|
||||
|
||||
export default function ViewIntentLab() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [packages, setPackages] = useState<PackageInfo[]>([])
|
||||
const [pkgsLoaded, setPkgsLoaded] = useState(false)
|
||||
const [selected, setSelected] = useState('')
|
||||
const [activities, setActivities] = useState<IntentActivity[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actFilter, setActFilter] = useState('')
|
||||
const [lastResult, setLastResult] = useState('')
|
||||
|
||||
// Free-form implicit-intent launcher
|
||||
const [action, setAction] = useState('android.intent.action.VIEW')
|
||||
const [data, setData] = useState('')
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (pkgsLoaded) return
|
||||
try {
|
||||
const pkgs = await ListPackages('all')
|
||||
setPackages(pkgs || [])
|
||||
setPkgsLoaded(true)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Populate the picker on open so it isn't empty until the search box is focused.
|
||||
useEffect(() => { loadPackages() }, [])
|
||||
|
||||
const loadActivities = async (pkg: string) => {
|
||||
if (!pkg.trim()) return
|
||||
setSelected(pkg.trim())
|
||||
setLoading(true)
|
||||
setActivities([])
|
||||
setLastResult('')
|
||||
try {
|
||||
const acts = await ListActivities(pkg.trim())
|
||||
setActivities(acts || [])
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const launch = async (component: string) => {
|
||||
const id = notify.loading(`Launching ${component}...`)
|
||||
try {
|
||||
const out = await StartActivity(component)
|
||||
notify.dismiss(id)
|
||||
notify.success(out || 'Started')
|
||||
setLastResult(`✓ ${component}\n${out}`)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
setLastResult(`✗ ${component}\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
const launchIntent = async () => {
|
||||
if (!action.trim()) { notify.error('Enter an action'); return }
|
||||
const id = notify.loading('Launching intent...')
|
||||
try {
|
||||
const out = await StartIntentAction(action.trim(), data.trim())
|
||||
notify.dismiss(id)
|
||||
notify.success(out || 'Started')
|
||||
setLastResult(`✓ ${action}${data ? ' ' + data : ''}\n${out}`)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
setLastResult(`✗ ${action}\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPkgs = packages.filter(p => p.packageName.toLowerCase().includes(search.toLowerCase()))
|
||||
const filteredActs = activities.filter(a =>
|
||||
a.name.toLowerCase().includes(actFilter.toLowerCase()) ||
|
||||
a.component.toLowerCase().includes(actFilter.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Left: package picker */}
|
||||
<div className="w-72 shrink-0 border-r border-bg-border flex flex-col overflow-hidden">
|
||||
<div className="p-3 border-b border-bg-border space-y-2 shrink-0">
|
||||
<p className="section-title">Intent Lab</p>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-7 text-xs w-full"
|
||||
placeholder="Package name..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onFocus={loadPackages}
|
||||
onKeyDown={e => e.key === 'Enter' && loadActivities(search)}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => loadActivities(search)} disabled={!search || loading} className="btn-primary text-xs w-full justify-center">
|
||||
{loading ? 'Loading...' : 'List activities'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filteredPkgs.map(p => (
|
||||
<button
|
||||
key={p.packageName}
|
||||
onClick={() => { setSearch(p.packageName); loadActivities(p.packageName) }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-bg-raised transition-colors border-b border-bg-border/30 ${
|
||||
selected === p.packageName ? 'bg-accent-green/5 text-text-primary' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<p className="truncate mono">{p.packageName}</p>
|
||||
</button>
|
||||
))}
|
||||
{!pkgsLoaded && (
|
||||
<p className="text-text-muted text-xs text-center p-4">Focus the box to load the package list</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: launcher */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{!selected && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-muted">
|
||||
<Rocket size={32} className="opacity-20" />
|
||||
<p className="text-sm">Pick an app to see its launchable activities</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selected || loading) && (
|
||||
<>
|
||||
{/* Free-form implicit-intent launcher */}
|
||||
<div className="border-b border-bg-border p-3 shrink-0 space-y-2 bg-bg-surface">
|
||||
<p className="section-title flex items-center gap-1.5"><Terminal size={12} /> Implicit intent (action + data)</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1"
|
||||
placeholder="action, e.g. android.intent.action.VIEW"
|
||||
value={action}
|
||||
onChange={e => setAction(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input text-xs flex-1"
|
||||
placeholder="data URI (optional), e.g. https://example.com"
|
||||
value={data}
|
||||
onChange={e => setData(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && launchIntent()}
|
||||
/>
|
||||
<button onClick={launchIntent} className="btn-primary text-xs shrink-0">
|
||||
<Play size={12} /> Fire
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activities */}
|
||||
<div className="border-b border-bg-border px-3 py-2 shrink-0 flex items-center gap-2">
|
||||
<Package size={13} className="text-accent-green shrink-0" />
|
||||
<span className="mono text-xs text-text-primary truncate">{selected}</span>
|
||||
<span className="text-xs text-text-muted">· {activities.length} launchable</span>
|
||||
<div className="flex-1" />
|
||||
<div className="relative">
|
||||
<Search size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-6 text-xs w-44 py-1"
|
||||
placeholder="Filter activities..."
|
||||
value={actFilter}
|
||||
onChange={e => setActFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && filteredActs.length === 0 && (
|
||||
<p className="text-text-muted text-xs text-center p-6">
|
||||
No launchable activities{activities.length > 0 ? ' match the filter' : ' — this app exports none, or requires root to reach its internal screens'}.
|
||||
</p>
|
||||
)}
|
||||
{!loading && filteredActs.map(act => (
|
||||
<div
|
||||
key={act.component}
|
||||
className="flex items-center gap-3 px-3 py-2 border-b border-bg-border/30 hover:bg-bg-raised transition-colors group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="mono text-xs text-text-primary truncate">{act.name}</p>
|
||||
<p className="mono text-[10px] text-text-muted truncate">{act.component}</p>
|
||||
</div>
|
||||
{act.exported && <span className="badge-green text-xs shrink-0">exported</span>}
|
||||
<button
|
||||
onClick={() => launch(act.component)}
|
||||
className="btn-ghost text-xs shrink-0 opacity-60 group-hover:opacity-100"
|
||||
>
|
||||
<Play size={12} /> Launch
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Last result */}
|
||||
{lastResult && (
|
||||
<div className="border-t border-bg-border px-3 py-2 shrink-0 bg-bg-surface">
|
||||
<pre className="mono text-[11px] whitespace-pre-wrap text-text-secondary max-h-24 overflow-auto">{lastResult}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Play, Square, Trash2, Download, Filter, ChevronDown, List, Share2 } from 'lucide-react'
|
||||
import { StartLogcat, StopLogcat, ClearLogcat } from '../../lib/wails'
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Play, Square, Trash2, Download, Filter, ChevronDown, List, Share2, Highlighter, Plus, X } from 'lucide-react'
|
||||
import { StartLogcat, StopLogcat, ClearLogcat, SaveTextFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import LogcatMap from './LogcatMap'
|
||||
import type { LogcatLine } from '../../lib/types'
|
||||
import {
|
||||
loadHighlightRules, saveHighlightRules, compileRules, scrubSensitive,
|
||||
HI_SWATCH, type HighlightRule, type HiColor,
|
||||
} from '../../lib/logcat_tools'
|
||||
|
||||
const HI_COLORS: HiColor[] = ['red', 'amber', 'green', 'blue', 'purple', 'pink']
|
||||
|
||||
// @ts-ignore
|
||||
const { EventsOn, EventsOff } = window['runtime'] || {}
|
||||
|
|
@ -42,6 +48,12 @@ export default function ViewLogcat() {
|
|||
const [search, setSearch] = useState('')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'text' | 'map'>('text')
|
||||
const [showHighlights, setShowHighlights] = useState(false)
|
||||
const [hiRules, setHiRules] = useState<HighlightRule[]>(() => loadHighlightRules())
|
||||
const [newPattern, setNewPattern] = useState('')
|
||||
const [newMode, setNewMode] = useState<'contains' | 'regex'>('contains')
|
||||
const [newColor, setNewColor] = useState<HiColor>('red')
|
||||
const [scrubExport, setScrubExport] = useState(true)
|
||||
const mapSinkRef = useRef<((l: LogcatLine) => void) | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -146,15 +158,40 @@ export default function ViewLogcat() {
|
|||
}
|
||||
}
|
||||
|
||||
const saveLog = () => {
|
||||
const text = lines.map(l => l.raw).join('\n')
|
||||
const blob = new Blob([text], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `logcat_${Date.now()}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
const saveLog = async () => {
|
||||
// Export the currently-visible (filtered) lines, optionally scrubbing
|
||||
// sensitive identifiers (IMEIs, phone numbers, SIM serials, MACs, emails).
|
||||
let text = filteredLines.map(l => l.raw).join('\n')
|
||||
if (scrubExport) text = scrubSensitive(text)
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
||||
const name = `logcat_${stamp}${scrubExport ? '_scrubbed' : ''}.txt`
|
||||
try {
|
||||
const saved = await SaveTextFile(name, text)
|
||||
if (saved) notify.success(`Saved ${scrubExport ? '(scrubbed) ' : ''}to ${saved}`)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Compiled highlight matchers (recompiled only when rules change).
|
||||
const compiled = useMemo(() => compileRules(hiRules), [hiRules])
|
||||
const highlightFor = useCallback((raw: string): string => {
|
||||
for (const c of compiled) if (c.test(raw)) return c.style
|
||||
return ''
|
||||
}, [compiled])
|
||||
|
||||
const addRule = () => {
|
||||
if (!newPattern.trim()) return
|
||||
const rule: HighlightRule = {
|
||||
id: `${Date.now()}-${Math.round(Math.random() * 1e6)}`,
|
||||
pattern: newPattern.trim(), mode: newMode, color: newColor,
|
||||
}
|
||||
const next = [...hiRules, rule]
|
||||
setHiRules(next); saveHighlightRules(next); setNewPattern('')
|
||||
}
|
||||
const removeRule = (id: string) => {
|
||||
const next = hiRules.filter(r => r.id !== id)
|
||||
setHiRules(next); saveHighlightRules(next)
|
||||
}
|
||||
|
||||
const filteredLines = lines.filter(line => {
|
||||
|
|
@ -212,9 +249,31 @@ export default function ViewLogcat() {
|
|||
<Trash2 size={12} /> Clear
|
||||
</button>
|
||||
|
||||
<button onClick={saveLog} disabled={lines.length === 0} className="btn-ghost text-xs">
|
||||
<Download size={12} /> Save
|
||||
<button onClick={saveLog} disabled={filteredLines.length === 0} className="btn-ghost text-xs" title={scrubExport ? 'Save visible lines to .txt (sensitive IDs scrubbed)' : 'Save visible lines to .txt'}>
|
||||
<Download size={12} /> Save .txt
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs text-text-muted cursor-pointer" title="Redact IMEIs, phone numbers, SIM serials, MACs and emails from the exported file">
|
||||
<input type="checkbox" checked={scrubExport} onChange={e => setScrubExport(e.target.checked)} className="accent-accent-green" />
|
||||
Scrub
|
||||
</label>
|
||||
|
||||
{/* Text / Map view toggle */}
|
||||
<div className="flex rounded overflow-hidden border border-bg-border ml-1">
|
||||
<button
|
||||
onClick={() => setViewMode('text')}
|
||||
className={`px-2 py-1 text-xs flex items-center gap-1 ${viewMode === 'text' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}
|
||||
title="Text log"
|
||||
>
|
||||
<List size={12} /> Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={`px-2 py-1 text-xs flex items-center gap-1 ${viewMode === 'map' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}
|
||||
title="Live visual map"
|
||||
>
|
||||
<Share2 size={12} /> Map
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Text / Map view toggle */}
|
||||
<div className="flex rounded overflow-hidden border border-bg-border ml-1">
|
||||
|
|
@ -252,6 +311,15 @@ export default function ViewLogcat() {
|
|||
<ChevronDown size={10} className={showFilters ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowHighlights(v => !v)}
|
||||
className={`btn-ghost text-xs ${showHighlights ? 'text-accent-green' : ''}`}
|
||||
title="Highlight rules — colour lines that match a pattern"
|
||||
>
|
||||
<Highlighter size={12} /> Highlight{hiRules.length > 0 ? ` (${hiRules.length})` : ''}
|
||||
<ChevronDown size={10} className={showHighlights ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Status */}
|
||||
|
|
@ -329,6 +397,59 @@ export default function ViewLogcat() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlight rules panel */}
|
||||
{showHighlights && (
|
||||
<div className="border-b border-bg-border px-4 py-2 bg-bg-raised shrink-0 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-text-muted">Add rule:</span>
|
||||
<input
|
||||
className="input text-xs w-56"
|
||||
placeholder="Text or /regex/ to match, e.g. FATAL"
|
||||
value={newPattern}
|
||||
onChange={e => setNewPattern(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addRule()}
|
||||
/>
|
||||
<select className="input text-xs w-24 py-1" value={newMode} onChange={e => setNewMode(e.target.value as 'contains' | 'regex')}>
|
||||
<option value="contains">contains</option>
|
||||
<option value="regex">regex</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1">
|
||||
{HI_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewColor(c)}
|
||||
title={c}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-transform ${newColor === c ? 'border-text-primary scale-110' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: HI_SWATCH[c] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addRule} disabled={!newPattern.trim()} className="btn-ghost text-xs">
|
||||
<Plus size={12} /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hiRules.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">
|
||||
No highlight rules. Add one to colour matching lines (e.g. "FATAL" → red). Rules are saved and applied live.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hiRules.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-1.5 rounded border border-bg-border px-2 py-1" style={{ backgroundColor: `${HI_SWATCH[r.color]}22` }}>
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: HI_SWATCH[r.color] }} />
|
||||
<span className="mono text-xs text-text-primary">{r.pattern}</span>
|
||||
<span className="text-[10px] text-text-muted">{r.mode}</span>
|
||||
<button onClick={() => removeRule(r.id)} className="text-text-muted hover:text-danger" title="Remove rule">
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual map — kept mounted so it keeps ingesting the stream; hidden in text mode */}
|
||||
<LogcatMap running={running} registerSink={registerMapSink} onInspectEntity={inspectEntity} hidden={viewMode !== 'map'} search={search} />
|
||||
|
||||
|
|
@ -343,22 +464,25 @@ export default function ViewLogcat() {
|
|||
{running ? 'Waiting for log output...' : 'Press Start to begin streaming logcat'}
|
||||
</div>
|
||||
)}
|
||||
{filteredLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-2 px-1 py-0.5 rounded leading-relaxed hover:bg-bg-raised ${LEVEL_BG[line.level] || ''}`}
|
||||
>
|
||||
<span className="text-text-muted shrink-0 w-20 truncate">{line.time}</span>
|
||||
<span className="text-text-muted shrink-0 w-10 truncate">{line.pid}</span>
|
||||
<span className={`shrink-0 w-4 font-bold ${LEVEL_COLORS[line.level] || 'text-text-muted'}`}>
|
||||
{line.level}
|
||||
</span>
|
||||
<span className="text-warn shrink-0 w-32 truncate">{line.tag}</span>
|
||||
<span className={`flex-1 break-all ${LEVEL_COLORS[line.level] || 'text-text-secondary'}`}>
|
||||
{line.message || line.raw}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{filteredLines.map((line, i) => {
|
||||
const hi = highlightFor(line.raw)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-2 px-1 py-0.5 rounded leading-relaxed hover:bg-bg-raised ${hi || LEVEL_BG[line.level] || ''}`}
|
||||
>
|
||||
<span className="text-text-muted shrink-0 w-20 truncate">{line.time}</span>
|
||||
<span className="text-text-muted shrink-0 w-10 truncate">{line.pid}</span>
|
||||
<span className={`shrink-0 w-4 font-bold ${LEVEL_COLORS[line.level] || 'text-text-muted'}`}>
|
||||
{line.level}
|
||||
</span>
|
||||
<span className="text-warn shrink-0 w-32 truncate">{line.tag}</span>
|
||||
<span className={`flex-1 break-all ${hi ? '' : LEVEL_COLORS[line.level] || 'text-text-secondary'}`}>
|
||||
{line.message || line.raw}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { GetBinaryInfo, SetAdbPath, SetFastbootPath, AppLockStatus, SetAppPasswo
|
|||
import { notify } from '../../lib/notify'
|
||||
import { refreshAppLockStatus } from '../../lib/applock'
|
||||
import { applyTheme, getTheme, THEMES, type Theme } from '../../lib/theme'
|
||||
import { getCustomAccent, setCustomAccent, getCustomFont, setCustomFont, FONT_OPTIONS } from '../../lib/appearance'
|
||||
import { getSidebarPosition, setSidebarPosition, SIDEBAR_POSITIONS, getSidebarLabels, setSidebarLabels, type SidebarPosition } from '../../lib/layout'
|
||||
import { getRootTools, setRootTools, getHiddenViews, setHiddenViews, TOGGLEABLE_VIEWS, getMuteNoDevice, setMuteNoDevice } from '../../lib/featureflags'
|
||||
import { resetDismissed } from '../../lib/dismissible'
|
||||
|
|
@ -18,7 +19,12 @@ export default function ViewSettings() {
|
|||
const [sidebarLabels, setSidebarLabelsState] = useState<boolean>(getSidebarLabels())
|
||||
const [rootTools, setRootToolsState] = useState<boolean>(getRootTools())
|
||||
|
||||
const [customAccent, setCustomAccentState] = useState<string>(getCustomAccent())
|
||||
const [customFont, setCustomFontState] = useState<string>(getCustomFont())
|
||||
|
||||
const changeTheme = (t: Theme) => { setTheme(t); applyTheme(t) }
|
||||
const changeAccent = (hex: string | null) => { setCustomAccentState(hex || ''); setCustomAccent(hex) }
|
||||
const changeFont = (id: string) => { setCustomFontState(id); setCustomFont(id || null) }
|
||||
const changeSidebarPos = (p: SidebarPosition) => { setSidebarPos(p); setSidebarPosition(p) }
|
||||
const changeSidebarLabels = (on: boolean) => { setSidebarLabelsState(on); setSidebarLabels(on) }
|
||||
const changeRootTools = (on: boolean) => { setRootToolsState(on); setRootTools(on) }
|
||||
|
|
@ -139,11 +145,51 @@ export default function ViewSettings() {
|
|||
<span className="text-xs font-medium text-text-primary">{t.label}</span>
|
||||
{theme === t.id && <Check size={12} className="text-accent-green" />}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1 leading-snug">{t.hint}</p>
|
||||
{/* Swatch preview: base · surface · accent · text */}
|
||||
<div className="flex gap-1 mt-2" aria-hidden="true">
|
||||
{t.swatch.map((c, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-4 flex-1 rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1.5 leading-snug">{t.hint}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom accent colour + font — system-wide overrides on top of the theme */}
|
||||
<div className="pt-1 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1.5">Custom accent colour (overrides the theme accent everywhere)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customAccent || '#a6d189'}
|
||||
onChange={e => changeAccent(e.target.value)}
|
||||
className="h-8 w-12 rounded border border-bg-border bg-bg-raised cursor-pointer p-0.5"
|
||||
title="Pick a custom accent colour"
|
||||
/>
|
||||
<span className="mono text-xs text-text-secondary">{customAccent || 'theme default'}</span>
|
||||
{customAccent && (
|
||||
<button onClick={() => changeAccent(null)} className="btn-ghost text-xs ml-auto">Reset</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1.5">Font (applied app-wide)</p>
|
||||
<select
|
||||
className="input text-xs w-full"
|
||||
value={customFont}
|
||||
onChange={e => changeFont(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map(f => <option key={f.id} value={f.id}>{f.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted pt-1">Sidebar position. Applies instantly and is remembered.</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SIDEBAR_POSITIONS.map(p => (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react'
|
|||
import { Terminal, Trash2, ChevronRight, ChevronDown, Library, Search, Copy, Save } from 'lucide-react'
|
||||
import { RunShellCommand, RunAdbHostCommand, SaveTextFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { CodeView, detectLang } from '../../lib/syntax'
|
||||
import { CATEGORIES, type Command } from './ViewUtilities'
|
||||
|
||||
interface HistoryEntry {
|
||||
|
|
@ -233,13 +234,17 @@ export default function ViewShell() {
|
|||
<span>{entry.cmd}</span>
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
className={`whitespace-pre-wrap break-words leading-relaxed ${
|
||||
entry.error ? 'text-danger' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{entry.output}
|
||||
</pre>
|
||||
{entry.error ? (
|
||||
<pre className="whitespace-pre-wrap break-words leading-relaxed text-danger">
|
||||
{entry.output}
|
||||
</pre>
|
||||
) : (
|
||||
<CodeView
|
||||
code={entry.output}
|
||||
lang={detectLang('', entry.output) === 'text' ? 'log' : detectLang('', entry.output)}
|
||||
className="whitespace-pre-wrap break-words leading-relaxed text-text-secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
|
|
|
|||
67
frontend/src/lib/appearance.ts
Normal file
67
frontend/src/lib/appearance.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// System-wide appearance overrides layered ON TOP of the selected theme:
|
||||
// - a custom accent colour (overrides --accent-green / --accent-dim)
|
||||
// - a custom UI font (overrides the app's sans font)
|
||||
//
|
||||
// Both are applied as INLINE custom properties on <html>, which beats the
|
||||
// per-theme stylesheet rules, and persist in localStorage. Clearing an override
|
||||
// removes the inline prop so the theme's own value shows through again.
|
||||
|
||||
const ACCENT_KEY = 'atk-custom-accent'
|
||||
const FONT_KEY = 'atk-custom-font'
|
||||
|
||||
// Built-in font choices. '' = use the theme/app default. The two @fontsource
|
||||
// families are bundled; the rest are system generics that always resolve.
|
||||
export const FONT_OPTIONS: { id: string; label: string; stack: string }[] = [
|
||||
{ id: '', label: 'Default (IBM Plex Sans)', stack: '' },
|
||||
{ id: 'jetbrains', label: 'JetBrains Mono', stack: "'JetBrains Mono', monospace" },
|
||||
{ id: 'system-sans', label: 'System Sans', stack: 'system-ui, sans-serif' },
|
||||
{ id: 'system-serif', label: 'System Serif', stack: 'Georgia, \'Times New Roman\', serif' },
|
||||
{ id: 'system-mono', label: 'System Monospace', stack: 'ui-monospace, \'Cascadia Code\', \'Courier New\', monospace' },
|
||||
]
|
||||
|
||||
export function hexToChannels(hex: string): string | null {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim())
|
||||
if (!m) return null
|
||||
const n = parseInt(m[1], 16)
|
||||
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`
|
||||
}
|
||||
|
||||
function darkenChannels(ch: string, f: number): string {
|
||||
const [r, g, b] = ch.split(' ').map(Number)
|
||||
return `${Math.round(r * f)} ${Math.round(g * f)} ${Math.round(b * f)}`
|
||||
}
|
||||
|
||||
export function getCustomAccent(): string { return localStorage.getItem(ACCENT_KEY) || '' }
|
||||
export function getCustomFont(): string { return localStorage.getItem(FONT_KEY) || '' }
|
||||
|
||||
export function setCustomAccent(hex: string | null): void {
|
||||
if (hex && hexToChannels(hex)) localStorage.setItem(ACCENT_KEY, hex)
|
||||
else localStorage.removeItem(ACCENT_KEY)
|
||||
applyAppearance()
|
||||
}
|
||||
|
||||
export function setCustomFont(id: string | null): void {
|
||||
if (id) localStorage.setItem(FONT_KEY, id)
|
||||
else localStorage.removeItem(FONT_KEY)
|
||||
applyAppearance()
|
||||
}
|
||||
|
||||
// applyAppearance (re)applies the stored overrides. Call on boot and after a change.
|
||||
export function applyAppearance(): void {
|
||||
const root = document.documentElement
|
||||
|
||||
const accent = getCustomAccent()
|
||||
const ch = accent ? hexToChannels(accent) : null
|
||||
if (ch) {
|
||||
root.style.setProperty('--accent-green', ch)
|
||||
root.style.setProperty('--accent-dim', darkenChannels(ch, 0.82))
|
||||
} else {
|
||||
root.style.removeProperty('--accent-green')
|
||||
root.style.removeProperty('--accent-dim')
|
||||
}
|
||||
|
||||
const fontId = getCustomFont()
|
||||
const stack = FONT_OPTIONS.find(f => f.id === fontId)?.stack
|
||||
if (stack) root.style.setProperty('--app-font', stack)
|
||||
else root.style.removeProperty('--app-font')
|
||||
}
|
||||
91
frontend/src/lib/logcat_tools.ts
Normal file
91
frontend/src/lib/logcat_tools.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Logcat highlight rules + sensitive-data scrubbing for export.
|
||||
|
||||
export type HiColor = 'red' | 'amber' | 'green' | 'blue' | 'purple' | 'pink'
|
||||
|
||||
export interface HighlightRule {
|
||||
id: string
|
||||
pattern: string
|
||||
mode: 'contains' | 'regex'
|
||||
color: HiColor
|
||||
}
|
||||
|
||||
// Row style per colour (background tint + a left accent + readable text).
|
||||
export const HI_STYLES: Record<HiColor, string> = {
|
||||
red: 'bg-danger/25 text-danger',
|
||||
amber: 'bg-warn/25 text-warn',
|
||||
green: 'bg-accent-green/20 text-accent-green',
|
||||
blue: 'bg-blue-500/20 text-blue-300',
|
||||
purple: 'bg-purple-500/20 text-purple-300',
|
||||
pink: 'bg-pink-500/20 text-pink-300',
|
||||
}
|
||||
|
||||
export const HI_SWATCH: Record<HiColor, string> = {
|
||||
red: '#e78284', amber: '#e5c890', green: '#a6d189', blue: '#8caaee', purple: '#ca9ee6', pink: '#f4b8e4',
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'atk-logcat-highlights'
|
||||
|
||||
export function loadHighlightRules(): HighlightRule[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter(r => r && typeof r.pattern === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHighlightRules(rules: HighlightRule[]): void {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)) } catch {}
|
||||
}
|
||||
|
||||
// A compiled matcher for one rule. Regex rules that fail to compile become a
|
||||
// literal substring match so a bad pattern never throws mid-render.
|
||||
export interface CompiledRule {
|
||||
rule: HighlightRule
|
||||
test: (raw: string) => boolean
|
||||
style: string
|
||||
}
|
||||
|
||||
export function compileRules(rules: HighlightRule[]): CompiledRule[] {
|
||||
return rules
|
||||
.filter(r => r.pattern.trim() !== '')
|
||||
.map(r => {
|
||||
let test: (raw: string) => boolean
|
||||
if (r.mode === 'regex') {
|
||||
try {
|
||||
const re = new RegExp(r.pattern, 'i')
|
||||
test = (raw) => re.test(raw)
|
||||
} catch {
|
||||
const needle = r.pattern.toLowerCase()
|
||||
test = (raw) => raw.toLowerCase().includes(needle)
|
||||
}
|
||||
} else {
|
||||
const needle = r.pattern.toLowerCase()
|
||||
test = (raw) => raw.toLowerCase().includes(needle)
|
||||
}
|
||||
return { rule: r, test, style: HI_STYLES[r.color] || HI_STYLES.amber }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensitive-data scrubbing (applied on export).
|
||||
// Order matters: longer / more-specific patterns run before shorter ones so a
|
||||
// digit run isn't half-consumed by a broader rule.
|
||||
// ---------------------------------------------------------------------------
|
||||
const SCRUBBERS: [RegExp, string][] = [
|
||||
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[redacted-email]'],
|
||||
[/\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b/g, '[redacted-mac]'],
|
||||
[/\b\d{19,20}\b/g, '[redacted-iccid]'], // ICCID (SIM serial)
|
||||
[/\b\d{14,16}\b/g, '[redacted-imei]'], // IMEI / IMSI / MEID / long device ids
|
||||
[/\+\d[\d ()\-.]{6,14}\d/g, '[redacted-phone]'], // international phone numbers
|
||||
[/\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b/g, '[redacted-phone]'], // NANP formatted
|
||||
]
|
||||
|
||||
// scrubSensitive removes IMEIs, phone numbers, SIM serials, MACs and emails.
|
||||
export function scrubSensitive(text: string): string {
|
||||
let out = text
|
||||
for (const [re, repl] of SCRUBBERS) out = out.replace(re, repl)
|
||||
return out
|
||||
}
|
||||
107
frontend/src/lib/syntax.tsx
Normal file
107
frontend/src/lib/syntax.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// Dependency-free, theme-aware syntax highlighter.
|
||||
//
|
||||
// tokenize() splits code into typed tokens; <CodeView> renders them as spans
|
||||
// whose colours come from CSS variables (.syn-* classes in global.css), so the
|
||||
// highlighting automatically tracks the active theme. Deliberately lightweight —
|
||||
// ordered sticky-regex rules per language, capped for large inputs — not a full
|
||||
// parser, but good enough for manifests, config, logs and shell output.
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export type SynLang = 'xml' | 'json' | 'shell' | 'log' | 'text'
|
||||
|
||||
interface Rule { re: RegExp; c: string }
|
||||
|
||||
// All patterns use the sticky flag so they only match at the cursor position.
|
||||
const RULES: Record<Exclude<SynLang, 'text'>, Rule[]> = {
|
||||
xml: [
|
||||
{ re: /<!--[\s\S]*?-->/y, c: 'com' },
|
||||
{ re: /<!\[CDATA\[[\s\S]*?\]\]>/y, c: 'str' },
|
||||
{ re: /<[?!][\s\S]*?>/y, c: 'com' },
|
||||
{ re: /<\/?[A-Za-z_][\w:.-]*/y, c: 'tag' },
|
||||
{ re: /"[^"]*"|'[^']*'/y, c: 'str' },
|
||||
{ re: /[A-Za-z_][\w:.-]*(?=\s*=)/y, c: 'attr' },
|
||||
{ re: /\/?>/y, c: 'punc' },
|
||||
{ re: /\b\d[\w.]*\b/y, c: 'num' },
|
||||
],
|
||||
json: [
|
||||
{ re: /"(?:\\.|[^"\\])*"(?=\s*:)/y, c: 'key' },
|
||||
{ re: /"(?:\\.|[^"\\])*"/y, c: 'str' },
|
||||
{ re: /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y, c: 'num' },
|
||||
{ re: /\b(?:true|false|null)\b/y, c: 'bool' },
|
||||
{ re: /[{}\[\],:]/y, c: 'punc' },
|
||||
],
|
||||
shell: [
|
||||
{ re: /#[^\n]*/y, c: 'com' },
|
||||
{ re: /"(?:\\.|[^"\\])*"|'[^']*'/y, c: 'str' },
|
||||
{ re: /--?[A-Za-z][\w-]*/y, c: 'attr' },
|
||||
{ re: /\b0x[0-9a-fA-F]+\b/y, c: 'num' },
|
||||
{ re: /\b\d+\b/y, c: 'num' },
|
||||
{ re: /[|&;<>()]/y, c: 'punc' },
|
||||
],
|
||||
log: [
|
||||
{ re: /"(?:\\.|[^"\\])*"/y, c: 'str' },
|
||||
{ re: /\b(?:true|false|null|enabled|disabled|granted|SYSTEM|DEBUGGABLE|ENABLED|DISABLED)\b/y, c: 'bool' },
|
||||
{ re: /[A-Za-z_][\w.]*(?=\s*=)/y, c: 'attr' },
|
||||
{ re: /\b0x[0-9a-fA-F]+\b/y, c: 'num' },
|
||||
{ re: /\b\d[\d.:]*\b/y, c: 'num' },
|
||||
{ re: /[=:{}\[\]]/y, c: 'punc' },
|
||||
],
|
||||
}
|
||||
|
||||
const MAX_HIGHLIGHT = 400_000 // skip highlighting for very large blobs (perf)
|
||||
|
||||
export interface Tok { t: string; c: string }
|
||||
|
||||
export function tokenize(code: string, lang: SynLang): Tok[] {
|
||||
if (lang === 'text') return [{ t: code, c: '' }]
|
||||
const rules = RULES[lang]
|
||||
const out: Tok[] = []
|
||||
let plain = ''
|
||||
const flush = () => { if (plain) { out.push({ t: plain, c: '' }); plain = '' } }
|
||||
let i = 0
|
||||
const n = code.length
|
||||
while (i < n) {
|
||||
let matched = false
|
||||
for (const { re, c } of rules) {
|
||||
re.lastIndex = i
|
||||
const m = re.exec(code)
|
||||
if (m && m.index === i && m[0].length > 0) {
|
||||
flush()
|
||||
out.push({ t: m[0], c })
|
||||
i += m[0].length
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) { plain += code[i]; i++ }
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// detectLang guesses a language from a filename and/or content sniff.
|
||||
export function detectLang(name: string, content: string): SynLang {
|
||||
const n = (name || '').toLowerCase()
|
||||
if (/\.(xml|htm|html|svg)$/.test(n) || n.endsWith('androidmanifest.xml')) return 'xml'
|
||||
if (/\.(json|arsc\.json)$/.test(n)) return 'json'
|
||||
if (/\.(sh|bash|zsh|rc|prop|conf|cfg|ini|env)$/.test(n)) return 'shell'
|
||||
const head = content.slice(0, 400).trimStart()
|
||||
if (head.startsWith('<?xml') || head.startsWith('<manifest') || /^<[A-Za-z!]/.test(head)) return 'xml'
|
||||
if (head.startsWith('{') || head.startsWith('[')) return 'json'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function CodeView({ code, lang, className }: { code: string; lang: SynLang; className?: string }) {
|
||||
const toks = useMemo(() => {
|
||||
if (!code) return null
|
||||
if (code.length > MAX_HIGHLIGHT || lang === 'text') return null
|
||||
return tokenize(code, lang)
|
||||
}, [code, lang])
|
||||
|
||||
if (!toks) return <pre className={className}>{code}</pre>
|
||||
return (
|
||||
<pre className={className}>
|
||||
{toks.map((t, i) => (t.c ? <span key={i} className={`syn-${t.c}`}>{t.t}</span> : <span key={i}>{t.t}</span>))}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +1,56 @@
|
|||
// Theme management. Palettes are defined in src/styles/global.css and selected
|
||||
// by the data-theme attribute on <html>. Choice is persisted in localStorage.
|
||||
//
|
||||
// The catalogue below is the single source of truth: adding a palette here
|
||||
// (plus its CSS block in global.css) makes it appear in the Settings picker
|
||||
// automatically. The `swatch` tuple ([base, surface, accent, text]) drives the
|
||||
// preview shown on each theme card.
|
||||
|
||||
export type Theme = 'dark' | 'frappe' | 'latte'
|
||||
export interface ThemeDef {
|
||||
id: string
|
||||
label: string
|
||||
hint: string
|
||||
swatch: [string, string, string, string]
|
||||
}
|
||||
|
||||
export const THEMES: { id: Theme; label: string; hint: string }[] = [
|
||||
{ id: 'dark', label: 'Dark', hint: 'Terminal green on black' },
|
||||
{ id: 'frappe', label: 'Frappé', hint: 'Catppuccin — soft pastels, dark' },
|
||||
{ id: 'latte', label: 'Latte', hint: 'Catppuccin — soft pastels, light (default)' },
|
||||
export const THEMES: ThemeDef[] = [
|
||||
// ATK originals
|
||||
{ id: 'dark', label: 'Dark', hint: 'Terminal green on black', swatch: ['#0a0a0f', '#111118', '#00ff88', '#e8e8f0'] },
|
||||
{ id: 'frappe', label: 'Frappé', hint: 'Catppuccin — pastels, dark', swatch: ['#303446', '#292c3c', '#a6d189', '#c6d0f5'] },
|
||||
{ id: 'latte', label: 'Latte', hint: 'Catppuccin — pastels, light', swatch: ['#eff1f5', '#e6e9ef', '#40a02b', '#4c4f69'] },
|
||||
// Ported from Notas
|
||||
{ id: 'dracula', label: 'Dracula', hint: 'Purple & pink on charcoal', swatch: ['#282a36', '#343746', '#bd93f9', '#f8f8f2'] },
|
||||
{ id: 'catppuccin-macchiato', label: 'Catppuccin Macchiato', hint: 'Catppuccin — pastels, medium-dark', swatch: ['#24273a', '#363a4f', '#c6a0f6', '#cad3f5'] },
|
||||
{ id: 'catppuccin-mocha', label: 'Catppuccin Mocha', hint: 'Catppuccin — pastels, darkest', swatch: ['#1e1e2e', '#313244', '#cba6f7', '#cdd6f4'] },
|
||||
{ id: 'vintage-light', label: 'Vintage Light', hint: 'Warm sepia paper, light', swatch: ['#f6efe1', '#efe5d0', '#b07d3a', '#46392b'] },
|
||||
{ id: 'neon-tessera', label: 'Neon Tessera', hint: 'Cyan & magenta neon on black', swatch: ['#0a0e14', '#11161f', '#00e5ff', '#d8e6f2'] },
|
||||
{ id: 'adventure-time', label: 'Adventure Time', hint: 'Playful purple & orange', swatch: ['#1f1d45', '#2a2755', '#e7741e', '#f8dcc0'] },
|
||||
{ id: 'borland', label: 'Borland', hint: 'Retro blue IDE', swatch: ['#0000a4', '#0a1ab0', '#ffff4e', '#ffff80'] },
|
||||
{ id: 'c64', label: 'Commodore 64', hint: 'Commodore 64 blues', swatch: ['#40318d', '#4d3ea0', '#bfce72', '#cabdf2'] },
|
||||
{ id: 'fairy-floss-dark', label: 'Fairy Floss Dark', hint: 'Cotton-candy pastels', swatch: ['#3b364c', '#4a4564', '#ffb8d1', '#f8f8f2'] },
|
||||
{ id: 'flat', label: 'Flat', hint: 'Flat-UI slate & blue', swatch: ['#2c3e50', '#34495e', '#3498db', '#ecf0f1'] },
|
||||
{ id: 'gogh', label: 'Gogh — Starry Night', hint: 'Starry Night blues & gold', swatch: ['#0d1b34', '#14264a', '#f4cd3a', '#e8eeff'] },
|
||||
{ id: 'grass', label: 'Grass', hint: 'Green field & amber', swatch: ['#13773d', '#1c8a4a', '#e7b000', '#fff0a5'] },
|
||||
{ id: 'gruvbox-material', label: 'Gruvbox Material', hint: 'Warm retro earth tones', swatch: ['#282828', '#32302f', '#d8a657', '#d4be98'] },
|
||||
{ id: 'homebrew', label: 'Homebrew', hint: 'Green-on-black terminal', swatch: ['#000000', '#0c140c', '#00ff00', '#00d000'] },
|
||||
{ id: 'ocean', label: 'Ocean', hint: 'Muted blue-grey', swatch: ['#2b303b', '#343d46', '#8fa1b3', '#c0c5ce'] },
|
||||
{ id: 'kokuban', label: 'Kokuban', hint: 'Chalkboard green', swatch: ['#1f3526', '#274030', '#f2e9c8', '#f0f0e8'] },
|
||||
{ id: 'mono-cyan', label: 'Mono Cyan', hint: 'Monochrome cyan glow', swatch: ['#081414', '#0e1f1f', '#00d0d0', '#c8f0f0'] },
|
||||
]
|
||||
|
||||
export type Theme = string
|
||||
|
||||
const STORAGE_KEY = 'atk-theme'
|
||||
const VALID_IDS = new Set(THEMES.map(t => t.id))
|
||||
const DEFAULT_THEME: Theme = 'gruvbox-material'
|
||||
|
||||
export function getTheme(): Theme {
|
||||
const t = localStorage.getItem(STORAGE_KEY)
|
||||
return t === 'frappe' || t === 'latte' || t === 'dark' ? t : 'latte'
|
||||
return t && VALID_IDS.has(t) ? t : DEFAULT_THEME
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
const t = VALID_IDS.has(theme) ? theme : DEFAULT_THEME
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,38 @@ export interface AppInspection {
|
|||
manifestDump: string
|
||||
}
|
||||
|
||||
export interface IntentActivity {
|
||||
name: string
|
||||
component: string
|
||||
exported: boolean
|
||||
}
|
||||
|
||||
export interface GsiCompat {
|
||||
trebleEnabled: boolean
|
||||
abi: string
|
||||
gsiArch: string
|
||||
vndkIsolated: boolean
|
||||
androidRelease: string
|
||||
sdk: string
|
||||
dsuStatus: string
|
||||
}
|
||||
|
||||
export interface PrivacyTracker {
|
||||
name: string
|
||||
category: string
|
||||
matches: number
|
||||
}
|
||||
|
||||
export interface PrivacyReport {
|
||||
packageName: string
|
||||
score: number // 0-100, higher = more private
|
||||
grade: string // A-F
|
||||
trackerCount: number
|
||||
trackers: PrivacyTracker[]
|
||||
dangerousPermissions: string[]
|
||||
apkSize: number
|
||||
}
|
||||
|
||||
export interface CertInfo {
|
||||
filename: string
|
||||
subject: string
|
||||
|
|
@ -223,6 +255,8 @@ export type View =
|
|||
| 'shell'
|
||||
| 'logcat'
|
||||
| 'appinspect'
|
||||
| 'intentlab'
|
||||
| 'gsiloader'
|
||||
| 'apkaudit'
|
||||
| 'certs'
|
||||
| 'backup'
|
||||
|
|
|
|||
|
|
@ -185,6 +185,29 @@ export const LogcatProcessNames = (): Promise<Record<string, string>> => window[
|
|||
// @ts-ignore
|
||||
export const InspectApp = (pkg: string) => window['go']['main']['App']['InspectApp'](pkg)
|
||||
// @ts-ignore
|
||||
export const ScanAppPrivacy = (pkg: string) => window['go']['main']['App']['ScanAppPrivacy'](pkg)
|
||||
// @ts-ignore
|
||||
export const ListActivities = (pkg: string) => window['go']['main']['App']['ListActivities'](pkg)
|
||||
// @ts-ignore
|
||||
export const StartActivity = (component: string) => window['go']['main']['App']['StartActivity'](component)
|
||||
// @ts-ignore
|
||||
export const StartIntentAction = (action: string, data: string) => window['go']['main']['App']['StartIntentAction'](action, data)
|
||||
// GSI Loader
|
||||
// @ts-ignore
|
||||
export const GsiCompat = () => window['go']['main']['App']['GsiCompat']()
|
||||
// @ts-ignore
|
||||
export const GsiDsuStatus = () => window['go']['main']['App']['GsiDsuStatus']()
|
||||
// @ts-ignore
|
||||
export const InstallDsu = (imagePath: string, systemSize: number, userdataSize: number) => window['go']['main']['App']['InstallDsu'](imagePath, systemSize, userdataSize)
|
||||
// @ts-ignore
|
||||
export const DsuEnable = () => window['go']['main']['App']['DsuEnable']()
|
||||
// @ts-ignore
|
||||
export const DsuDisable = () => window['go']['main']['App']['DsuDisable']()
|
||||
// @ts-ignore
|
||||
export const DsuWipe = () => window['go']['main']['App']['DsuWipe']()
|
||||
// @ts-ignore
|
||||
export const FlashGsiSystem = (imagePath: string, opts: any) => window['go']['main']['App']['FlashGsiSystem'](imagePath, opts)
|
||||
// @ts-ignore
|
||||
export const CheckPinning = (pkg: string) => window['go']['main']['App']['CheckPinning'](pkg)
|
||||
|
||||
// Certificates
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import '@fontsource/jetbrains-mono/400.css'
|
|||
import '@fontsource/jetbrains-mono/500.css'
|
||||
import './styles/global.css'
|
||||
import { applyTheme, getTheme } from './lib/theme'
|
||||
import { applyAppearance } from './lib/appearance'
|
||||
|
||||
// Apply the saved theme before first paint to avoid a flash of the default.
|
||||
// Apply the saved theme + custom accent/font before first paint to avoid a flash.
|
||||
applyTheme(getTheme())
|
||||
applyAppearance()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,301 @@
|
|||
body (not :root) so it never tints the transparent document canvas. */
|
||||
:root[data-theme="latte"] body { color-scheme: light; }
|
||||
|
||||
/* ============================================================
|
||||
Ported palettes from Notas — mapped onto the same RGB-channel
|
||||
tokens above. Switch via data-theme on <html>. Secondary tokens
|
||||
(accent-dim/-muted, text-muted, scrollbar-hover) are derived.
|
||||
============================================================ */
|
||||
/* Dracula — ported from Notas (dark) */
|
||||
:root[data-theme="dracula"] {
|
||||
--bg-base: 40 42 54;
|
||||
--bg-surface: 52 55 70;
|
||||
--bg-raised: 60 63 81;
|
||||
--bg-border: 68 71 90;
|
||||
--accent-green: 189 147 249;
|
||||
--accent-dim: 155 121 204;
|
||||
--accent-muted: 73 69 97;
|
||||
--text-primary: 248 248 242;
|
||||
--text-secondary: 158 168 199;
|
||||
--text-muted: 99 105 126;
|
||||
--danger: 255 85 85;
|
||||
--warn: 241 250 140;
|
||||
--scrollbar-hover: 142 151 179;
|
||||
}
|
||||
|
||||
/* Catppuccin Macchiato — ported from Notas (dark) */
|
||||
:root[data-theme="catppuccin-macchiato"] {
|
||||
--bg-base: 36 39 58;
|
||||
--bg-surface: 54 58 79;
|
||||
--bg-raised: 73 77 100;
|
||||
--bg-border: 73 77 100;
|
||||
--accent-green: 198 160 246;
|
||||
--accent-dim: 162 131 202;
|
||||
--accent-muted: 76 73 104;
|
||||
--text-primary: 202 211 245;
|
||||
--text-secondary: 165 173 203;
|
||||
--text-muted: 100 106 130;
|
||||
--danger: 237 135 150;
|
||||
--warn: 238 212 159;
|
||||
--scrollbar-hover: 148 156 184;
|
||||
}
|
||||
|
||||
/* Catppuccin Mocha — ported from Notas (dark) */
|
||||
:root[data-theme="catppuccin-mocha"] {
|
||||
--bg-base: 30 30 46;
|
||||
--bg-surface: 49 50 68;
|
||||
--bg-raised: 69 71 90;
|
||||
--bg-border: 69 71 90;
|
||||
--accent-green: 203 166 247;
|
||||
--accent-dim: 166 136 203;
|
||||
--accent-muted: 72 67 95;
|
||||
--text-primary: 205 214 244;
|
||||
--text-secondary: 166 173 200;
|
||||
--text-muted: 98 102 123;
|
||||
--danger: 243 139 168;
|
||||
--warn: 249 226 175;
|
||||
--scrollbar-hover: 149 155 180;
|
||||
}
|
||||
|
||||
/* Vintage Light — ported from Notas (light) */
|
||||
:root[data-theme="vintage-light"] {
|
||||
--bg-base: 246 239 225;
|
||||
--bg-surface: 239 229 208;
|
||||
--bg-raised: 231 218 191;
|
||||
--bg-border: 216 200 168;
|
||||
--accent-green: 176 125 58;
|
||||
--accent-dim: 144 102 48;
|
||||
--accent-muted: 230 213 185;
|
||||
--text-primary: 70 57 43;
|
||||
--text-secondary: 122 106 85;
|
||||
--text-muted: 184 172 155;
|
||||
--danger: 161 77 58;
|
||||
--warn: 176 125 58;
|
||||
--scrollbar-hover: 139 123 100;
|
||||
}
|
||||
:root[data-theme="vintage-light"] body { color-scheme: light; }
|
||||
|
||||
/* Neon Tessera — ported from Notas (dark) */
|
||||
:root[data-theme="neon-tessera"] {
|
||||
--bg-base: 10 14 20;
|
||||
--bg-surface: 17 22 31;
|
||||
--bg-raised: 22 29 41;
|
||||
--bg-border: 29 39 53;
|
||||
--accent-green: 0 229 255;
|
||||
--accent-dim: 0 188 209;
|
||||
--accent-muted: 14 53 65;
|
||||
--text-primary: 216 230 242;
|
||||
--text-secondary: 126 147 168;
|
||||
--text-muted: 68 80 94;
|
||||
--danger: 255 56 96;
|
||||
--warn: 255 196 0;
|
||||
--scrollbar-hover: 109 128 147;
|
||||
}
|
||||
|
||||
/* Adventure Time — ported from Notas (dark) */
|
||||
:root[data-theme="adventure-time"] {
|
||||
--bg-base: 31 29 69;
|
||||
--bg-surface: 42 39 85;
|
||||
--bg-raised: 52 48 106;
|
||||
--bg-border: 58 53 111;
|
||||
--accent-green: 231 116 30;
|
||||
--accent-dim: 189 95 25;
|
||||
--accent-muted: 70 51 77;
|
||||
--text-primary: 248 220 192;
|
||||
--text-secondary: 163 154 196;
|
||||
--text-muted: 97 92 132;
|
||||
--danger: 189 0 19;
|
||||
--warn: 231 176 0;
|
||||
--scrollbar-hover: 144 136 181;
|
||||
}
|
||||
|
||||
/* Borland — ported from Notas (dark) */
|
||||
:root[data-theme="borland"] {
|
||||
--bg-base: 0 0 164;
|
||||
--bg-surface: 10 26 176;
|
||||
--bg-raised: 23 48 192;
|
||||
--bg-border: 42 64 196;
|
||||
--accent-green: 255 255 78;
|
||||
--accent-dim: 209 209 64;
|
||||
--accent-muted: 47 60 161;
|
||||
--text-primary: 255 255 128;
|
||||
--text-secondary: 182 182 230;
|
||||
--text-muted: 91 91 197;
|
||||
--danger: 255 89 89;
|
||||
--warn: 255 255 78;
|
||||
--scrollbar-hover: 157 161 224;
|
||||
}
|
||||
|
||||
/* Commodore 64 — ported from Notas (dark) */
|
||||
:root[data-theme="c64"] {
|
||||
--bg-base: 64 49 141;
|
||||
--bg-surface: 77 62 160;
|
||||
--bg-raised: 90 75 176;
|
||||
--bg-border: 86 72 168;
|
||||
--accent-green: 191 206 114;
|
||||
--accent-dim: 157 169 93;
|
||||
--accent-muted: 94 84 153;
|
||||
--text-primary: 202 189 242;
|
||||
--text-secondary: 147 133 201;
|
||||
--text-muted: 106 91 171;
|
||||
--danger: 136 57 50;
|
||||
--warn: 191 206 114;
|
||||
--scrollbar-hover: 136 122 195;
|
||||
}
|
||||
|
||||
/* Fairy Floss Dark — ported from Notas (dark) */
|
||||
:root[data-theme="fairy-floss-dark"] {
|
||||
--bg-base: 59 54 76;
|
||||
--bg-surface: 74 69 100;
|
||||
--bg-raised: 86 80 111;
|
||||
--bg-border: 86 79 111;
|
||||
--accent-green: 255 184 209;
|
||||
--accent-dim: 209 151 171;
|
||||
--accent-muted: 101 86 116;
|
||||
--text-primary: 248 248 242;
|
||||
--text-secondary: 197 189 218;
|
||||
--text-muted: 128 122 147;
|
||||
--danger: 255 133 127;
|
||||
--warn: 255 234 0;
|
||||
--scrollbar-hover: 177 169 199;
|
||||
}
|
||||
|
||||
/* Flat — ported from Notas (dark) */
|
||||
:root[data-theme="flat"] {
|
||||
--bg-base: 44 62 80;
|
||||
--bg-surface: 52 73 94;
|
||||
--bg-raised: 62 88 112;
|
||||
--bg-border: 62 80 102;
|
||||
--accent-green: 52 152 219;
|
||||
--accent-dim: 43 125 180;
|
||||
--accent-muted: 52 85 113;
|
||||
--text-primary: 236 240 241;
|
||||
--text-secondary: 164 181 196;
|
||||
--text-muted: 104 122 138;
|
||||
--danger: 231 76 60;
|
||||
--warn: 241 196 15;
|
||||
--scrollbar-hover: 146 163 179;
|
||||
}
|
||||
|
||||
/* Gogh — Starry Night — ported from Notas (dark) */
|
||||
:root[data-theme="gogh"] {
|
||||
--bg-base: 13 27 52;
|
||||
--bg-surface: 20 38 74;
|
||||
--bg-raised: 27 50 96;
|
||||
--bg-border: 33 52 95;
|
||||
--accent-green: 244 205 58;
|
||||
--accent-dim: 200 168 48;
|
||||
--accent-muted: 54 63 72;
|
||||
--text-primary: 232 238 255;
|
||||
--text-secondary: 148 168 204;
|
||||
--text-muted: 80 98 128;
|
||||
--danger: 217 96 59;
|
||||
--warn: 244 205 58;
|
||||
--scrollbar-hover: 127 147 184;
|
||||
}
|
||||
|
||||
/* Grass — ported from Notas (dark) */
|
||||
:root[data-theme="grass"] {
|
||||
--bg-base: 19 119 61;
|
||||
--bg-surface: 28 138 74;
|
||||
--bg-raised: 35 154 85;
|
||||
--bg-border: 42 154 94;
|
||||
--accent-green: 231 176 0;
|
||||
--accent-dim: 189 144 0;
|
||||
--accent-muted: 58 144 63;
|
||||
--text-primary: 255 240 165;
|
||||
--text-secondary: 188 214 160;
|
||||
--text-muted: 104 166 110;
|
||||
--danger: 207 58 42;
|
||||
--warn: 231 176 0;
|
||||
--scrollbar-hover: 162 203 148;
|
||||
}
|
||||
|
||||
/* Gruvbox Material — ported from Notas (dark) */
|
||||
:root[data-theme="gruvbox-material"] {
|
||||
--bg-base: 40 40 40;
|
||||
--bg-surface: 50 48 47;
|
||||
--bg-raised: 60 56 54;
|
||||
--bg-border: 69 64 61;
|
||||
--accent-green: 216 166 87;
|
||||
--accent-dim: 177 136 71;
|
||||
--accent-muted: 75 66 53;
|
||||
--text-primary: 212 190 152;
|
||||
--text-secondary: 168 153 132;
|
||||
--text-muted: 104 96 86;
|
||||
--danger: 234 105 98;
|
||||
--warn: 216 166 87;
|
||||
--scrollbar-hover: 150 137 119;
|
||||
}
|
||||
|
||||
/* Homebrew — ported from Notas (dark) */
|
||||
:root[data-theme="homebrew"] {
|
||||
--bg-base: 0 0 0;
|
||||
--bg-surface: 12 20 12;
|
||||
--bg-raised: 18 32 18;
|
||||
--bg-border: 16 56 16;
|
||||
--accent-green: 0 255 0;
|
||||
--accent-dim: 0 209 0;
|
||||
--accent-muted: 10 55 10;
|
||||
--text-primary: 0 208 0;
|
||||
--text-secondary: 31 138 31;
|
||||
--text-muted: 16 69 16;
|
||||
--danger: 200 0 0;
|
||||
--warn: 154 154 0;
|
||||
--scrollbar-hover: 28 123 28;
|
||||
}
|
||||
|
||||
/* Ocean — ported from Notas (dark) */
|
||||
:root[data-theme="ocean"] {
|
||||
--bg-base: 43 48 59;
|
||||
--bg-surface: 52 61 70;
|
||||
--bg-raised: 62 72 85;
|
||||
--bg-border: 62 72 85;
|
||||
--accent-green: 143 161 179;
|
||||
--accent-dim: 117 132 147;
|
||||
--accent-muted: 66 76 86;
|
||||
--text-primary: 192 197 206;
|
||||
--text-secondary: 139 149 164;
|
||||
--text-muted: 91 98 112;
|
||||
--danger: 191 97 106;
|
||||
--warn: 235 203 139;
|
||||
--scrollbar-hover: 125 135 150;
|
||||
}
|
||||
|
||||
/* Kokuban — ported from Notas (dark) */
|
||||
:root[data-theme="kokuban"] {
|
||||
--bg-base: 31 53 38;
|
||||
--bg-surface: 39 64 48;
|
||||
--bg-raised: 47 76 57;
|
||||
--bg-border: 49 80 64;
|
||||
--accent-green: 242 233 200;
|
||||
--accent-dim: 198 191 164;
|
||||
--accent-muted: 69 89 71;
|
||||
--text-primary: 240 240 232;
|
||||
--text-secondary: 169 194 175;
|
||||
--text-muted: 100 124 106;
|
||||
--danger: 242 160 160;
|
||||
--warn: 240 230 140;
|
||||
--scrollbar-hover: 147 173 155;
|
||||
}
|
||||
|
||||
/* Mono Cyan — ported from Notas (dark) */
|
||||
:root[data-theme="mono-cyan"] {
|
||||
--bg-base: 8 20 20;
|
||||
--bg-surface: 14 31 31;
|
||||
--bg-raised: 20 48 48;
|
||||
--bg-border: 22 56 56;
|
||||
--accent-green: 0 208 208;
|
||||
--accent-dim: 0 171 171;
|
||||
--accent-muted: 12 58 58;
|
||||
--text-primary: 200 240 240;
|
||||
--text-secondary: 92 154 154;
|
||||
--text-muted: 50 87 87;
|
||||
--danger: 224 133 133;
|
||||
--warn: 128 224 224;
|
||||
--scrollbar-hover: 79 136 136;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
|
@ -102,7 +397,9 @@
|
|||
background: transparent;
|
||||
color-scheme: dark;
|
||||
color: rgb(var(--text-primary));
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
/* --app-font is an optional system-wide override set from Settings (see
|
||||
lib/appearance.ts); falls back to the bundled UI font. */
|
||||
font-family: var(--app-font, 'IBM Plex Sans', sans-serif);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
|
@ -265,3 +562,15 @@
|
|||
.status-dot-green { background: rgb(var(--accent-green)); box-shadow: 0 0 6px rgb(var(--accent-green) / 0.53); }
|
||||
.status-dot-red { background: rgb(var(--danger)); box-shadow: 0 0 6px rgb(var(--danger) / 0.53); }
|
||||
.status-dot-gray { background: rgb(var(--text-muted)); animation: none; }
|
||||
|
||||
/* ============================================================
|
||||
Syntax highlighting (lib/syntax.tsx). Colours map onto the
|
||||
active theme's tokens, so highlighting tracks the theme (and
|
||||
any custom accent) automatically — no per-theme authoring.
|
||||
============================================================ */
|
||||
.syn-tag, .syn-key { color: rgb(var(--accent-green)); }
|
||||
.syn-attr { color: rgb(var(--accent-dim)); }
|
||||
.syn-str { color: rgb(var(--warn)); }
|
||||
.syn-num, .syn-bool{ color: rgb(var(--danger)); }
|
||||
.syn-com { color: rgb(var(--text-muted)); font-style: italic; }
|
||||
.syn-punc { color: rgb(var(--text-secondary)); }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: "atk"
|
||||
arch: "amd64"
|
||||
platform: "linux"
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
section: "utils"
|
||||
priority: "optional"
|
||||
maintainer: "jegly <https://github.com/jegly>"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue