Merge dev: GSI Loader, Intent Lab, Firmware, Screen Mirror, App Lock, privileged uninstall helper (v1.2.0)

This commit is contained in:
jegly 2026-07-06 15:27:38 +10:00
commit 4da6a69964
25 changed files with 2399 additions and 116 deletions

7
.gitignore vendored
View file

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

View file

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

@ -0,0 +1,302 @@
package main
// GSI Loader backend — two ways to run a Generic System Image on the device:
//
// 1. DSU (temporary): install a GSI as a guest OS via Dynamic System Updates.
// Non-destructive, no unlock, no wipe. Follows the exact adb flow from the
// Android DSU docs (see dsi_info.txt): gzip the raw image, push it to
// /storage/emulated/0/Download, then fire the START_INSTALL intent at
// com.android.dynsystem. Managed afterwards with gsi_tool.
//
// 2. GSI flash (permanent): fastboot-flash a GSI to the system partition.
// Destructive; DANGER-gated (App Lock). Sequences the documented fastboot
// steps with a dry-run preview.
import (
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const dsuDefaultUserdata int64 = 8589934592 // 8 GiB
const dsuRemoteDir = "/storage/emulated/0/Download"
// GsiCompat reports whether the device can run a GSI and which one.
type GsiCompat struct {
TrebleEnabled bool `json:"trebleEnabled"`
Abi string `json:"abi"` // ro.product.cpu.abi, e.g. arm64-v8a
GsiArch string `json:"gsiArch"` // derived: arm64 / x86_64 / arm / x86
VndkIsolated bool `json:"vndkIsolated"` // true => any newer GSI ok; false => same-version only
AndroidRelease string `json:"androidRelease"`
Sdk string `json:"sdk"`
DsuStatus string `json:"dsuStatus"`
}
// GsiCompat runs the documented compatibility checks (getprop + ld.config).
func (a *App) GsiCompat() (GsiCompat, error) {
c := GsiCompat{}
if v, err := a.runAdbShell("getprop", "ro.treble.enabled"); err == nil {
c.TrebleEnabled = strings.TrimSpace(v) == "true"
}
if v, err := a.runAdbShell("getprop", "ro.product.cpu.abi"); err == nil {
c.Abi = strings.TrimSpace(v)
c.GsiArch = gsiArchForAbi(c.Abi)
}
if v, err := a.runAdbShell("getprop", "ro.build.version.release"); err == nil {
c.AndroidRelease = strings.TrimSpace(v)
}
if v, err := a.runAdbShell("getprop", "ro.build.version.sdk"); err == nil {
c.Sdk = strings.TrimSpace(v)
}
if v, err := a.runAdbShell("cat", "/system/etc/ld.config.version_identifier.txt"); err == nil {
c.VndkIsolated = vendorNamespaceIsolated(v)
}
if v, err := a.runAdbShell("gsi_tool", "status"); err == nil {
c.DsuStatus = strings.TrimSpace(v)
}
return c, nil
}
func gsiArchForAbi(abi string) string {
switch {
case strings.HasPrefix(abi, "arm64"):
return "arm64"
case strings.HasPrefix(abi, "x86_64"):
return "x86_64"
case strings.HasPrefix(abi, "x86"):
return "x86"
case strings.HasPrefix(abi, "arm"):
return "arm"
}
return abi
}
// vendorNamespaceIsolated parses ld.config for the [vendor] section and reports
// whether namespace.default.isolated is true (full VNDK => any newer GSI works).
func vendorNamespaceIsolated(ld string) bool {
inVendor := false
for _, line := range strings.Split(ld, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
inVendor = t == "[vendor]"
continue
}
if inVendor && strings.Contains(t, "namespace.default.isolated") {
return strings.Contains(strings.ToLower(t), "true")
}
}
return false
}
// --- gsi_tool management ---------------------------------------------------
func (a *App) GsiDsuStatus() (string, error) { return a.runAdbShell("gsi_tool", "status") }
func (a *App) DsuEnable() (string, error) { return a.runAdbShell("gsi_tool", "enable") }
func (a *App) DsuDisable() (string, error) { return a.runAdbShell("gsi_tool", "disable") }
func (a *App) DsuWipe() (string, error) { return a.runAdbShell("gsi_tool", "wipe") }
// --- DSU install -----------------------------------------------------------
// InstallDsu prepares and installs a temporary DSU from a GSI image, then fires
// the DynamicSystemInstallationService intent. `systemSize` is the UNCOMPRESSED
// raw image size in bytes (auto = file size for a raw .img; REQUIRED for a .gz).
// `userdataSize` defaults to 8 GiB when <= 0.
func (a *App) InstallDsu(imagePath string, systemSize int64, userdataSize int64) (string, error) {
imagePath = strings.TrimSpace(imagePath)
if imagePath == "" {
return "", fmt.Errorf("no GSI image selected")
}
info, err := os.Stat(imagePath)
if err != nil || info.IsDir() {
return "", fmt.Errorf("image not found: %s", imagePath)
}
if isSparseImage(imagePath) {
return "", fmt.Errorf("this looks like a SPARSE image — DSU needs an unsparsed raw image. Convert first:\n simg2img system.img system_raw.img\nthen select the raw .img (or a .gz you made from it).")
}
if userdataSize <= 0 {
userdataSize = dsuDefaultUserdata
}
var gzPath string
var sysSize int64
isGz := strings.HasSuffix(strings.ToLower(imagePath), ".gz")
if isGz {
gzPath = imagePath
if systemSize <= 0 {
return "", fmt.Errorf("for a .gz image, provide the uncompressed system image size (bytes) — DSU needs KEY_SYSTEM_SIZE")
}
sysSize = systemSize
} else {
// Raw image: size is exact; gzip it host-side as the docs require.
sysSize = info.Size()
if systemSize > 0 {
sysSize = systemSize
}
gzPath = filepath.Join(os.TempDir(), "atk-dsu.gz")
if err := gzipFile(imagePath, gzPath); err != nil {
return "", fmt.Errorf("failed to gzip image: %w", err)
}
defer os.Remove(gzPath)
}
// Push the gzipped image to the device (progress via transfer:* events).
if _, err := a.PushWithProgress(gzPath, dsuRemoteDir); err != nil {
return "", err
}
remote := dsuRemoteDir + "/" + filepath.Base(gzPath)
// Fire the DSU install intent — verbatim from the Android docs.
out, err := a.runAdbShell(
"am", "start-activity",
"-n", "com.android.dynsystem/com.android.dynsystem.VerificationActivity",
"-a", "android.os.image.action.START_INSTALL",
"-d", "file://"+remote,
"--el", "KEY_SYSTEM_SIZE", strconv.FormatInt(sysSize, 10),
"--el", "KEY_USERDATA_SIZE", strconv.FormatInt(userdataSize, 10),
)
if err != nil {
return "", fmt.Errorf("failed to launch DSU install: %w (%s)", err, strings.TrimSpace(out))
}
return fmt.Sprintf("DSU install started (system %s, userdata %s). On the device, tap Restart in the notification to boot the GSI, or Discard to cancel. Use 'gsi_tool enable' for sticky mode.",
humanBytes(sysSize), humanBytes(userdataSize)), nil
}
// isSparseImage checks the Android sparse-image magic (0xed26ff3a, little-endian).
func isSparseImage(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
var b [4]byte
if _, err := io.ReadFull(f, b[:]); err != nil {
return false
}
return b[0] == 0x3a && b[1] == 0xff && b[2] == 0x26 && b[3] == 0xed
}
func gzipFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
zw := gzip.NewWriter(out)
if _, err := io.Copy(zw, in); err != nil {
zw.Close()
return err
}
return zw.Close()
}
func humanBytes(n int64) string {
const u = 1024
if n < u {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(u), 0
for x := n / u; x >= u; x /= u {
div *= u
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
// --- GSI permanent flash (fastboot) ----------------------------------------
type GsiFlashOpts struct {
Fastbootd bool `json:"fastbootd"` // reboot fastboot (fastbootd) first
WipeData bool `json:"wipeData"` // fastboot -w
DisableVerity bool `json:"disableVerity"` // flash vbmeta --disable-verification
DeleteProduct bool `json:"deleteProduct"` // free space: delete product_<slot>
Slot string `json:"slot"` // "a" / "b" / "" (for delete-logical-partition)
VbmetaPath string `json:"vbmetaPath"` // required if DisableVerity
DryRun bool `json:"dryRun"`
}
type gsiStep struct {
desc string
args []string
}
func (a *App) gsiFlashSteps(imagePath string, opts GsiFlashOpts) []gsiStep {
var steps []gsiStep
if opts.Fastbootd {
steps = append(steps, gsiStep{"Reboot to fastbootd", []string{"reboot", "fastboot"}})
}
if opts.DeleteProduct {
part := "product"
if opts.Slot != "" {
part += "_" + opts.Slot
}
steps = append(steps, gsiStep{"Free space: delete " + part, []string{"delete-logical-partition", part}})
}
steps = append(steps,
gsiStep{"Erase system", []string{"erase", "system"}},
gsiStep{"Flash system", []string{"flash", "system", imagePath}},
)
if opts.WipeData {
steps = append(steps, gsiStep{"Wipe userdata", []string{"-w"}})
}
if opts.DisableVerity && strings.TrimSpace(opts.VbmetaPath) != "" {
steps = append(steps, gsiStep{"Flash vbmeta (disable verification)", []string{"--disable-verification", "flash", "vbmeta", opts.VbmetaPath}})
}
steps = append(steps, gsiStep{"Reboot", []string{"reboot"}})
return steps
}
// FlashGsiSystem flashes a GSI to the system partition via fastboot. With
// DryRun, it returns the command list without executing. Otherwise it runs each
// step, gated behind the App Lock danger check.
func (a *App) FlashGsiSystem(imagePath string, opts GsiFlashOpts) (string, error) {
imagePath = strings.TrimSpace(imagePath)
if imagePath == "" {
return "", fmt.Errorf("no GSI system image selected")
}
if !opts.DryRun {
if info, err := os.Stat(imagePath); err != nil || info.IsDir() {
return "", fmt.Errorf("image not found: %s", imagePath)
}
}
steps := a.gsiFlashSteps(imagePath, opts)
if opts.DryRun {
var b strings.Builder
for _, s := range steps {
b.WriteString("fastboot " + strings.Join(s.args, " ") + "\n")
}
return b.String(), nil
}
if err := a.requireDangerUnlocked(); err != nil {
return "", err
}
var out strings.Builder
for _, s := range steps {
out.WriteString("$ fastboot " + strings.Join(s.args, " ") + "\n")
res, err := a.runCommandTimeout(10*time.Minute, "fastboot", s.args...)
if strings.TrimSpace(res) != "" {
out.WriteString(res + "\n")
}
if err != nil {
return out.String(), fmt.Errorf("%s failed: %w", s.desc, err)
}
// fastbootd takes a few seconds to come up before it accepts commands.
if len(s.args) == 2 && s.args[0] == "reboot" && s.args[1] == "fastboot" {
time.Sleep(8 * time.Second)
}
}
return out.String(), nil
}

123
backend_intent.go Normal file
View file

@ -0,0 +1,123 @@
package main
// Intent Lab — list an app's launchable (exported) activities and start them via
// `am start`, plus a free-form implicit-intent launcher. Lets a user reach hidden
// settings menus / internal screens that aren't on the launcher.
//
// Activity discovery uses `dumpsys package <pkg>`: components that appear in the
// Activity Resolver Table have an intent filter, so they're launchable by the
// shell user. Non-filtered/exported=false activities generally can't be started
// from adb; the launcher surfaces the real `am start` result either way.
import (
"fmt"
"regexp"
"sort"
"strings"
)
type IntentActivity struct {
Name string `json:"name"` // activity class, relative to the package
Component string `json:"component"` // full "package/activity" target for am start
Exported bool `json:"exported"` // appears in the resolver table (has an intent filter)
}
// componentRe guards the `am start -n <component>` target against shell injection
// on the device side (adb shell reparses the command).
var componentRe = regexp.MustCompile(`^[A-Za-z0-9_.]+/[A-Za-z0-9_.$]+$`)
var actionRe = regexp.MustCompile(`^[A-Za-z0-9_.]+$`)
// ListActivities returns the launchable activities of an installed package.
func (a *App) ListActivities(packageName string) ([]IntentActivity, error) {
if err := validatePackageName(packageName); err != nil {
return nil, err
}
dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
if err != nil {
return nil, fmt.Errorf("failed to query package: %w", err)
}
prefix := packageName + "/"
seen := map[string]bool{}
var acts []IntentActivity
inActivities := false
for _, line := range strings.Split(dump, "\n") {
t := strings.TrimSpace(line)
switch {
case strings.Contains(t, "Activity Resolver Table"):
inActivities = true
continue
case strings.Contains(t, "Receiver Resolver Table"),
strings.Contains(t, "Service Resolver Table"),
strings.Contains(t, "Provider Resolver Table"),
strings.Contains(t, "Preferred Activities"),
strings.Contains(t, "Key Set Manager"):
inActivities = false
}
if !inActivities {
continue
}
for _, tok := range strings.Fields(t) {
if strings.HasPrefix(tok, prefix) && len(tok) > len(prefix) && !seen[tok] {
seen[tok] = true
acts = append(acts, IntentActivity{
Name: strings.TrimPrefix(tok, prefix),
Component: tok,
Exported: true,
})
}
}
}
sort.Slice(acts, func(i, j int) bool { return acts[i].Name < acts[j].Name })
return acts, nil
}
// StartActivity launches an explicit component ("package/activity").
func (a *App) StartActivity(component string) (string, error) {
component = strings.TrimSpace(component)
if !componentRe.MatchString(component) {
return "", fmt.Errorf("invalid component, expected package/activity: %s", component)
}
out, err := a.runAdbShell("am", "start", "-n", component)
return interpretAmResult(out, err)
}
// StartIntentAction launches an implicit intent by action, with an optional data URI.
func (a *App) StartIntentAction(action, data string) (string, error) {
action = strings.TrimSpace(action)
if action == "" || !actionRe.MatchString(action) {
return "", fmt.Errorf("invalid or empty action")
}
args := []string{"am", "start", "-a", action}
if data = strings.TrimSpace(data); data != "" {
// Reject shell metacharacters — adb shell reparses this on the device.
if strings.ContainsAny(data, " \t\n\r;&|`$<>()\"'\\") {
return "", fmt.Errorf("data URI contains disallowed characters")
}
args = append(args, "-d", data)
}
out, err := a.runAdbShell(args...)
return interpretAmResult(out, err)
}
// interpretAmResult normalises `am start` output — it often prints errors to
// stdout with a zero exit, so inspect the text as well as err.
func interpretAmResult(out string, err error) (string, error) {
out = strings.TrimSpace(out)
if err != nil {
if out != "" {
return "", fmt.Errorf("%s", firstLine(out))
}
return "", err
}
if strings.Contains(out, "Error:") || strings.Contains(out, "Exception") ||
strings.Contains(out, "does not exist") || strings.Contains(out, "Permission Denial") {
return "", fmt.Errorf("%s", firstLine(out))
}
if out == "" {
out = "Started."
}
return out, nil
}

263
backend_privacy.go Normal file
View file

@ -0,0 +1,263 @@
package main
// Privacy & Tracker Scanner.
//
// Pulls an installed app's base APK, scans its DEX bytecode for known
// third-party tracker/analytics/ad SDK signatures, cross-references declared
// dangerous permissions, and derives a 0-100 Privacy Score (A-F grade). This
// reuses the APK auditor's DEX tracker matcher (matchTrackers /
// trackerSignatures) and the shared dangerousPermissions set, and additionally
// enriches the shared tracker DB below (which also improves the full auditor).
//
// Data source: a bundled static signature list (Exodus-Privacy-style code
// signatures = Java package prefixes as they appear in classes*.dex). No runtime
// network — works fully offline on any device.
import (
"archive/zip"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// extraTrackerSignatures widens the built-in trackerSignatures set with more
// well-known Exodus-catalogued SDKs. Merged into the shared maps at init so both
// the Privacy Scanner and the APK auditor see them. Signatures are conservative
// package prefixes chosen to avoid false positives.
var extraTrackerSignatures = map[string][]string{
"Google Tag Manager": {"com/google/android/gms/tagmanager"},
"Amazon Mobile Ads": {"com/amazon/device/ads", "com/amazon/aps"},
"AdColony": {"com/adcolony"},
"Startapp": {"com/startapp"},
"Mintegral": {"com/mbridge", "com/mintegral"},
"Pangle (ByteDance)": {"com/bytedance/sdk/openadsdk", "com/bytedance/pangle"},
"ByteDance AppLog": {"com/bytedance/applog"},
"PubMatic": {"com/pubmatic"},
"Criteo": {"com/criteo"},
"Smaato": {"com/smaato"},
"Fyber": {"com/fyber"},
"Taboola": {"com/taboola"},
"Outbrain": {"com/outbrain"},
"CleverTap": {"com/clevertap"},
"MoEngage": {"com/moengage"},
"Airship": {"com/urbanairship"},
"Leanplum": {"com/leanplum"},
"Batch": {"com/batch/android"},
"Iterable": {"com/iterable"},
"Pushwoosh": {"com/pushwoosh"},
"Swrve": {"com/swrve"},
"Optimizely": {"com/optimizely"},
"Adobe Experience": {"com/adobe/marketing/mobile", "com/adobe/mobile"},
"New Relic": {"com/newrelic"},
"Datadog": {"com/datadog/android"},
"Instabug": {"com/instabug"},
"Embrace": {"io/embrace/android"},
"Countly": {"ly/count/android"},
"Matomo": {"org/matomo", "org/piwik"},
"Snowplow": {"com/snowplowanalytics"},
"Smartlook": {"com/smartlook"},
"Nielsen": {"com/nielsen/app"},
"Mapbox Telemetry": {"com/mapbox/android/telemetry"},
"Foursquare": {"com/foursquare"},
"Gimbal": {"com/gimbal"},
"Radar": {"io/radar/sdk"},
}
var extraTrackerCategory = map[string]string{
"Google Tag Manager": "Analytics", "Amazon Mobile Ads": "Advertising", "AdColony": "Advertising",
"Startapp": "Advertising", "Mintegral": "Advertising", "Pangle (ByteDance)": "Advertising",
"ByteDance AppLog": "Analytics", "PubMatic": "Advertising", "Criteo": "Advertising",
"Smaato": "Advertising", "Fyber": "Advertising", "Taboola": "Advertising", "Outbrain": "Advertising",
"CleverTap": "Analytics", "MoEngage": "Marketing", "Airship": "Marketing", "Leanplum": "Marketing",
"Batch": "Marketing", "Iterable": "Marketing", "Pushwoosh": "Push/Analytics", "Swrve": "Marketing",
"Optimizely": "Analytics", "Adobe Experience": "Analytics", "New Relic": "Analytics",
"Datadog": "Analytics", "Instabug": "Crash reporting", "Embrace": "Crash reporting",
"Countly": "Analytics", "Matomo": "Analytics", "Snowplow": "Analytics", "Smartlook": "Analytics",
"Nielsen": "Analytics", "Mapbox Telemetry": "Location", "Foursquare": "Location",
"Gimbal": "Location", "Radar": "Location",
}
func init() {
for name, sigs := range extraTrackerSignatures {
if _, exists := trackerSignatures[name]; !exists {
trackerSignatures[name] = sigs
}
}
for name, cat := range extraTrackerCategory {
if _, exists := trackerCategory[name]; !exists {
trackerCategory[name] = cat
}
}
}
// privacyCategoryWeight is the score penalty per unique tracker of a category.
// Advertising / attribution / location are weighted heaviest (most invasive);
// crash reporting is light (usually operational, not surveillance).
var privacyCategoryWeight = map[string]int{
"Advertising": 12,
"Location": 12,
"Attribution": 8,
"Marketing": 8,
"Analytics": 7,
"Push/Analytics": 6,
"Crash reporting": 3,
}
const defaultTrackerWeight = 6
type PrivacyTracker struct {
Name string `json:"name"`
Category string `json:"category"`
Matches int `json:"matches"`
}
type PrivacyReport struct {
PackageName string `json:"packageName"`
Score int `json:"score"` // 0-100, higher = more private
Grade string `json:"grade"` // A-F
TrackerCount int `json:"trackerCount"`
Trackers []PrivacyTracker `json:"trackers"`
DangerousPermissions []string `json:"dangerousPermissions"`
ApkSize int64 `json:"apkSize"`
}
// ScanAppPrivacy pulls the base APK of an installed package, scans it for
// tracker SDKs, collects its declared dangerous permissions, and scores it.
func (a *App) ScanAppPrivacy(packageName string) (PrivacyReport, error) {
if err := validatePackageName(packageName); err != nil {
return PrivacyReport{}, err
}
report := PrivacyReport{PackageName: packageName}
// Locate the base APK on the device.
out, err := a.runAdbShell("pm", "path", packageName)
if err != nil {
return report, fmt.Errorf("could not locate package on device: %w", err)
}
var remote string
for _, line := range strings.Split(out, "\n") {
p := strings.TrimPrefix(strings.TrimSpace(line), "package:")
if strings.HasSuffix(p, "base.apk") {
remote = p
break
}
if remote == "" && strings.HasSuffix(p, ".apk") {
remote = p
}
}
if remote == "" {
return report, fmt.Errorf("no APK path found for %s", packageName)
}
// Pull to a temp file; unlike the auditor we don't need to keep it around.
tmp := filepath.Join(os.TempDir(), "atk-privacy-"+sanitizeFileToken(packageName)+".apk")
if _, err := a.runCommandTimeout(auditCommandTimeout, "adb", "pull", remote, tmp); err != nil {
return report, fmt.Errorf("failed to pull APK: %w", err)
}
defer os.Remove(tmp)
if info, statErr := os.Stat(tmp); statErr == nil {
report.ApkSize = info.Size()
}
// Scan DEX bytecode for tracker signatures.
trackerHits := map[string]int{}
if zr, zerr := zip.OpenReader(tmp); zerr == nil {
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, "classes") || !strings.HasSuffix(f.Name, ".dex") {
continue
}
if f.UncompressedSize64 > maxDexBytes {
continue
}
if data := readZipEntry(f); data != nil {
matchTrackers(data, trackerHits)
}
}
zr.Close()
} else {
return report, fmt.Errorf("could not open pulled APK: %w", zerr)
}
for name, n := range trackerHits {
report.Trackers = append(report.Trackers, PrivacyTracker{
Name: name, Category: trackerCategory[name], Matches: n,
})
}
// Heaviest categories first, then alphabetical.
sort.Slice(report.Trackers, func(i, j int) bool {
wi, wj := privacyCategoryWeight[report.Trackers[i].Category], privacyCategoryWeight[report.Trackers[j].Category]
if wi != wj {
return wi > wj
}
return report.Trackers[i].Name < report.Trackers[j].Name
})
report.TrackerCount = len(report.Trackers)
// Declared dangerous permissions (a permission named anywhere in the package
// dump is declared/involved for this package).
report.DangerousPermissions = a.declaredDangerousPermissions(packageName)
report.Score, report.Grade = computePrivacyScore(report.Trackers, report.DangerousPermissions)
return report, nil
}
// declaredDangerousPermissions returns the dangerous permissions the package
// declares, read from its dumpsys output.
func (a *App) declaredDangerousPermissions(packageName string) []string {
dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
if err != nil || dump == "" {
return nil
}
var found []string
for perm := range dangerousPermissions {
if strings.Contains(dump, perm) {
found = append(found, perm)
}
}
sort.Strings(found)
return found
}
// computePrivacyScore derives a 0-100 score (higher = more private) and an A-F
// grade from the detected trackers and declared dangerous permissions. Trackers
// dominate; permissions are a secondary, capped penalty.
func computePrivacyScore(trackers []PrivacyTracker, dangerousPerms []string) (int, string) {
score := 100
for _, t := range trackers {
w, ok := privacyCategoryWeight[t.Category]
if !ok {
w = defaultTrackerWeight
}
score -= w
}
// Permissions: -2 each, capped at -24 so a permission-heavy but tracker-free
// app (e.g. a camera app) isn't punished as hard as a tracker-laden one.
permPenalty := len(dangerousPerms) * 2
if permPenalty > 24 {
permPenalty = 24
}
score -= permPenalty
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
var grade string
switch {
case score >= 85:
grade = "A"
case score >= 70:
grade = "B"
case score >= 55:
grade = "C"
case score >= 40:
grade = "D"
default:
grade = "F"
}
return score, grade
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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