diff --git a/.gitignore b/.gitignore index c5ba58a..76b0820 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index b708b56..3495a0a 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,14 @@ ATK

-

ATK · Android Tool kit

+

ATK · Android Toolkit

An all-in-one Android command centre with a real-time system-map debugging engine.

+ Downloads License GPLv3 Linux 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 +> [!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 diff --git a/backend_gsi.go b/backend_gsi.go new file mode 100644 index 0000000..59cce14 --- /dev/null +++ b/backend_gsi.go @@ -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 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 +} diff --git a/backend_intent.go b/backend_intent.go new file mode 100644 index 0000000..f7fa816 --- /dev/null +++ b/backend_intent.go @@ -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 `: 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 ` 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 +} diff --git a/backend_privacy.go b/backend_privacy.go new file mode 100644 index 0000000..952a636 --- /dev/null +++ b/backend_privacy.go @@ -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 +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73668ce..8ee6283 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 case 'logcat': return case 'appinspect': return + case 'intentlab': return case 'apkaudit': return case 'certs': return case 'backup': return case 'props': return case 'flasher': return + case 'gsiloader': return case 'utilities': return case 'settings': return default: return diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9a79be7..24caea1 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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: , label: 'Shell' }, { view: 'logcat', icon: , label: 'Logcat', dividerBefore: true }, { view: 'appinspect', icon: , label: 'App Inspector' }, + { view: 'intentlab', icon: , label: 'Intent Lab' }, { view: 'apkaudit', icon: , label: 'APK Audit' }, { view: 'certs', icon: , label: 'Certificates' }, { view: 'backup', icon: , label: 'Backup' }, { view: 'props', icon: , label: 'Prop Editor' }, { view: 'utilities', icon: , label: 'Utilities', dividerBefore: true }, { view: 'flasher', icon: , label: 'Flasher' }, + { view: 'gsiloader', icon: , label: 'GSI Loader' }, ] interface DragProps { diff --git a/frontend/src/components/views/ViewApkAudit.tsx b/frontend/src/components/views/ViewApkAudit.tsx index 4344af0..a178f58 100644 --- a/frontend/src/components/views/ViewApkAudit.tsx +++ b/frontend/src/components/views/ViewApkAudit.tsx @@ -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() { )} {entry?.kind === 'text' && ( -

{entry.text}
+ )} {entry?.kind === 'binary' && (
{entry.hex}
diff --git a/frontend/src/components/views/ViewAppInspect.tsx b/frontend/src/components/views/ViewAppInspect.tsx index 3bdb123..0edd004 100644 --- a/frontend/src/components/views/ViewAppInspect.tsx +++ b/frontend/src/components/views/ViewAppInspect.tsx @@ -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 = { + 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(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: }, + { id: 'privacy', label: privacy ? `Privacy (${privacy.grade})` : 'Privacy', icon: }, { id: 'permissions', label: `Permissions (${result?.permissions?.length || 0})`, icon: }, { id: 'components', label: 'Components', icon: }, { id: 'libs', label: 'Native Libs', icon: }, @@ -218,6 +245,100 @@ export default function ViewAppInspect() { )} + {activeTab === 'privacy' && ( +
+ {!privacy && ( +
+ +

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

+ +
+ )} + + {privacy && ( + <> + {/* Score header */} +
+
+ {privacy.grade} +
+
+
+ {privacy.score} + / 100 privacy score +
+

+ {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} +

+ {/* Score bar */} +
+
= 70 ? 'bg-accent-green' : privacy.score >= 40 ? 'bg-warn' : 'bg-danger'}`} + style={{ width: `${privacy.score}%` }} + /> +
+
+ +
+ + {/* Trackers */} +
+

Trackers ({privacy.trackerCount})

+ {privacy.trackerCount === 0 && ( +

+ No known trackers detected in bytecode. +

+ )} +
+ {privacy.trackers.map(t => ( +
+ + {t.name} + {t.category} +
+ ))} +
+
+ + {/* Dangerous permissions */} +
+

Dangerous permissions ({privacy.dangerousPermissions.length})

+ {privacy.dangerousPermissions.length === 0 && ( +

None declared.

+ )} +
+ {privacy.dangerousPermissions.map(p => ( +
+ + {p} +
+ ))} +
+
+ +

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

+ + )} +
+ )} + {activeTab === 'permissions' && (
{result.permissions?.length === 0 && ( @@ -295,9 +416,11 @@ export default function ViewAppInspect() { {showManifest ? 'Hide' : 'Show'} full package dump ({result.manifestDump?.split('\n').length} lines) {showManifest && ( -
-                      {result.manifestDump}
-                    
+ )}
)} diff --git a/frontend/src/components/views/ViewDebloater.tsx b/frontend/src/components/views/ViewDebloater.tsx index b1d0578..69f2978 100644 --- a/frontend/src/components/views/ViewDebloater.tsx +++ b/frontend/src/components/views/ViewDebloater.tsx @@ -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 = { +// Display safety includes 'unknown' for device packages not in the UAD database. +type RowSafety = Safety | 'unknown' + +const UNCATEGORIZED = 'Uncategorized' + +const SAFETY_CONFIG: Record = { safe: { label: 'Safe', cls: 'badge-green', icon: }, caution: { label: 'Caution', cls: 'badge-yellow', icon: }, keep: { label: 'Keep', cls: 'badge-red', icon: }, + unknown: { label: 'Unknown', cls: 'badge-gray', icon: }, +} + +// 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>(new Set()) const [search, setSearch] = useState('') - const [safetyFilter, setSafety] = useState('all') + const [safetyFilter, setSafety] = useState('all') const [mfrFilter, setMfrFilter] = useState('all') const [openCats, setOpenCats] = useState>(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() + 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((pkgs || []).map((p: PackageInfo) => p.packageName)) setInstalled(names) setDisabled(new Set((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() - 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() + 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() {
- {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`}
- {/* Manufacturer filter */} + {/* Manufacturer / category filter */} {/* Safety filter */}
- {(['all', 'safe', 'caution', 'keep'] as const).map(f => ( + {(['all', 'safe', 'caution', 'keep', 'unknown'] as const).map(f => (
diff --git a/frontend/src/components/views/ViewFiles.tsx b/frontend/src/components/views/ViewFiles.tsx index 9f2e19c..c71f30c 100644 --- a/frontend/src/components/views/ViewFiles.tsx +++ b/frontend/src/components/views/ViewFiles.tsx @@ -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(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>({ 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() {
)} + {/* Text viewer — highlighted preview for text/code/config files */} + {textView && ( +
setTextView(null)}> +
+ {textView.name} + +
+
e.stopPropagation()}> +
+ {textLoading ? ( +
+
+
+ ) : ( + + )} +
+
+
+ )} + {/* Status bar */}
@@ -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) +} diff --git a/frontend/src/components/views/ViewGsiLoader.tsx b/frontend/src/components/views/ViewGsiLoader.tsx new file mode 100644 index 0000000..2964d84 --- /dev/null +++ b/frontend/src/components/views/ViewGsiLoader.tsx @@ -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(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, 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 ( +
+ {/* Header + tabs */} +
+ + GSI Loader +
+ + +
+
+ +
+ + {/* Compatibility panel */} +
+ Compatibility + {!compat && {compatLoading ? 'Checking…' : 'No device / unknown'}} + {compat && ( + <> + + {trebleOk ? : } Treble {trebleOk ? 'enabled' : 'NOT enabled'} + + ABI: {compat.abi || '?'} → use {compat.gsiArch || '?'} GSI + Android {compat.androidRelease || '?'} (SDK {compat.sdk || '?'}) + + {compat.vndkIsolated ? 'VNDK isolated — any newer GSI' : 'not VNDK-isolated — same-version GSI only'} + + + )} + {compat && !trebleOk && ( + Device may not support GSIs + )} +
+ +
+ {tab === 'dsu' ? ( +
+

+ Installs a GSI as a temporary guest OS via Dynamic System Updates — no unlock, + no data wipe. Pick a raw (unsparsed) GSI system.img or a + .gz you made from one. After install, tap Restart in the device notification to boot it. +

+ + {/* Image picker */} +
+ + {dsuImage ? baseName(dsuImage) : 'no file selected'} +
+ +
+
+

Userdata size (GiB)

+ setUserdataGiB(Math.max(1, Number(e.target.value)))} /> +
+
+

System size (bytes) — auto for raw .img, required for .gz

+ setSystemSize(Math.max(0, Number(e.target.value)))} placeholder="0 = auto (raw .img)" /> +
+
+ + {pushPct >= 0 && ( +
+
+
+
+ {pushPct}% +
+ )} + +
+ + +
+ + {/* gsi_tool management */} +
+

DSU management (gsi_tool)

+
+ + + + +
+ {dsuStatus &&
{dsuStatus}
} +
+
+ ) : ( +
+
+ +

+ Destructive & permanent. Erases the system partition, wipes userdata, and needs an + unlocked bootloader. An incompatible GSI can leave the device unbootable — keep the stock factory image to recover. GSIs don't support rollback. +

+
+ +
+ + {flashImage ? baseName(flashImage) : 'no file selected'} +
+ +
+ {([ + ['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]) => ( + + ))} +
+ + {(opts.disableVerity) && ( +
+ + {vbmeta ? baseName(vbmeta) : 'required for disable-verity'} +
+ )} + + {(opts.deleteProduct) && ( +
+ Active slot suffix: + +
+ )} + +
+ + +
+ + {dryRun && ( +
+

Command preview

+
{dryRun}
+
+ )} + {flashOut && ( +
+

Output

+
{flashOut}
+
+ )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/views/ViewIntentLab.tsx b/frontend/src/components/views/ViewIntentLab.tsx new file mode 100644 index 0000000..157627e --- /dev/null +++ b/frontend/src/components/views/ViewIntentLab.tsx @@ -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([]) + const [pkgsLoaded, setPkgsLoaded] = useState(false) + const [selected, setSelected] = useState('') + const [activities, setActivities] = useState([]) + 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 ( +
+ {/* Left: package picker */} +
+
+

Intent Lab

+
+ + setSearch(e.target.value)} + onFocus={loadPackages} + onKeyDown={e => e.key === 'Enter' && loadActivities(search)} + /> +
+ +
+
+ {filteredPkgs.map(p => ( + + ))} + {!pkgsLoaded && ( +

Focus the box to load the package list

+ )} +
+
+ + {/* Right: launcher */} +
+ {!selected && !loading && ( +
+ +

Pick an app to see its launchable activities

+
+ )} + + {(selected || loading) && ( + <> + {/* Free-form implicit-intent launcher */} +
+

Implicit intent (action + data)

+
+ setAction(e.target.value)} + /> + setData(e.target.value)} + onKeyDown={e => e.key === 'Enter' && launchIntent()} + /> + +
+
+ + {/* Activities */} +
+ + {selected} + · {activities.length} launchable +
+
+ + setActFilter(e.target.value)} + /> +
+
+ +
+ {loading && ( +
+
+
+ )} + {!loading && filteredActs.length === 0 && ( +

+ No launchable activities{activities.length > 0 ? ' match the filter' : ' — this app exports none, or requires root to reach its internal screens'}. +

+ )} + {!loading && filteredActs.map(act => ( +
+
+

{act.name}

+

{act.component}

+
+ {act.exported && exported} + +
+ ))} +
+ + {/* Last result */} + {lastResult && ( +
+
{lastResult}
+
+ )} + + )} +
+
+ ) +} diff --git a/frontend/src/components/views/ViewLogcat.tsx b/frontend/src/components/views/ViewLogcat.tsx index c9b776c..7749fc9 100644 --- a/frontend/src/components/views/ViewLogcat.tsx +++ b/frontend/src/components/views/ViewLogcat.tsx @@ -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(() => loadHighlightRules()) + const [newPattern, setNewPattern] = useState('') + const [newMode, setNewMode] = useState<'contains' | 'regex'>('contains') + const [newColor, setNewColor] = useState('red') + const [scrubExport, setScrubExport] = useState(true) const mapSinkRef = useRef<((l: LogcatLine) => void) | null>(null) const bottomRef = useRef(null) const containerRef = useRef(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() { Clear - + + + {/* Text / Map view toggle */} +
+ + +
{/* Text / Map view toggle */}
@@ -252,6 +311,15 @@ export default function ViewLogcat() { + +
{/* Status */} @@ -329,6 +397,59 @@ export default function ViewLogcat() {
)} + {/* Highlight rules panel */} + {showHighlights && ( +
+
+ Add rule: + setNewPattern(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addRule()} + /> + +
+ {HI_COLORS.map(c => ( +
+ +
+ + {hiRules.length === 0 ? ( +

+ No highlight rules. Add one to colour matching lines (e.g. "FATAL" → red). Rules are saved and applied live. +

+ ) : ( +
+ {hiRules.map(r => ( +
+ + {r.pattern} + {r.mode} + +
+ ))} +
+ )} +
+ )} + {/* Visual map — kept mounted so it keeps ingesting the stream; hidden in text mode */}
)} - {filteredLines.map((line, i) => ( -
- {line.time} - {line.pid} - - {line.level} - - {line.tag} - - {line.message || line.raw} - -
- ))} + {filteredLines.map((line, i) => { + const hi = highlightFor(line.raw) + return ( +
+ {line.time} + {line.pid} + + {line.level} + + {line.tag} + + {line.message || line.raw} + +
+ ) + })}
diff --git a/frontend/src/components/views/ViewSettings.tsx b/frontend/src/components/views/ViewSettings.tsx index b375368..7384047 100644 --- a/frontend/src/components/views/ViewSettings.tsx +++ b/frontend/src/components/views/ViewSettings.tsx @@ -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(getSidebarLabels()) const [rootTools, setRootToolsState] = useState(getRootTools()) + const [customAccent, setCustomAccentState] = useState(getCustomAccent()) + const [customFont, setCustomFontState] = useState(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() { {t.label} {theme === t.id && }
-

{t.hint}

+ {/* Swatch preview: base · surface · accent · text */} + +

{t.hint}

))}
+ {/* Custom accent colour + font — system-wide overrides on top of the theme */} +
+
+

Custom accent colour (overrides the theme accent everywhere)

+
+ 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" + /> + {customAccent || 'theme default'} + {customAccent && ( + + )} +
+
+
+

Font (applied app-wide)

+ +
+
+

Sidebar position. Applies instantly and is remembered.

{SIDEBAR_POSITIONS.map(p => ( diff --git a/frontend/src/components/views/ViewShell.tsx b/frontend/src/components/views/ViewShell.tsx index 9492263..5a69d79 100644 --- a/frontend/src/components/views/ViewShell.tsx +++ b/frontend/src/components/views/ViewShell.tsx @@ -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() { {entry.cmd}
)} -
-                {entry.output}
-              
+ {entry.error ? ( +
+                  {entry.output}
+                
+ ) : ( + + )}
))} {loading && ( diff --git a/frontend/src/lib/appearance.ts b/frontend/src/lib/appearance.ts new file mode 100644 index 0000000..02b3dc9 --- /dev/null +++ b/frontend/src/lib/appearance.ts @@ -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 , 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') +} diff --git a/frontend/src/lib/logcat_tools.ts b/frontend/src/lib/logcat_tools.ts new file mode 100644 index 0000000..e606a58 --- /dev/null +++ b/frontend/src/lib/logcat_tools.ts @@ -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 = { + 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 = { + 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 +} diff --git a/frontend/src/lib/syntax.tsx b/frontend/src/lib/syntax.tsx new file mode 100644 index 0000000..0ac2ec1 --- /dev/null +++ b/frontend/src/lib/syntax.tsx @@ -0,0 +1,107 @@ +// Dependency-free, theme-aware syntax highlighter. +// +// tokenize() splits code into typed tokens; 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, Rule[]> = { + xml: [ + { re: //y, c: 'com' }, + { re: //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(' { + if (!code) return null + if (code.length > MAX_HIGHLIGHT || lang === 'text') return null + return tokenize(code, lang) + }, [code, lang]) + + if (!toks) return
{code}
+ return ( +
+      {toks.map((t, i) => (t.c ? {t.t} : {t.t}))}
+    
+ ) +} diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index b7c647e..855b668 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -1,22 +1,56 @@ // Theme management. Palettes are defined in src/styles/global.css and selected // by the data-theme attribute on . 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) } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b287e7a..84118a9 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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' diff --git a/frontend/src/lib/wails.ts b/frontend/src/lib/wails.ts index a686511..6279314 100644 --- a/frontend/src/lib/wails.ts +++ b/frontend/src/lib/wails.ts @@ -185,6 +185,29 @@ export const LogcatProcessNames = (): Promise> => 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 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 48c5434..cc2573f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f7e6caa..37eb07a 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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 . 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)); } diff --git a/nfpm.yaml b/nfpm.yaml index fab20c3..1e117e6 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,7 +1,7 @@ name: "atk" arch: "amd64" platform: "linux" -version: "1.1.0" +version: "1.2.0" section: "utils" priority: "optional" maintainer: "jegly "