From a2e9f5ccd747d5679186ce6115795f5619975223 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:50:42 -0700 Subject: [PATCH 01/33] =?UTF-8?q?docs(plan):=20mole-parity=20PRD=20(+=20?= =?UTF-8?q?=CE=B1=20Process=20Inspector,=20=CE=B2=20Get=20Online=20compani?= =?UTF-8?q?on)=20+=20competitor=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plans/competitor-feature-scan-2026-06-25.md | 140 +++++++++++++++ plans/mole-parity-prd-2026-06-25.md | 187 ++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 plans/competitor-feature-scan-2026-06-25.md create mode 100644 plans/mole-parity-prd-2026-06-25.md diff --git a/plans/competitor-feature-scan-2026-06-25.md b/plans/competitor-feature-scan-2026-06-25.md new file mode 100644 index 0000000..c4b0ce7 --- /dev/null +++ b/plans/competitor-feature-scan-2026-06-25.md @@ -0,0 +1,140 @@ +# Competitor feature scan — additions to the feature plan + +> Running capture of features from other Mac apps the user is sending, to fold into Burrow's plan. Local only; not posted. Separate from the Mole-parity PRD (`mole-parity-prd-2026-06-25.md`) and from the other session's "one big plan". Each app: what it does → how it maps to Burrow (have / partial / new) → leverage (what existing Burrow infra it reuses) → value. +> +> Burrow context that keeps recurring here: Burrow already has a **process sampler + per-process history** (ProcessSampler/MetricsStore.processWindow, used by spike-forensics), **per-process nettop bandwidth** (NetUsage — built for Ports), an **alert/threshold engine** (AlertEngine/ThresholdAlerts + notifier), **proc_pidfdinfo socket enumeration** (PortEnumerator), **SSE event stream + notifications**, and a **menu-bar widget row**. Several asks below are mostly "surface infra we already have." + +--- + +## Synthesis & recommendations (after both apps) + +Two clean new epics fall out, plus a handful of standalone wins. Deduped against the Mole-parity PRD (`mole-parity-prd-2026-06-25.md`) and the already-shipped Get Online pane. + +### Epic α — "Process Inspector" (deepen Status) — from ProcessSpy +Turn Burrow's sortable process list into a real inspector. This is the single biggest depth gap a power user notices. Reuses a lot we already have. +- **Tier 1 (high value ÷ low cost, reuses infra):** + - **Per-process watchdog + actions** — per-process rules ("X holds >N% CPU/mem/disk for T s" → notify / quit / suspend). Reuses AlertEngine + notifier. Most-requested ProcessSpy feature; they're hesitant to build it. *Our actuating direction makes this on-brand.* + - **Per-process network column** — surface NetUsage (already parses nettop) in the table + inspector. Near-free; ProcessSpy **refuses** to do it. + - **Inspector panel** — extend the Mole PRD's **ProcessOrigin + BinaryIntegrity** into a full detail panel (signing/hardened/entitlements/Rosetta, QoS, footprint+peak, page-ins, per-process disk I/O, threads, parent/children). Extra metrics via unprivileged `proc_pidinfo`/`proc_pid_rusage`. + - **Shortcuts / event hooks on process events** — same event plumbing as the watchdog + SSE. Pairs with the watchdog. +- **Tier 2:** process **tree view** (summed CPU/mem/threads, XPC-linked) · **predicate/JS filters + saved smart-filter tabs** (Unsigned/Neural/Startup/Recent/app) · per-row **CPU sparkline**. +- **Tier 3 / cheap polish:** suspend/resume signals · reveal **open files** (extend proc_pidfdinfo to vnodes) · **finished-process recall** ("Recent") · **JSON/CSV export** · multi-select aggregation. + +### Epic β — "Get Online → travel companion" (extend the shipped pane) — from Hotspot Guide +We own the *fixes* (not sandboxed). Add the *companion* layer: +- **Venue/airline tips DB** (SSID-keyed known-issues + bypass tips) — content moat; ship curated + **community-extensible JSON**. +- **Connection History** (per-SSID success/fail + measured speed + failure reason) — reuse the SQLite history DB. +- **Speed test done right** (multi-stream + jitter/packet-loss). +- **Network Info / nearby-networks scan** (RSSI/channel/security) + **Public-vs-Home mode**. +- **Offline guides** (bundled markdown) · **lifetime stats** · staged check animation. +- **One gating decision:** **CoreWLAN + Location** permission — unlocks SSID auto-detect (→ venue DB), Home mode, and the scanner. The Mole PRD currently *defers* this; β is the reason to reconsider. + +### Cross-cutting decisions to make +1. **CoreWLAN + Location permission** — yes/no. Gates the entire β companion layer (venue auto-detect, Home mode, scanner). Without it, β degrades to a manual venue picker + history + offline guides (still useful). +2. **Per-process deep metrics** (`proc_pidinfo`/`proc_pid_rusage` for QoS/footprint/page-ins/disk) — a small native reader that feeds the whole α inspector. Build once. +3. **Predicate-filter language** — JS (ProcessSpy's choice) vs a typed predicate. Burrow already exposes data to agents; a small predicate DSL might fit the agent-native angle better than embedding JS. + +### Highest-ROI shortlist (build these first) +1. **Per-process network column** (α) — almost free, real differentiator. +2. **Per-process watchdog + actions + Shortcuts hooks** (α) — top ask, reuses alert engine, fits actuating direction. +3. **Connection History + venue DB** (β) — makes Get Online a companion; history reuses the DB, venue DB is curated content. +4. **Inspector panel + proc_pidinfo metrics** (α) — extends modules already in the Mole PRD. + +### Integration notes +- α overlaps the Mole-PRD modules **ProcessOrigin** + **BinaryIntegrity** — don't duplicate; the inspector *is* their home. +- β **extends** the Mole-PRD connectivity items + the shipped Get Online — fold in, don't fork. +- Another session wrote `plans/burrow-cli-master-plan-2026-06-25.md` ("one big plan"). These two epics should be **reconciled into** it (or the Mole PRD) rather than living only here — flag for the user. Don't edit that file from this session (collision). + +--- + +## 1. ProcessSpy — process-spy.app (advanced process monitor / Activity Monitor replacement) + +Deep, native, fully-local process inspector. Freemium ($34.99 lifetime). macOS 14+, notarized, Homebrew cask. The depth is in the **per-process inspector, tree view, per-process history, and power-user filtering** — exactly the layer where Burrow's Status process table is currently shallow (sortable list + pin + spike-forensics, no inspector). + +### Features → Burrow mapping + +**A. Rich per-process inspector panel** (click a process → detail) — Burrow: **new** (row menu only does pin/reveal/copy/quit today). Fields: +- Identity/Time: start time, run time, **lifetime timeline vs system uptime** (log scale), last-seen. +- Process details: bundle ID, **format/arch + native-vs-Rosetta (emulated) status**, command line, main exec path, **Launched-By / responsible PID**, **startup-entry type** (daemon/agent/login). +- Security: **sandboxed, hardened-runtime, signature/signing org, entitlements, Info.plist**. +- Resources: CPU, CPU time, **user/sys split, QoS class**, memory, **footprint + peak footprint**, **page-ins**, **per-process disk I/O read/write (+per-sec)**, threads — and Burrow can add **per-process network up/down** that ProcessSpy deliberately omits. +- Hierarchy: parent + children, XPC services linked by responsible PID. +- *Leverage:* reuses the PRD's **BinaryIntegrity** + **ProcessOrigin** modules; QoS/footprint/page-ins/disk come from `proc_pid_rusage`/`proc_pidinfo`; net from existing **NetUsage**; signing from existing signature checks. + +**B. Process tree / hierarchy view** with rolled-up aggregate CPU/Memory/Threads + multi-select sum — Burrow: **new** (we have ppid, no tree). XPC-link children by responsible PID. *Leverage:* ProcessSampler already captures ppid. + +**C. Per-process history graphs** (CPU% + memory over time, avg/peak) + **export CSV/JSON** — Burrow: **partial** (we record per-process samples for spike-forensics + system history graphs; not surfaced as a per-process live graph or export). *Leverage:* MetricsStore.processWindow already holds the data. + +**D. Per-process mini CPU sparkline in the table row** — Burrow: **new** (we have a PWR column, no per-row sparkline). + +**E. ⭐ Per-process alert rules / watchdog** — "alert when a process holds >N% CPU/mem/disk for T seconds," optional auto-action (notify / quit / suspend). The single most-requested ProcessSpy feature in the thread (users even want auto-kill of a runaway AI process). Burrow: **partial→high-value** — we have system-level CPU/mem threshold alerts; extend the rule engine to **per-process** with actions. *Leverage:* AlertEngine + ThresholdAlerts + notifier + SSE. **Strong fit + differentiator** (ProcessSpy hasn't built it; the dev is hesitant). + +**F. Advanced filters** — JS/predicate filters over process props (`process.residentMemory > X`), regex multi-property search, and **saved smart-filter tabs** (All/System/Apps/My/Unsigned/Neural-ANE/java/Microsoft/Startup-Entry/Recent/app-specific) — Burrow: **partial** (basic search). Predicate filters + saved-filter tabs are the power layer. + +**G. ⭐ Per-process network column** (up/down per process) — Burrow: **near-free win**. ProcessSpy **refuses** this (doesn't want to parse nettop / overlap Little Snitch). Burrow **already parses nettop per-process** (NetUsage) for Ports → surface it in the process table as a column + inspector field. Low cost, real differentiator. + +**H. Point-and-click process discovery** — click a window → identify its owning process — Burrow: **new** (CGWindowList/AX → pid). Power feature, modest. + +**I. Pause / Resume (SIGSTOP/SIGCONT)** from the row menu — Burrow: **partial** (we have quit + force-kill; add suspend/resume). Cheap. (ProcessSpy markets that resume actually works vs Activity Monitor's broken button.) + +**J. Reveal a process's open files** (lsof-style) + reveal in Finder — Burrow: **partial** (we enumerate sockets via proc_pidfdinfo for Ports; extend to file vnodes). Modest. + +**K. Finished / recently-exited process recall** ("Recent" tab) — keep recently-dead processes + last metrics for forensics — Burrow: **new** but adjacent to spike-forensics. Modest. + +**L. Run Shortcuts on process events** — fire a macOS Shortcut when a process matching a filter spawns/exits/crosses a threshold — Burrow: **new, on-brand** (Burrow's "actuating / agent-native" direction). *Leverage:* same event plumbing as the watchdog (E) + SSE. Pairs naturally with E. + +**M. Per-process metrics we lack**: QoS class, footprint+peak, page-ins, user/sys CPU split, per-process disk I/O — Burrow: **new**, native via `proc_pid_rusage`/`proc_pidinfo` (rusage_info_v*). Feeds A. + +**N. Misc**: process tagging; export visible processes to JSON/CSV; multi-select aggregation; native-vs-Rosetta badge; status-bar system indicators (Burrow **has** the menu-bar widget row — parity); local/no-telemetry (Burrow **matches**). + +### Standouts for Burrow (high value ÷ low cost, mostly reusing our infra) +1. **Per-process watchdog with actions (E)** + **Shortcuts/automation on events (L)** — biggest ask, strong fit with Burrow's alert engine + actuating direction; ProcessSpy is hesitant to build it. +2. **Per-process network column (G)** — almost free (NetUsage exists); a thing ProcessSpy explicitly won't do. +3. **Rich inspector panel (A) + extra metrics (M)** — turns Burrow's shallow process list into a real inspector; reuses BinaryIntegrity/ProcessOrigin from the Mole PRD. +4. **Process tree view (B)** and **predicate filters + saved tabs (F)** — the "power user" depth. +5. Cheap wins: **suspend/resume (I)**, **per-process sparkline (D)**, **open-files reveal (J)**, **export (N)**. + +### Notes / cautions +- This is a coherent **"deepen Status into a real process inspector"** epic — overlaps the Mole-PRD's ProcessOrigin + BinaryIntegrity (don't duplicate; extend them into the inspector). +- Keep it honest/local (matches Burrow's stance). Per-process disk/QoS/page-ins are all unprivileged `proc_pidinfo` reads — no signing needed. +- Where ProcessSpy gates things behind paid (history export, env-vars/entitlements inspector), Burrow is open-core — these can be free. + +--- + +## 2. Hotspot Guide — hotspotguide.app (captive-portal Wi-Fi rescue for travelers) + +The connectivity app our **Get Online** pane was already modeled on (deep-researched earlier; the checks + one-click fixes are built). $6.99 MAS, **sandboxed** — which is its ceiling: it can only deep-link to Settings, never actually fix anything. **Burrow already beats it on the fixes** (real one-click Flush DNS / Renew DHCP via the privilege broker). So this capture is only the **net-new layer** the screenshots reveal beyond our current Get Online. + +### Already built in Burrow's Get Online (do NOT re-add) +The 9 device-side checks (Private Relay / VPN / proxy / custom DNS / MDM / gateway / captive-portal / reachability), Open-Settings deep-links, Open Login Page, per-item "recheck", and — where we **exceed** it — actual one-click Flush DNS / Renew DHCP. + +### Net-new → Burrow mapping + +**A. ⭐ Curated venue/airline/hotel tips database** — auto-detect the venue by SSID → show **known issues + bypass tips** ("Hilton properties run older portal software that blocks encrypted DNS; Honors members can bypass with their credentials"). Covers hotels (Hilton, Marriott) + airlines (Delta Fly-Fi, United, American, Southwest, Alaska, JetBlue, Spirit). Burrow: **new**. This is the **content moat** — curated knowledge, not code. Open-core angle: ship a small curated list, make it **community-extensible** (a JSON others can PR) — turns their static asset into our growing one. *Leverage:* SSID via CoreWLAN+Location (see D). + +**B. ⭐ Connection History** — per-attempt log: SSID, timestamp, success/fail, **measured speed (Mbps)**, and the **failure reason** (captive portal required / login page unreachable / no internet access), expandable + clearable. "Know which lounge has the fastest Wi-Fi." Burrow: **new** (we have a SQLite history DB for system metrics → reuse it for connection events). *Leverage:* DB + the Get Online probe results we already compute. + +**C. Public Wi-Fi mode vs Home mode** toggle — Public = the rescue checklist; Home = network details + surrounding-networks scan (WiFi-Explorer-lite). Burrow: **new** (small — a mode switch over existing + new Network Info). + +**D. Network Info / surrounding-networks scan** — per visible network: **RSSI / signal, channel, channel-congestion, security**; plus current SSID/IP/gateway/DNS. Burrow: **partial** (we already show IP/gateway/DNS in Get Online; SSID/signal/channel scan needs **CoreWLAN + Location permission** — already flagged *deferred* in the Mole PRD). This single permission unlocks A (SSID detect), C, and D. + +**E. Speed test (done right)** — throughput **+ packet loss + jitter** to distinguish "slow" from "flaky." Burrow: **new**. Note: Hotspot Guide's one-stream Cloudflare test reads badly low (user feedback: 37 Mbps vs real 2.5 Gbps) → if we build it, **multi-stream** (my earlier research flagged single-stream undercounts). Pairs with B (record the speed per attempt). + +**F. Offline troubleshooting Guides** — readable with **zero connectivity** (Private Relay / VPN / DNS / MDM / captive-portal basics, "slow vs flaky", internet-sharing tether). Burrow: **new**. Bundled markdown; cheap, high trust-value (the app works when the internet doesn't). + +**G. Lifetime stats** — "N networks · M checks run · K portals fixed." Burrow: **new**, cheap (we already persist; just count). + +**H. Sequential animated check run** with live per-check status ("Checking… / Waiting… / detected"). Burrow: **partial** (we run checks; match the staged live presentation). + +**I. Per-SSID credential autofill** (keychain: name/email/loyalty #, autofill on return) — Burrow: **new but niche**; only if we add a portal browser. Lower priority. + +### Standouts for Burrow +1. **Venue/airline tips DB (A)** + **Connection History with speeds & failure reasons (B)** — the two things that make Get Online feel like a *travel companion* rather than a diagnostic. A is a defensible content asset; make it community-extensible. +2. **Network Info / nearby-networks + SSID (D)** — one CoreWLAN+Location permission unlocks venue auto-detect, Home mode, and the scanner. Decide if we want that permission (Mole PRD currently defers it). +3. **Speed test + jitter/loss (E)** and **offline guides (F)** — round out the "rescue kit." +4. Cheap polish: **lifetime stats (G)**, **staged check animation (H)**. + +### Notes +- **Strategic position:** combine *our* real fixes (not sandboxed) + their venue DB + history + speed test + offline guides → Burrow's Get Online becomes strictly better than Hotspot Guide. The only thing gating the travel-companion half is the **CoreWLAN+Location** decision. +- Don't duplicate the Mole-parity PRD's connectivity items — this **extends** Get Online; fold A–H into that pane's roadmap. +- All of this is unsigned/sandbox-free work (CoreWLAN+Location is a normal permission prompt, not Developer ID). diff --git a/plans/mole-parity-prd-2026-06-25.md b/plans/mole-parity-prd-2026-06-25.md new file mode 100644 index 0000000..32b1b9f --- /dev/null +++ b/plans/mole-parity-prd-2026-06-25.md @@ -0,0 +1,187 @@ +# PRD — Close the Mole feature gaps (non-signing) + +> Local planning doc. **Not** filed as a GitHub issue (per request). Scope = every gap from the 2026-06-25 Burrow-vs-Mole audit **except** the three that require a Developer-ID-signed resident privileged helper (Battery Care, Fan control, the helper itself) — see Out of Scope. +> +> **Now also folds in two competitor-scan epics** (sourced from `competitor-feature-scan-2026-06-25.md`): **α — Process Inspector** (from ProcessSpy: deepen Status into a real inspector) and **β — Get Online → travel companion** (from Hotspot Guide: extend the shipped Get Online pane). Neither needs Developer-ID signing; β has one open permission decision (CoreWLAN + Location — see Cross-cutting decisions). + +## Problem Statement + +Burrow and Mole (mole.fit) both ride the `mo` engine, so our Clean / Optimize / Uninstall / Analyze **coverage** is largely at parity. But Mole's app has pulled ahead on two fronts a user actually notices: **native macOS features the engine doesn't provide** (system diagnostics, login-item management, update installation, privacy/awake utilities, process forensics) and **GUI/render polish** (treemap legibility, progressive scans, lifetime stats, keyboard/refresh affordances). A user comparing the two sees Burrow as the thinner, less-finished app even though the cleaning power is equivalent. We want to close that perceived and real gap without taking on the one thing we can't ship today — a signed privileged helper. + +## Solution + +A multi-phase program that brings Burrow to parity (or ahead) on everything that does **not** require Developer-ID code signing. The work splits into deep, independently testable "decision" modules (version gating, sizing, classification, parsing, posture) wrapped by thin impure seams (shell-outs, IOKit reads, render code), plus a set of pure GUI/UX refinements. Phase 1 is the cheap, high-leverage polish that makes the app feel finished; Phase 2 adds the native subsystems (modern login items, security posture, process forensics, real update resolution); Phase 3 is the heavy installer/forensics work. Everything ships behind the existing ad-hoc distribution — no new entitlements or signing. + +## User Stories + +**Software / Updates** +1. As a Mac user, I want App Store updates that require a newer macOS to be hidden, so that I'm not prompted to install something my Mac can't run. +2. As a Mac user, I want an App Store row to clear only once the on-disk version actually changes, so that a "still available" update doesn't linger after I've updated. +3. As a developer, I want Burrow to find updates for apps that publish only on GitHub Releases, so that I don't miss updates for tools without Sparkle/App Store feeds. +4. As an Electron-app user, I want Burrow to tell me an actual newer version exists (not just a badge), so that the Updates tab is trustworthy. +5. As a power user, I want to filter the update check by source (only Homebrew, only Sparkle, etc.), so that I can run a fast targeted check. +6. As a keyboard user, I want ⌘R to refresh the Updates list, so that I don't have to reach for the mouse. +7. As a user, I want a manual refresh to bypass stale catalog/HTTP caches, so that I see truly current versions. +8. As a user, I want a badge on the Software tab counting unseen app updates, so that I know when to look without opening the pane. +9. As a Homebrew user, I want casks and formulae distinguished in the list, so that I understand what each update is. + +**Uninstall** +10. As a user, I want an app's removal details to appear as I hover its row, so that I can preview what will be deleted without clicking. +11. As a user, I want a "Clear Data" action that wipes an app's data but keeps the app, so that I can reset an app without reinstalling it. +12. As a Homebrew user, I want cask apps removed with `--zap`, so that artifacts a file scan misses are also cleaned. +13. As a bilingual / iOS-app user, I want wrapped iOS apps and bilingual-named apps detected and searchable, so that I can find and remove them. +14. As a user, I want search to resolve app aliases, so that typing a common alternate name still finds the app. +15. As a user, I want input methods (e.g. WeChat/Doubao) surfaced clearly as removable leftovers, so that they don't hide in an "Other" bucket. +16. As a user, I want root-owned leftover items I tick to be removed via an admin prompt instead of silently failing, so that the removal actually completes. +17. As a user, I want Burrow to say plainly when nothing was selected/removed, so that I'm never misled into thinking a clean sweep happened. +18. As a user, I want an app's installer **receipts** linked into its removal set, so that uninstalling forgets the package too. + +**Startup items** +19. As a user, I want modern Login Items / background items (the System Settings list) shown alongside LaunchAgents, so that I see everything that auto-starts. +20. As a user, I want a LaunchAgent that targets an **unplugged external drive** to not be flagged as "broken", so that I don't delete something that's actually fine. +21. As a user, I want a one-click cleanup for genuinely broken login items, so that I can remove dead entries without editing plists. +22. As a user, I want "Reveal" to open the target app (not the plist) and locked rows to deep-link into System Settings, so that I can act on items Burrow can't toggle directly. + +**Clean** +23. As a user, I want clean results sorted by deletion impact (safest/regenerable first), so that I can skim and trust the top of the list. +24. As a user, I want credential/keychain remnants flagged with a distinct caution badge, so that I review them deliberately instead of treating them as ordinary cache. +25. As a user, I want my lifetime cleanup total shown on the Clean completion screen, so that I see the cumulative payoff where I just acted. +26. As a user, I want to see the path currently being scanned, so that the scan feels live and accountable. +27. As a user, I want section cards to appear and fill in progressively as the scan runs, so that I'm not staring at one number. +28. As a user, I want the macOS wallpaper cache protected by default, so that cleaning never blanks my desktop. +29. As a user, I want reclaim figures that don't double-count hardlinked files, so that the "space freed" number is honest. +30. As a user, I want root-owned items to go to a recoverable location even in Trash mode, so that I can undo a mistaken removal. + +**Optimize** +31. As a user, I want Burrow to warn me before optimizing when a VPN, external audio device, external display, or a Bluetooth keyboard/mouse is active, so that a maintenance run doesn't disrupt something I'm using. +32. As a user, I want user-visible fixes grouped/surfaced first in the results, so that the outcome reads as a clear summary. + +**Analyze** +33. As a user, I want tiny cells folded into a single "Other" cell and huge flat folders' long tails collapsed, so that the treemap stays legible and the picture matches the total. +34. As a user, I want app cells to show their real app icons, so that I can recognize apps at a glance. +35. As a user, I want long cell names to middle-truncate and show the full name on hover, so that I can read what a cell is. +36. As a user, I want tall narrow cells to rotate their label, so that I can read them too. +37. As a user, I want the big cells to draw immediately and slower folders to fill in, so that I don't wait on a blank pane. +38. As a user, I want to optionally start Analyze on the whole disk, so that I can see system-wide usage, not just Home. +39. As a user on a many-core Mac, I want sizing concurrency tuned to my machine, so that the scan is fast without pinning the CPU. + +**Status / Menu bar** +40. As a user, I want to tap a process and see where it came from (its shell / SSH session), so that I understand what a mystery process is. +41. As a security-minded user, I want a warning when a process runs from a deleted or replaced binary, so that I can spot tampering. +42. As a user, I want the menu-bar network badge to reflect the interface actually carrying traffic, so that the readout is meaningful. +43. As a user, I want the menu-bar runner available on by default with a planet/character-style option whose cadence can track my display, so that the menu bar feels alive like Mole's. +44. As a user, I want the menu-bar item to come back automatically if Control Center drops it, so that I don't lose Burrow until I relaunch. + +**Doctor** +45. As a user, I want Doctor to report SIP, Gatekeeper, FileVault, and firewall status, so that I know my security posture at a glance. +46. As a user, I want Doctor to include battery-health and high-CPU checks, so that the report reflects what's actually stressing my Mac. +47. As a user, I want display, external-volume, and network context in Doctor, so that a shared report carries the details a maintainer needs. +48. As a user, I want a "Copy diagnostics" button, so that I can paste the report into a support thread in one tap. + +**Everyday / Privacy / fit-and-finish** +49. As a laptop user, I want Keep Screen On to optionally keep tasks running with the lid closed, so that a backup or render doesn't die when I shut the lid. +50. As a user, I want an active Keep-Screen-On session to be restored after a relaunch, so that my intent survives a restart. +51. As a privacy-minded user, I want a notification when my camera or microphone turns on, so that I notice usage without watching the popover. +52. As a user, I want to choose the Clean Screen color and exit via a deliberate move-and-hold, so that wiping is flexible and I don't exit by accident. +53. As a user, I want a dark-mode adaptive app icon and a native full-screen shortcut, so that the app matches platform expectations. +54. As a VoiceOver/keyboard user, I want labels on the main panes' metrics and controls and visible keyboard focus, so that the whole app is usable without a mouse. + +**α — Process Inspector (from ProcessSpy)** +55. As a power user, I want a rich inspector panel for a selected process (identity, command line, launched-by, arch/native-vs-Rosetta, sandbox/hardened/signing/entitlements), so that I understand exactly what a process is. +56. As a power user, I want per-process resource detail — CPU user/sys split, QoS class, memory footprint + peak, page-ins, threads, and per-process disk I/O — so that I can diagnose what a process is doing. +57. As a user, I want a per-process **network** up/down readout in the table and inspector, so that I can see which process is eating my bandwidth (a thing ProcessSpy refuses to do). +58. As a user, I want a **watchdog** that alerts me — and can optionally quit or suspend a process — when one holds high CPU/memory/disk for a sustained time, so that runaway processes get caught automatically. +59. As an automation user, I want to run a macOS Shortcut when a process matching a rule spawns / exits / crosses a threshold, so that I can wire process events into my own workflows. +60. As a power user, I want a process tree/hierarchy view with summed CPU/memory/threads (XPC children linked), so that I can see a process and its children together. +61. As a power user, I want predicate/expression filters and saved filter tabs (unsigned, ANE/Neural, startup, recent, app-specific), so that I can slice the process list precisely. +62. As a user, I want a per-row mini CPU graph, so that I can spot a spiking process at a glance. +63. As a user, I want to suspend and resume a process (and have resume actually work), so that I can pause a heavy task without killing it. +64. As a user, I want to reveal a process's open files in Finder, so that I can see what it's touching. +65. As a user, I want recently-finished processes kept visible with their last metrics, so that I can investigate something that already exited. +66. As a user, I want to export the visible process list to JSON/CSV, so that I can share or analyze it. + +**β — Get Online → travel companion (from Hotspot Guide)** +67. As a traveler, I want Burrow to recognize the venue/airline by network name and show its known portal quirks + bypass tips, so that I know what to try before guessing. +68. As a traveler, I want a history of my connection attempts per network — success/failure, measured speed, and what went wrong — so that I know which networks (or lounges) actually work. +69. As a traveler, I want an accurate speed test with jitter and packet loss, so that I can tell a slow connection from a flaky one. +70. As a user at home, I want a Home mode showing my network details and the surrounding Wi-Fi networks (signal, channel, security), so that I can troubleshoot my own network too. +71. As a traveler, I want the troubleshooting guides to work with no connectivity, so that I can read them exactly when I'm offline. +72. As a user, I want the checks to run as a visible staged sequence and show lifetime stats (networks, checks, portals fixed), so that the run feels alive and I see the payoff. +73. As a returning traveler, I want Burrow to optionally remember and autofill my portal login per network, so that re-joining a hotel chain is one tap. *(niche; depends on an embedded portal browser — see Out of Scope.)* + +## Implementation Decisions + +**Architecture.** Each gap is split into a **deep, pure decision module** (the testable core) and a **thin impure seam** (shell-out / IOKit / render / persistence). This mirrors the existing pattern (e.g. the nettop and scutil parsers are pure; sampling is the seam). No new entitlements, no signing, no resident helper — elevation, where needed, reuses the existing one-shot `osascript … with administrator privileges` broker (already used for Clean/Optimize and the Connectivity flush-DNS/renew-DHCP fixes). + +**Deep modules to build (pure cores), each with a stable, narrow interface:** +- **OSUpdateGate** — inputs: an app's `minimumOsVersion` + the running OS version; output: installable / blocked. Also re-reads the on-disk version to clear a row. +- **GitHubReleaseResolver** — inputs: repo coordinates + installed version + a release-list payload; output: newer version / none. Plus a bundle→repo heuristic. +- **ElectronVersionResolver** — resolves an Electron app's latest version from its update feed; pure parse over the fetched payload. +- **UpdateSeenStore** — diff of current available-update set vs a persisted "seen" set → unseen count for the badge. +- **CleanImpactRanker** — assigns a safety/impact rank to a clean category/item → review-list ordering. +- **SensitiveRemnantMatcher** — flags credential/keychain-style paths for a caution badge. +- **HardlinkAwareSizer** — given a path set and an inode/nlink provider, computes exclusive bytes (de-counts shared inodes). Phase-3 full version; Phase-1 ships a cheaper "de-dup obvious double-counts" variant. +- **TreemapTail** — folds the long tail (by area/count threshold) into one inert "Other" cell before layout. +- **LoginItemsReader** — parses the modern background-task-manager dump (`sfltool dumpbtm`-style) into login/background items, merged with the plist scan. +- **RemovableVolumeGuard** — given a missing executable path + the mounted-volume set, classifies "on an unplugged removable drive" vs "broken." +- **ReceiptLinker** — parses `pkgutil` output, maps receipts → bundle id, lists forgotten files for the uninstall set. +- **SecurityPosture** — parses `csrutil status` / `spctl` / `fdesetup status` / firewall state into SIP/Gatekeeper/FileVault/firewall verdicts (each a tiny pure parser). +- **ProcessOrigin** — given a ppid chain + controlling tty + ancestry, classifies a process's launch origin (login shell / Terminal session / sshd connection). +- **BinaryIntegrity** — given a running exe path + on-disk state, classifies intact / deleted / replaced. +- **OptimizeGuards** — given VPN / external-audio / external-display / BT-input state, emits pre-run warnings (reuses the existing VPN detector). +- **UninstallPlanner additions** — `DataOnly` subset (everything except the `.app`), cask-zap token derivation, input-method/iOS-wrapper/bilingual classification + alias index. + +**α — Process Inspector modules (pure cores):** +- **ProcessInspectorReader** — reads QoS / footprint+peak / page-ins / user-sys split / per-process disk I/O / threads / arch (Rosetta) via `proc_pidinfo` / `proc_pid_rusage`; the syscall is the seam, field decoding is pure. Composes with **ProcessOrigin** + **BinaryIntegrity** (already above) + **NetUsage** (per-pid bandwidth, already built for Ports) to populate one inspector panel. +- **ProcessRule / ProcessWatchdog** — given a rule (match predicate + metric + threshold + sustain window) and the per-process sample stream, decides fire/clear and the action (notify / quit / suspend). Pure rule eval, same shape as **AlertEngine**; the sampler + signal/Shortcut dispatch are seams. Also drives **Shortcuts-on-process-event** (spawn/exit/threshold). +- **ProcessTree** — folds the flat ppid list into a parent→children tree with summed CPU/memory/threads; XPC children linked by responsible pid. Pure. +- **ProcessFilter** — evaluates a filter expression over a process record (see filter-language decision); pure. Saved smart tabs are presets over it. +- **ProcessExport** — serialize the visible set to JSON/CSV; pure. + +**β — Get Online companion modules:** +- **VenueMatcher** — given an SSID + the bundled, community-extensible venue/airline catalog, returns the matched venue + its tips. Pure (the SSID read is a seam, gated on CoreWLAN+Location). +- **ConnectionHistoryRecorder** — records per-attempt events (SSID, result, measured speed, failure reason) into the existing SQLite history DB; the failure-reason classification reuses the Get Online probe verdicts (pure), the DB write is the seam. +- **SpeedTest** — multi-stream throughput + jitter + packet-loss; the network transfer is the seam, the sample→result aggregation is pure (single-stream undercounts badly — see the earlier connectivity research). +- **NearbyNetworks** — CoreWLAN scan → per-network RSSI / channel / channel-congestion / security; the scan is the seam (needs Location), the sorting/congestion read-out is pure. + +**Modified surfaces (thin seams + GUI):** Updates pane (gate/resolvers/filter/badge/⌘R/cache-policy), Uninstall flow (hover pre-scan, Clear-Data, zap branch, root-owned via broker, receipts), Startup segment (BTM merge, removable-drive guard, broken cleanup, reveal-app + Settings deep-link), Clean (impact sort, sensitive badge, lifetime total on done screen, live path, progressive sections, wallpaper default-protect), Optimize (pre-run guard banner), Analyze treemap (Other-fold, real icons, hover tooltip, rotated labels, progressive entries, whole-disk option, core-count concurrency, mtime cache invalidation), Status/menu bar (process-origin inspector, binary-integrity badge, default-route badge interface, runner default + planet variant + display-Hz cadence, Control-Center restore watchdog, incremental rows, lazy GPU), Doctor (security/battery/CPU/display/volume/network checks + copy button), Everyday (lid-closed assertion + restore, Clean Screen color + move-and-hold, camera/mic start notification), and app-level fit-and-finish (dark-mode appicon variant, full-screen command, accessibility-label sweep). **α** — Status process view (inspector detail panel + tree view + per-process net/QoS/footprint/page-ins/disk columns + watchdog rules + predicate filters/saved tabs + per-row sparkline + suspend/resume + reveal-open-files + recently-finished recall + JSON/CSV export). **β** — Get Online pane (venue auto-detect/picker + per-network connection history + multi-stream speed test + Public/Home mode + nearby-networks scanner + bundled offline guides + lifetime stats + staged check run). + +**Elevation policy.** Anything requiring root (root-owned trash, pkgutil --forget, batch login/agent removal) goes through the existing one-shot osascript admin broker — never a resident helper. If a given action can't be done acceptably with a single per-action prompt, it is deferred rather than gated on signing. (α's suspend/resume + quit are plain `kill`/`SIGSTOP`/`SIGCONT` on the user's own processes — no elevation.) + +**Cross-cutting decisions (resolve before building the dependent items):** +- **CoreWLAN + Location permission** — yes/no. Gates **β**'s SSID auto-detect (→ VenueMatcher), Public/Home mode, and NearbyNetworks scanner. If declined, β degrades gracefully to a manual venue picker + connection history + speed test + offline guides (still useful, no SSID). The Mole-gap audit previously *deferred* Wi-Fi SSID/signal scanning for this reason; β is the case to reconsider it. One permission unlocks three β features. +- **Filter language for ProcessFilter** — embedded JS (ProcessSpy's choice) vs a small typed predicate DSL. The typed DSL likely fits Burrow's agent-native angle better (no JS runtime, same predicates usable from MCP/agents) and avoids shipping a JS engine; decide before α's filter work. +- **Per-process deep-metrics reader** (`proc_pidinfo`/`proc_pid_rusage`) is built **once** and feeds the whole α inspector — sequence it first within α. + +## Testing Decisions + +**What a good test is here:** exercise a module's external behavior through its public interface against **captured real-world fixtures**, never its internals. The codebase's parser tests are the model — e.g. the connectivity parsers are tested against real `scutil`/`route` output, the nettop parser against a real `nettop` frame, the clean-list/disk-scanner parsers against captured engine output. New pure modules follow the same shape: feed recorded command output / version strings / path sets in, assert the decision out. + +**Modules to cover with tests (the pure cores):** OSUpdateGate, GitHubReleaseResolver, ElectronVersionResolver, UpdateSeenStore, CleanImpactRanker, SensitiveRemnantMatcher, HardlinkAwareSizer, TreemapTail, LoginItemsReader, RemovableVolumeGuard, ReceiptLinker, SecurityPosture (one case per tool's output), ProcessOrigin, BinaryIntegrity, OptimizeGuards, and the UninstallPlanner additions (DataOnly subset, cask-zap token, classifiers/alias index). **α:** ProcessInspectorReader (field decode from a captured `proc_pidinfo` blob), ProcessRule/Watchdog (fire/clear over a synthetic sample stream + correct action), ProcessTree (tree shape + summed aggregates), ProcessFilter (predicate eval), ProcessExport (JSON/CSV shape). **β:** VenueMatcher (SSID→venue+tips against the catalog), ConnectionHistoryRecorder's failure-reason classifier (probe-verdict → reason), SpeedTest sample→result aggregation (jitter/loss math), NearbyNetworks sorting/congestion read-out. + +**Not unit-tested (integration / impure seams):** render code (treemap drawing, label rotation, real-icon compositing), IOKit/IOPMAssertion calls, `NSStatusItem` visibility watchdog, notification posting, osascript-admin spawns, and live network installs. These are verified by build + hand-test, consistent with how the native enumeration and render paths are handled today. + +**Prior art to copy:** `ConnectivityTests`, `NetUsageTests`, `CleanListTests`, `DiskScannerTests`, `AnomalyScanTests`, `DoctorTests`, `PortInspectorTests` — same fixture-in / verdict-out structure. + +## Out of Scope + +- **Battery Care** (hold charge ~80% / resume <75%), **Fan control** (Auto/Cool/Quiet presets), and the **shared resident signed privileged helper** they both require. These need a Developer-ID-signed `SMJobBless`/`SMAppService` LaunchDaemon that writes SMC / charge-controller keys; Burrow ships **ad-hoc (no Developer ID)**, so a resident helper can't be installed/trusted. Explicitly excluded per request. (We keep the existing read-only SMC fan-RPM monitoring.) +- **Bundling RunCat's actual cat artwork** or any third-party runner art — licensing. The runner work ships **original/programmatic** runners only (the planet/character variant is our own art/shapes). +- **Per-app camera/mic attribution + "mute trusted apps" + suppressing Siri/dictation as false alarms** — this needs per-app device-usage attribution that may require private APIs/entitlements; the camera/mic **start notification** (system-level) is in scope, the per-app attribution layer is parked pending feasibility. +- **Managed website-installer download-cache + notarization-verify install** beyond the serial Update-All flow is Phase 3 and may be reduced if it proves to need more than a single admin prompt per app. +- **β: embedded portal browser + per-network credential autofill** (story 73) — needs a bundled web view + a Keychain credential store; parked as niche, revisit only if the rest of β lands well. +- **Embedding a JavaScript runtime for process filters** — avoided in favor of a typed predicate DSL (see Cross-cutting decisions); reconsider only if the DSL proves too limiting. +- Anything already at parity or where Burrow exceeds Mole (see the audit's "match/exceed" list) — no work. + +## Further Notes + +- **Phasing (by value ÷ effort).** + - **Phase 1 — quick wins / polish:** OSUpdateGate, ⌘R + cache-bypass, Doctor (battery/CPU + copy + SIP/Gatekeeper/FileVault/firewall), Clean impact-sort + sensitive-badge + lifetime-total-on-done + live-path + wallpaper-protect, treemap Other-fold + tooltip + real icons, Keep-Screen-On lid-closed + restore, Clean Screen color + move-and-hold, dark-mode appicon, full-screen shortcut, uninstall Clear-Data + alias search + input-method + "all-skipped" message + hover pre-scan, startup removable-drive guard + broken cleanup + reveal-app + Settings deep-link, net-badge default-route interface, hardlink double-count de-dup. + - **Phase 2 — native subsystems:** GitHub/Electron resolvers + source filter + unseen badge + cask split, LoginItemsReader (BTM), ReceiptLinker + cask `--zap` + iOS-wrapper detection, OptimizeGuards, treemap progressive render + rotated labels + core-count concurrency + whole-disk scope + mtime invalidation, ProcessOrigin + BinaryIntegrity inspector, Control-Center restore watchdog, camera/mic start notification, runner-on-by-default + planet variant + display-Hz cadence, root-owned trash via broker, incremental process rows + lazy GPU, accessibility-label sweep. + - **Phase 3 — heavy:** managed serial Update-All / installer, full HardlinkAwareSizer, Dock-ghost + one-prompt batch login/agent removal at uninstall. + - **Epic α (Process Inspector) — sequence:** α1 deep-metrics reader + **per-process network column** (near-free, reuses NetUsage) → α2 **watchdog + actions + Shortcuts-on-event** (reuses AlertEngine — top ask) → α3 inspector panel (composes ProcessOrigin + BinaryIntegrity) → α4 tree view + predicate filters/saved tabs + per-row sparkline → α5 suspend/resume + reveal-open-files + recently-finished recall + export. + - **Epic β (Get Online companion) — sequence:** β1 connection history (reuses the DB) + lifetime stats + staged check run → β2 venue/airline tips DB (curated + community JSON) → β3 multi-stream speed test → β4 *(gated on CoreWLAN+Location)* nearby-networks scanner + Public/Home mode → β5 offline guides. +- **Highest-ROI shortlist across all epics (build first):** per-process network column (α1) · per-process watchdog + actions + Shortcuts (α2) · connection history + venue DB (β1/β2) · inspector panel + the `proc_pidinfo` reader (α3). All reuse existing infra (NetUsage, AlertEngine, the history DB, ProcessOrigin/BinaryIntegrity) and are the most visible depth wins. +- **Engine caveat.** A few "gaps" are partly the `mo` engine's job (external-Applications enumeration, some leftover breadth, optimize task ordering). Where the engine already covers it, the app change is just surfacing/guarding; where it doesn't, the module above owns it. Items that would require changing `mo` itself are flagged in their issue, not solved here. +- **No third-party-app coupling.** Update resolvers and receipt/zap logic must degrade gracefully (an app with no resolvable source simply shows no update, never a wrong one) — same honesty bar as the rest of the app. +- This doc stays in `plans/`; do not file as a GitHub issue or push without an explicit go-ahead. From 3d039a4fd65dbd062ec074eba76d108dacd96bc4 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:55:44 -0700 Subject: [PATCH 02/33] feat(parity): SecurityPosture + OSUpdateGate + RemovableVolumeGuard (pure cores + tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First TDD modules for the parity PRD. Pure parsers/logic, type-checked standalone (swiftc -typecheck); real-fixture-backed (csrutil/spctl/fdesetup/socketfilterfw). - SecurityPosture: SIP/Gatekeeper/FileVault/firewall verdicts for Doctor. - OSUpdateGate: hide App Store updates needing a newer macOS; clear row on landing. - RemovableVolumeGuard: don't flag a LaunchAgent on an unplugged drive as broken. NOTE: full app build/test-run deferred — disk full (1.9Gi); modules verified via swiftc -typecheck, suite runs on CI. --- macos/Sources/OSUpdateGate.swift | 44 ++++++++++++++++++ macos/Sources/RemovableVolumeGuard.swift | 27 +++++++++++ macos/Sources/SecurityPosture.swift | 51 +++++++++++++++++++++ macos/Tests/OSUpdateGateTests.swift | 29 ++++++++++++ macos/Tests/RemovableVolumeGuardTests.swift | 28 +++++++++++ macos/Tests/SecurityPostureTests.swift | 35 ++++++++++++++ 6 files changed, 214 insertions(+) create mode 100644 macos/Sources/OSUpdateGate.swift create mode 100644 macos/Sources/RemovableVolumeGuard.swift create mode 100644 macos/Sources/SecurityPosture.swift create mode 100644 macos/Tests/OSUpdateGateTests.swift create mode 100644 macos/Tests/RemovableVolumeGuardTests.swift create mode 100644 macos/Tests/SecurityPostureTests.swift diff --git a/macos/Sources/OSUpdateGate.swift b/macos/Sources/OSUpdateGate.swift new file mode 100644 index 0000000..2f58881 --- /dev/null +++ b/macos/Sources/OSUpdateGate.swift @@ -0,0 +1,44 @@ +// +// OSUpdateGate.swift +// Burrow +// +// App Store update gating (PRD §Software): decide whether an update is +// actually installable on the running macOS (hide it if it needs a newer OS — +// the false-prompt Mole suppresses), and when a shown "update available" row +// should clear (the on-disk version finally caught up). Pure — the Updates +// pane supplies `minimumOsVersion` (from the iTunes lookup) + the on-disk +// version. +// + +import Foundation + +enum OSUpdateGate { + /// Dotted version → comparable integer components ("14", "26.5.1"). + static func parse(_ v: String) -> [Int] { + v.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 } + } + + /// a >= b over dotted versions (ragged lengths padded with 0). + static func atLeast(_ a: [Int], _ b: [Int]) -> Bool { + let n = max(a.count, b.count) + for i in 0.. y } + } + return true + } + + /// Whether an App Store update requiring `minimumOS` can install on the + /// running OS. nil/empty minimum = no requirement → installable. + static func isInstallable(minimumOS: String?, running: String) -> Bool { + guard let m = minimumOS?.trimmingCharacters(in: .whitespaces), !m.isEmpty else { return true } + return atLeast(parse(running), parse(m)) + } + + /// Whether a previously-shown update row should clear: the on-disk version + /// is now at least the version we offered (the update actually landed). + static func updateLanded(offered: String, onDisk: String) -> Bool { + atLeast(parse(onDisk), parse(offered)) + } +} diff --git a/macos/Sources/RemovableVolumeGuard.swift b/macos/Sources/RemovableVolumeGuard.swift new file mode 100644 index 0000000..3093bf0 --- /dev/null +++ b/macos/Sources/RemovableVolumeGuard.swift @@ -0,0 +1,27 @@ +// +// RemovableVolumeGuard.swift +// Burrow +// +// Startup-item false-positive guard (PRD §Startup): a LaunchAgent whose +// executable lives on an external drive that's currently unplugged must NOT be +// flagged "broken" (and never auto-removed). Pure — given the missing path and +// the set of currently-mounted volume roots, classify unplugged-vs-broken; the +// mounted-volume enumeration is the impure seam in the startup scanner. +// + +import Foundation + +enum RemovableVolumeGuard { + enum Verdict: Equatable { case broken, onUnpluggedVolume } + + /// A path under `/Volumes//…` whose volume root isn't in the mounted + /// set is on an unplugged drive (skip). Anything else that's missing is + /// genuinely broken. + static func classify(missingPath: String, mountedVolumes: Set) -> Verdict { + guard missingPath.hasPrefix("/Volumes/") else { return .broken } + let comps = missingPath.split(separator: "/", omittingEmptySubsequences: true) + guard comps.count >= 2 else { return .broken } // ["Volumes", "", …] + let volRoot = "/Volumes/\(comps[1])" + return mountedVolumes.contains(volRoot) ? .broken : .onUnpluggedVolume + } +} diff --git a/macos/Sources/SecurityPosture.swift b/macos/Sources/SecurityPosture.swift new file mode 100644 index 0000000..5c30e30 --- /dev/null +++ b/macos/Sources/SecurityPosture.swift @@ -0,0 +1,51 @@ +// +// SecurityPosture.swift +// Burrow +// +// Security-posture verdicts for the Doctor report (PRD §Doctor): SIP, +// Gatekeeper, FileVault, firewall — parsed from the standard system tools. +// Pure parsers; the command spawns are the impure seam in Doctor. Each tool's +// output is tiny and stable. An unrecognized line reads as `.unknown` — never +// a wrong "secure". +// + +import Foundation + +enum SecurityPosture { + enum State: String, Equatable { case on, off, unknown } + + /// `csrutil status` → "System Integrity Protection status: enabled." + static func sip(_ out: String) -> State { + let s = out.lowercased() + if s.contains("status: enabled") { return .on } + if s.contains("status: disabled") { return .off } + return .unknown + } + + /// `spctl --status` → "assessments enabled". + static func gatekeeper(_ out: String) -> State { + let s = out.lowercased() + if s.contains("assessments enabled") { return .on } + if s.contains("assessments disabled") { return .off } + return .unknown + } + + /// `fdesetup status` → "FileVault is On." + static func fileVault(_ out: String) -> State { + let s = out.lowercased() + if s.contains("filevault is on") { return .on } + if s.contains("filevault is off") { return .off } + return .unknown + } + + /// `/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate` + /// → "Firewall is enabled. (State = 1)". State 0 = off, 1 = on, 2 = block-all. + static func firewall(_ out: String) -> State { + let s = out.lowercased() + if s.contains("state = 1") || s.contains("state = 2") { return .on } + if s.contains("state = 0") { return .off } + if s.contains("enabled") { return .on } + if s.contains("disabled") { return .off } + return .unknown + } +} diff --git a/macos/Tests/OSUpdateGateTests.swift b/macos/Tests/OSUpdateGateTests.swift new file mode 100644 index 0000000..85ffb53 --- /dev/null +++ b/macos/Tests/OSUpdateGateTests.swift @@ -0,0 +1,29 @@ +// +// OSUpdateGateTests.swift +// BurrowTests +// + +import XCTest +@testable import Burrow + +final class OSUpdateGateTests: XCTestCase { + func testInstallable_hiddenWhenOSTooOld() { + XCTAssertFalse(OSUpdateGate.isInstallable(minimumOS: "26.0", running: "15.5")) + XCTAssertTrue(OSUpdateGate.isInstallable(minimumOS: "15.0", running: "15.5")) + XCTAssertTrue(OSUpdateGate.isInstallable(minimumOS: "15.5", running: "15.5"), "equal installs") + XCTAssertTrue(OSUpdateGate.isInstallable(minimumOS: nil, running: "15.5"), "no requirement") + XCTAssertTrue(OSUpdateGate.isInstallable(minimumOS: "", running: "15.5")) + } + + func testRaggedVersions() { + XCTAssertTrue(OSUpdateGate.isInstallable(minimumOS: "14", running: "14.5.1")) + XCTAssertFalse(OSUpdateGate.isInstallable(minimumOS: "14.6", running: "14.5")) + XCTAssertTrue(OSUpdateGate.isInstallable(minimumOS: "26", running: "26.5.1")) + } + + func testUpdateLanded() { + XCTAssertTrue(OSUpdateGate.updateLanded(offered: "2.0.0", onDisk: "2.0.0")) + XCTAssertTrue(OSUpdateGate.updateLanded(offered: "2.0.0", onDisk: "2.1.0")) + XCTAssertFalse(OSUpdateGate.updateLanded(offered: "2.0.0", onDisk: "1.9.9")) + } +} diff --git a/macos/Tests/RemovableVolumeGuardTests.swift b/macos/Tests/RemovableVolumeGuardTests.swift new file mode 100644 index 0000000..a123380 --- /dev/null +++ b/macos/Tests/RemovableVolumeGuardTests.swift @@ -0,0 +1,28 @@ +// +// RemovableVolumeGuardTests.swift +// BurrowTests +// + +import XCTest +@testable import Burrow + +final class RemovableVolumeGuardTests: XCTestCase { + func testUnpluggedVolume_isNotBroken() { + XCTAssertEqual(RemovableVolumeGuard.classify(missingPath: "/Volumes/MyDrive/App.app/Contents/MacOS/App", + mountedVolumes: []), .onUnpluggedVolume) + XCTAssertEqual(RemovableVolumeGuard.classify(missingPath: "/Volumes/Backup/x", + mountedVolumes: ["/Volumes/Other"]), .onUnpluggedVolume) + } + + func testMountedButMissing_isBroken() { + XCTAssertEqual(RemovableVolumeGuard.classify(missingPath: "/Volumes/MyDrive/App.app", + mountedVolumes: ["/Volumes/MyDrive"]), .broken) + } + + func testInternalPath_isBroken() { + XCTAssertEqual(RemovableVolumeGuard.classify(missingPath: "/Applications/Foo.app/Contents/MacOS/Foo", + mountedVolumes: []), .broken) + XCTAssertEqual(RemovableVolumeGuard.classify(missingPath: "/Volumes", + mountedVolumes: []), .broken) + } +} diff --git a/macos/Tests/SecurityPostureTests.swift b/macos/Tests/SecurityPostureTests.swift new file mode 100644 index 0000000..a6eabaf --- /dev/null +++ b/macos/Tests/SecurityPostureTests.swift @@ -0,0 +1,35 @@ +// +// SecurityPostureTests.swift +// BurrowTests +// +// Real-fixture parser tests for the Doctor security-posture checks. +// + +import XCTest +@testable import Burrow + +final class SecurityPostureTests: XCTestCase { + func testSIP() { + XCTAssertEqual(SecurityPosture.sip("System Integrity Protection status: enabled."), .on) + XCTAssertEqual(SecurityPosture.sip("System Integrity Protection status: disabled."), .off) + XCTAssertEqual(SecurityPosture.sip("unexpected"), .unknown) + } + + func testGatekeeper() { + XCTAssertEqual(SecurityPosture.gatekeeper("assessments enabled\n"), .on) + XCTAssertEqual(SecurityPosture.gatekeeper("assessments disabled\n"), .off) + XCTAssertEqual(SecurityPosture.gatekeeper(""), .unknown) + } + + func testFileVault() { + XCTAssertEqual(SecurityPosture.fileVault("FileVault is On."), .on) + XCTAssertEqual(SecurityPosture.fileVault("FileVault is Off."), .off) + XCTAssertEqual(SecurityPosture.fileVault("Deferred enablement appears to be active"), .unknown) + } + + func testFirewall() { + XCTAssertEqual(SecurityPosture.firewall("Firewall is enabled. (State = 1)"), .on) + XCTAssertEqual(SecurityPosture.firewall("Firewall is enabled. (State = 2)"), .on) // block-all + XCTAssertEqual(SecurityPosture.firewall("Firewall is disabled. (State = 0)"), .off) + } +} From e7fb52ff1bf19cf7c56d70c0398aa93b1aeac50f Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:00:39 -0700 Subject: [PATCH 03/33] =?UTF-8?q?feat(parity):=208=20pure=20cores=20?= =?UTF-8?q?=E2=80=94=20clean-rank,=20sensitive-flag,=20update-seen,=20opti?= =?UTF-8?q?mize-guards,=20process-tree/rule,=20venue,=20conn-failure=20(+?= =?UTF-8?q?=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers PRD §Clean (impact sort + sensitive badge), §Software (unseen badge), §Optimize (pre-run guards), §α (process tree aggregates + watchdog rule), §β (venue matcher + connection-failure classifier). All type-checked standalone. --- macos/Sources/CleanImpactRanker.swift | 29 +++++++++++++ .../Sources/ConnectionFailureClassifier.swift | 24 +++++++++++ macos/Sources/OptimizeGuards.swift | 29 +++++++++++++ macos/Sources/ProcessRule.swift | 29 +++++++++++++ macos/Sources/ProcessTree.swift | 40 ++++++++++++++++++ macos/Sources/SensitiveRemnantMatcher.swift | 21 ++++++++++ macos/Sources/UpdateSeenStore.swift | 24 +++++++++++ macos/Sources/VenueMatcher.swift | 42 +++++++++++++++++++ macos/Tests/CleanImpactRankerTests.swift | 18 ++++++++ .../ConnectionFailureClassifierTests.swift | 11 +++++ macos/Tests/OptimizeGuardsTests.swift | 15 +++++++ macos/Tests/ProcessRuleTests.swift | 18 ++++++++ macos/Tests/ProcessTreeTests.swift | 21 ++++++++++ .../Tests/SensitiveRemnantMatcherTests.swift | 15 +++++++ macos/Tests/UpdateSeenStoreTests.swift | 17 ++++++++ macos/Tests/VenueMatcherTests.swift | 15 +++++++ 16 files changed, 368 insertions(+) create mode 100644 macos/Sources/CleanImpactRanker.swift create mode 100644 macos/Sources/ConnectionFailureClassifier.swift create mode 100644 macos/Sources/OptimizeGuards.swift create mode 100644 macos/Sources/ProcessRule.swift create mode 100644 macos/Sources/ProcessTree.swift create mode 100644 macos/Sources/SensitiveRemnantMatcher.swift create mode 100644 macos/Sources/UpdateSeenStore.swift create mode 100644 macos/Sources/VenueMatcher.swift create mode 100644 macos/Tests/CleanImpactRankerTests.swift create mode 100644 macos/Tests/ConnectionFailureClassifierTests.swift create mode 100644 macos/Tests/OptimizeGuardsTests.swift create mode 100644 macos/Tests/ProcessRuleTests.swift create mode 100644 macos/Tests/ProcessTreeTests.swift create mode 100644 macos/Tests/SensitiveRemnantMatcherTests.swift create mode 100644 macos/Tests/UpdateSeenStoreTests.swift create mode 100644 macos/Tests/VenueMatcherTests.swift diff --git a/macos/Sources/CleanImpactRanker.swift b/macos/Sources/CleanImpactRanker.swift new file mode 100644 index 0000000..f87e281 --- /dev/null +++ b/macos/Sources/CleanImpactRanker.swift @@ -0,0 +1,29 @@ +// +// CleanImpactRanker.swift +// Burrow +// +// Orders clean review items by deletion impact (PRD §Clean): safest / +// most-regenerable first, user-visible state last. Pure — keyed off the +// category name the engine reports. Lower rank = safer = shown first. +// + +import Foundation + +enum CleanImpactRanker { + /// 0 = pure regenerable cache … 4 = credentials / user state. + static func rank(category: String) -> Int { + let c = category.lowercased() + if c.contains("credential") || c.contains("keychain") || c.contains("login") { return 4 } + if c.contains("document") || c.contains("state") || c.contains("essential") { return 3 } + if c.contains("log") || c.contains("leftover") || c.contains("trash") { return 2 } + if c.contains("download") || c.contains("derived") || c.contains("build") || c.contains("artifact") { return 1 } + return 0 // caches + everything else: safest + } + + /// Stable ascending-impact sort, preserving input order within a rank. + static func sorted(_ items: [(category: String, value: T)]) -> [T] { + items.enumerated() + .sorted { (rank(category: $0.element.category), $0.offset) < (rank(category: $1.element.category), $1.offset) } + .map { $0.element.value } + } +} diff --git a/macos/Sources/ConnectionFailureClassifier.swift b/macos/Sources/ConnectionFailureClassifier.swift new file mode 100644 index 0000000..33fecf9 --- /dev/null +++ b/macos/Sources/ConnectionFailureClassifier.swift @@ -0,0 +1,24 @@ +// +// ConnectionFailureClassifier.swift +// Burrow +// +// Classifies why a Get Online attempt failed, from the probe verdicts, for the +// connection-history log (PRD §β). Pure. +// + +import Foundation + +enum ConnectionFailureClassifier { + enum Reason: String, Equatable { + case ok // online + case captivePortal // portal present, login page reachable + case loginUnreachable // portal present, login page itself didn't respond + case noInternet // no portal, no reachability + } + + static func classify(online: Bool, portal: Bool, loginReachable: Bool) -> Reason { + if online { return .ok } + if portal { return loginReachable ? .captivePortal : .loginUnreachable } + return .noInternet + } +} diff --git a/macos/Sources/OptimizeGuards.swift b/macos/Sources/OptimizeGuards.swift new file mode 100644 index 0000000..f8a3033 --- /dev/null +++ b/macos/Sources/OptimizeGuards.swift @@ -0,0 +1,29 @@ +// +// OptimizeGuards.swift +// Burrow +// +// Pre-run safety warnings for Optimize (PRD §Optimize): warn before a +// maintenance run when something the user relies on is active. Pure — the +// system-state probes (VPN / audio / display / Bluetooth input) are the seam. +// + +import Foundation + +enum OptimizeGuards { + struct State { + var vpnActive = false + var externalAudio = false + var externalDisplay = false + var btInput = false + } + + /// Human-readable warnings for whatever's active; empty = clear to run. + static func warnings(_ s: State) -> [String] { + var out: [String] = [] + if s.vpnActive { out.append("A VPN is active — maintenance may reset network state.") } + if s.externalAudio { out.append("An external audio device is in use — audio maintenance may interrupt it.") } + if s.externalDisplay { out.append("An external display is connected — display tasks may flicker it.") } + if s.btInput { out.append("A Bluetooth keyboard/mouse is connected — avoid Bluetooth resets.") } + return out + } +} diff --git a/macos/Sources/ProcessRule.swift b/macos/Sources/ProcessRule.swift new file mode 100644 index 0000000..d9d38b3 --- /dev/null +++ b/macos/Sources/ProcessRule.swift @@ -0,0 +1,29 @@ +// +// ProcessRule.swift +// Burrow +// +// Per-process watchdog rule (PRD §α). Pure decision: given a rule and a +// process's recent metric samples (one per second, oldest→newest), decide +// whether it has stayed over threshold for the full sustain window (fire). The +// sampler + action dispatch (notify / quit / suspend) and Shortcuts hook are +// the seam. +// + +import Foundation + +enum ProcessRule { + enum Metric: String, Equatable { case cpu, memory, diskRead } + enum Action: String, Equatable { case notify, quit, suspend } + struct Rule: Equatable { + let metric: Metric + let threshold: Double + let sustainSeconds: Int + let action: Action + } + + /// Fires when the most recent `sustainSeconds` samples are ALL over threshold. + static func fires(_ rule: Rule, samples: [Double]) -> Bool { + guard rule.sustainSeconds > 0, samples.count >= rule.sustainSeconds else { return false } + return samples.suffix(rule.sustainSeconds).allSatisfy { $0 > rule.threshold } + } +} diff --git a/macos/Sources/ProcessTree.swift b/macos/Sources/ProcessTree.swift new file mode 100644 index 0000000..3508ff8 --- /dev/null +++ b/macos/Sources/ProcessTree.swift @@ -0,0 +1,40 @@ +// +// ProcessTree.swift +// Burrow +// +// Process hierarchy with rolled-up aggregates (PRD §α — Process Inspector). +// Pure — folds a flat list of (pid, ppid, cpu, mem, threads) into a parent→ +// children tree where each node reports the summed CPU/memory/threads of its +// whole subtree. +// + +import Foundation + +enum ProcessTree { + struct Proc { let pid: Int; let ppid: Int; let cpu: Double; let mem: Int64; let threads: Int } + + final class Node { + let proc: Proc + var children: [Node] = [] + init(_ p: Proc) { proc = p } + var totalCPU: Double { proc.cpu + children.reduce(0) { $0 + $1.totalCPU } } + var totalMem: Int64 { proc.mem + children.reduce(0) { $0 + $1.totalMem } } + var totalThreads: Int { proc.threads + children.reduce(0) { $0 + $1.totalThreads } } + } + + /// Roots = processes whose parent isn't in the set (or who are their own + /// parent). Children are attached under their ppid. + static func build(_ procs: [Proc]) -> [Node] { + let nodes = Dictionary(procs.map { ($0.pid, Node($0)) }, uniquingKeysWith: { a, _ in a }) + var roots: [Node] = [] + for p in procs { + guard let n = nodes[p.pid] else { continue } + if p.ppid != p.pid, let parent = nodes[p.ppid] { + parent.children.append(n) + } else { + roots.append(n) + } + } + return roots + } +} diff --git a/macos/Sources/SensitiveRemnantMatcher.swift b/macos/Sources/SensitiveRemnantMatcher.swift new file mode 100644 index 0000000..cf58fd5 --- /dev/null +++ b/macos/Sources/SensitiveRemnantMatcher.swift @@ -0,0 +1,21 @@ +// +// SensitiveRemnantMatcher.swift +// Burrow +// +// Flags credential/keychain-style leftover paths so Clean review can show a +// caution badge instead of a plain "Safe" chip (PRD §Clean). Pure pattern +// match — never hides anything, only flags for deliberate review. +// + +import Foundation + +enum SensitiveRemnantMatcher { + private static let needles = [ + "keychain", "credential", "/.ssh", "id_rsa", "id_ed25519", ".gnupg", + "token", "secret", "password", ".aws/credentials", ".netrc", "cookies", + ] + static func isSensitive(_ path: String) -> Bool { + let p = path.lowercased() + return needles.contains { p.contains($0) } + } +} diff --git a/macos/Sources/UpdateSeenStore.swift b/macos/Sources/UpdateSeenStore.swift new file mode 100644 index 0000000..4ca44ba --- /dev/null +++ b/macos/Sources/UpdateSeenStore.swift @@ -0,0 +1,24 @@ +// +// UpdateSeenStore.swift +// Burrow +// +// "Unseen updates" badge count (PRD §Software): how many currently-available +// third-party updates the user hasn't acknowledged. Pure — the persisted +// "seen" set is supplied by the seam. A key is bundleID@version, so a NEW +// version of an already-seen app re-badges. +// + +import Foundation + +enum UpdateSeenStore { + static func key(bundleID: String, version: String) -> String { "\(bundleID)@\(version)" } + + static func unseenCount(available: [(bundleID: String, version: String)], seen: Set) -> Int { + available.filter { !seen.contains(key(bundleID: $0.bundleID, version: $0.version)) }.count + } + + /// The set to persist once the user has viewed the list. + static func markAllSeen(available: [(bundleID: String, version: String)], seen: Set) -> Set { + seen.union(available.map { key(bundleID: $0.bundleID, version: $0.version) }) + } +} diff --git a/macos/Sources/VenueMatcher.swift b/macos/Sources/VenueMatcher.swift new file mode 100644 index 0000000..3ffc1bd --- /dev/null +++ b/macos/Sources/VenueMatcher.swift @@ -0,0 +1,42 @@ +// +// VenueMatcher.swift +// Burrow +// +// Matches a Wi-Fi network name to a known venue/airline and its captive-portal +// tips (PRD §β — Get Online companion). Pure — the catalog is a bundled seed +// (community-extensible via JSON later); the SSID read is the seam +// (CoreWLAN + Location). +// + +import Foundation + +enum VenueMatcher { + struct Venue: Equatable { let name: String; let tips: [String] } + + /// `keys` are lowercased substrings matched against the SSID. First match wins. + static let catalog: [(keys: [String], venue: Venue)] = [ + (["hilton", "honors"], Venue(name: "Hilton", tips: [ + "Hilton portals often run older software that blocks encrypted DNS — turn custom/secure DNS off.", + "Honors members can sometimes bypass the portal with their account credentials."])), + (["marriott", "bonvoy"], Venue(name: "Marriott", tips: [ + "Marriott portals may require turning Private Relay off to load."])), + (["delta"], Venue(name: "Delta Fly-Fi", tips: [ + "In-flight portals block VPNs and custom DNS — disable both to reach the login page."])), + (["united"], Venue(name: "United Wi-Fi", tips: [ + "Open the free messaging/portal first; VPN and Private Relay will block it."])), + (["alaska"], Venue(name: "Alaska Wi-Fi", tips: [ + "Starlink-backed — disable Private Relay/VPN and the portal loads fast."])), + (["jetblue"], Venue(name: "JetBlue Fly-Fi", tips: [ + "Free Fly-Fi — turn off custom DNS if the welcome page won't load."])), + (["southwest"], Venue(name: "Southwest Wi-Fi", tips: [ + "Open the portal before connecting any VPN."])), + (["spirit"], Venue(name: "Spirit Wi-Fi", tips: [ + "Disable VPN/Private Relay to reach the paid portal."])), + ] + + static func match(ssid: String) -> Venue? { + let s = ssid.lowercased() + for entry in catalog where entry.keys.contains(where: { s.contains($0) }) { return entry.venue } + return nil + } +} diff --git a/macos/Tests/CleanImpactRankerTests.swift b/macos/Tests/CleanImpactRankerTests.swift new file mode 100644 index 0000000..84c1a05 --- /dev/null +++ b/macos/Tests/CleanImpactRankerTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import Burrow + +final class CleanImpactRankerTests: XCTestCase { + func testRankOrder() { + XCTAssertLessThan(CleanImpactRanker.rank(category: "App caches"), CleanImpactRanker.rank(category: "Logs")) + XCTAssertLessThan(CleanImpactRanker.rank(category: "Logs"), CleanImpactRanker.rank(category: "User essentials")) + XCTAssertEqual(CleanImpactRanker.rank(category: "Keychain leftovers"), 4) + XCTAssertEqual(CleanImpactRanker.rank(category: "Browsers"), 0) + } + + func testSortedAscendingImpactStable() { + let items: [(category: String, value: String)] = [ + ("User essentials", "a"), ("Caches", "b"), ("Logs", "c"), ("Caches", "d"), + ] + XCTAssertEqual(CleanImpactRanker.sorted(items), ["b", "d", "c", "a"]) + } +} diff --git a/macos/Tests/ConnectionFailureClassifierTests.swift b/macos/Tests/ConnectionFailureClassifierTests.swift new file mode 100644 index 0000000..91190f3 --- /dev/null +++ b/macos/Tests/ConnectionFailureClassifierTests.swift @@ -0,0 +1,11 @@ +import XCTest +@testable import Burrow + +final class ConnectionFailureClassifierTests: XCTestCase { + func testAllVerdicts() { + XCTAssertEqual(ConnectionFailureClassifier.classify(online: true, portal: false, loginReachable: false), .ok) + XCTAssertEqual(ConnectionFailureClassifier.classify(online: false, portal: true, loginReachable: true), .captivePortal) + XCTAssertEqual(ConnectionFailureClassifier.classify(online: false, portal: true, loginReachable: false), .loginUnreachable) + XCTAssertEqual(ConnectionFailureClassifier.classify(online: false, portal: false, loginReachable: false), .noInternet) + } +} diff --git a/macos/Tests/OptimizeGuardsTests.swift b/macos/Tests/OptimizeGuardsTests.swift new file mode 100644 index 0000000..ecc64f3 --- /dev/null +++ b/macos/Tests/OptimizeGuardsTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import Burrow + +final class OptimizeGuardsTests: XCTestCase { + func testClearWhenNothingActive() { + XCTAssertTrue(OptimizeGuards.warnings(OptimizeGuards.State()).isEmpty) + } + + func testWarnsPerActiveCondition() { + var s = OptimizeGuards.State() + s.vpnActive = true + s.btInput = true + XCTAssertEqual(OptimizeGuards.warnings(s).count, 2) + } +} diff --git a/macos/Tests/ProcessRuleTests.swift b/macos/Tests/ProcessRuleTests.swift new file mode 100644 index 0000000..2bf0d69 --- /dev/null +++ b/macos/Tests/ProcessRuleTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import Burrow + +final class ProcessRuleTests: XCTestCase { + private let rule = ProcessRule.Rule(metric: .cpu, threshold: 80, sustainSeconds: 3, action: .notify) + + func testFiresWhenSustainedOverThreshold() { + XCTAssertTrue(ProcessRule.fires(rule, samples: [50, 90, 95, 100])) // last 3 all > 80 + } + + func testDoesNotFireOnDip() { + XCTAssertFalse(ProcessRule.fires(rule, samples: [90, 70, 95, 100])) // last 3 include 70 + } + + func testDoesNotFireBeforeWindowFills() { + XCTAssertFalse(ProcessRule.fires(rule, samples: [90, 95])) + } +} diff --git a/macos/Tests/ProcessTreeTests.swift b/macos/Tests/ProcessTreeTests.swift new file mode 100644 index 0000000..a9c2024 --- /dev/null +++ b/macos/Tests/ProcessTreeTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import Burrow + +final class ProcessTreeTests: XCTestCase { + func testBuildAndAggregate() { + let procs = [ + ProcessTree.Proc(pid: 1, ppid: 0, cpu: 0, mem: 0, threads: 1), + ProcessTree.Proc(pid: 2, ppid: 1, cpu: 10, mem: 100, threads: 2), + ProcessTree.Proc(pid: 3, ppid: 2, cpu: 5, mem: 50, threads: 1), + ] + let roots = ProcessTree.build(procs) + XCTAssertEqual(roots.count, 1) // pid 1: ppid 0 not present → root + let root = roots[0] + XCTAssertEqual(root.proc.pid, 1) + XCTAssertEqual(root.totalCPU, 15) // 0 + 10 + 5 + XCTAssertEqual(root.totalMem, 150) + XCTAssertEqual(root.totalThreads, 4) + XCTAssertEqual(root.children.count, 1) + XCTAssertEqual(root.children[0].children.count, 1) + } +} diff --git a/macos/Tests/SensitiveRemnantMatcherTests.swift b/macos/Tests/SensitiveRemnantMatcherTests.swift new file mode 100644 index 0000000..706cda3 --- /dev/null +++ b/macos/Tests/SensitiveRemnantMatcherTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import Burrow + +final class SensitiveRemnantMatcherTests: XCTestCase { + func testFlagsCredentials() { + XCTAssertTrue(SensitiveRemnantMatcher.isSensitive("/Users/x/Library/Keychains/login.keychain-db")) + XCTAssertTrue(SensitiveRemnantMatcher.isSensitive("/Users/x/.ssh/id_rsa")) + XCTAssertTrue(SensitiveRemnantMatcher.isSensitive("/Users/x/.aws/credentials")) + } + + func testOrdinaryCacheNotFlagged() { + XCTAssertFalse(SensitiveRemnantMatcher.isSensitive("/Users/x/Library/Caches/com.foo/Cache.db")) + XCTAssertFalse(SensitiveRemnantMatcher.isSensitive("/Applications/Foo.app")) + } +} diff --git a/macos/Tests/UpdateSeenStoreTests.swift b/macos/Tests/UpdateSeenStoreTests.swift new file mode 100644 index 0000000..c50827b --- /dev/null +++ b/macos/Tests/UpdateSeenStoreTests.swift @@ -0,0 +1,17 @@ +import XCTest +@testable import Burrow + +final class UpdateSeenStoreTests: XCTestCase { + func testUnseenAndMarkSeen() { + let avail = [(bundleID: "a", version: "1.0"), (bundleID: "b", version: "2.0")] + XCTAssertEqual(UpdateSeenStore.unseenCount(available: avail, seen: []), 2) + let seen = UpdateSeenStore.markAllSeen(available: avail, seen: []) + XCTAssertEqual(UpdateSeenStore.unseenCount(available: avail, seen: seen), 0) + } + + func testNewVersionRebadges() { + let seen: Set = [UpdateSeenStore.key(bundleID: "a", version: "1.0")] + XCTAssertEqual(UpdateSeenStore.unseenCount(available: [(bundleID: "a", version: "1.1")], seen: seen), 1) + XCTAssertEqual(UpdateSeenStore.unseenCount(available: [(bundleID: "a", version: "1.0")], seen: seen), 0) + } +} diff --git a/macos/Tests/VenueMatcherTests.swift b/macos/Tests/VenueMatcherTests.swift new file mode 100644 index 0000000..3165afe --- /dev/null +++ b/macos/Tests/VenueMatcherTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import Burrow + +final class VenueMatcherTests: XCTestCase { + func testMatchesKnownVenues() { + XCTAssertEqual(VenueMatcher.match(ssid: "Hilton Honors Lobby")?.name, "Hilton") + XCTAssertEqual(VenueMatcher.match(ssid: "Delta Fly-Fi")?.name, "Delta Fly-Fi") + XCTAssertEqual(VenueMatcher.match(ssid: "JetBlue Fly-Fi")?.name, "JetBlue Fly-Fi") + } + + func testUnknownAndTipsPresent() { + XCTAssertNil(VenueMatcher.match(ssid: "Joe's Coffee")) + XCTAssertFalse(VenueMatcher.match(ssid: "Hilton")?.tips.isEmpty ?? true) + } +} From 5416e9725c3ba9d028831a5857db939893b6da14 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:06:52 -0700 Subject: [PATCH 04/33] =?UTF-8?q?feat(parity):=208=20more=20pure=20cores?= =?UTF-8?q?=20=E2=80=94=20BTM=20login=20items,=20pkgutil=20receipts,=20tre?= =?UTF-8?q?emap=20tail,=20hardlink=20sizer,=20binary=20integrity,=20proces?= =?UTF-8?q?s=20origin,=20github=20releases,=20uninstall=20plan=20(+=20test?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers PRD §Startup (LoginItemsReader/BTM), §Uninstall (ReceiptLinker, UninstallPlan data-only/input-method/alias), §Analyze (TreemapTail), §Clean (HardlinkAwareSizer), §Status/§α (BinaryIntegrity, ProcessOrigin), §Software (GitHubReleaseResolver). Fixture-backed (sfltool/pkgutil); all type-checked standalone. --- macos/Sources/BinaryIntegrity.swift | 22 +++++++ macos/Sources/GitHubReleaseResolver.swift | 35 ++++++++++ macos/Sources/HardlinkAwareSizer.swift | 22 +++++++ macos/Sources/LoginItemsReader.swift | 69 ++++++++++++++++++++ macos/Sources/ProcessOrigin.swift | 36 ++++++++++ macos/Sources/ReceiptLinker.swift | 33 ++++++++++ macos/Sources/TreemapTail.swift | 23 +++++++ macos/Sources/UninstallPlan.swift | 31 +++++++++ macos/Tests/BinaryIntegrityTests.swift | 10 +++ macos/Tests/GitHubReleaseResolverTests.swift | 23 +++++++ macos/Tests/HardlinkAwareSizerTests.swift | 13 ++++ macos/Tests/LoginItemsReaderTests.swift | 48 ++++++++++++++ macos/Tests/ProcessOriginTests.swift | 30 +++++++++ macos/Tests/ReceiptLinkerTests.swift | 25 +++++++ macos/Tests/TreemapTailTests.swift | 20 ++++++ macos/Tests/UninstallPlanTests.swift | 23 +++++++ 16 files changed, 463 insertions(+) create mode 100644 macos/Sources/BinaryIntegrity.swift create mode 100644 macos/Sources/GitHubReleaseResolver.swift create mode 100644 macos/Sources/HardlinkAwareSizer.swift create mode 100644 macos/Sources/LoginItemsReader.swift create mode 100644 macos/Sources/ProcessOrigin.swift create mode 100644 macos/Sources/ReceiptLinker.swift create mode 100644 macos/Sources/TreemapTail.swift create mode 100644 macos/Sources/UninstallPlan.swift create mode 100644 macos/Tests/BinaryIntegrityTests.swift create mode 100644 macos/Tests/GitHubReleaseResolverTests.swift create mode 100644 macos/Tests/HardlinkAwareSizerTests.swift create mode 100644 macos/Tests/LoginItemsReaderTests.swift create mode 100644 macos/Tests/ProcessOriginTests.swift create mode 100644 macos/Tests/ReceiptLinkerTests.swift create mode 100644 macos/Tests/TreemapTailTests.swift create mode 100644 macos/Tests/UninstallPlanTests.swift diff --git a/macos/Sources/BinaryIntegrity.swift b/macos/Sources/BinaryIntegrity.swift new file mode 100644 index 0000000..0d7651b --- /dev/null +++ b/macos/Sources/BinaryIntegrity.swift @@ -0,0 +1,22 @@ +// +// BinaryIntegrity.swift +// Burrow +// +// Classifies whether a running process's executable on disk is intact, deleted, +// or replaced since launch (PRD §Status / §α — a security signal). Pure: feed +// the launch inode and the current on-disk inode at the same path; the +// proc_pidpath + stat reads are the seam. +// + +import Foundation + +enum BinaryIntegrity { + enum Verdict: String, Equatable { case intact, deleted, replaced } + + /// `onDiskInode` nil = the path no longer exists (deleted/moved). A + /// different inode at the same path = the binary was replaced underneath it. + static func classify(launchInode: UInt64, onDiskInode: UInt64?) -> Verdict { + guard let now = onDiskInode else { return .deleted } + return now == launchInode ? .intact : .replaced + } +} diff --git a/macos/Sources/GitHubReleaseResolver.swift b/macos/Sources/GitHubReleaseResolver.swift new file mode 100644 index 0000000..bcf830c --- /dev/null +++ b/macos/Sources/GitHubReleaseResolver.swift @@ -0,0 +1,35 @@ +// +// GitHubReleaseResolver.swift +// Burrow +// +// Resolves whether a newer version exists on GitHub Releases for a third-party +// app with no Sparkle/App-Store source (PRD §Software). Pure: parse the +// releases JSON + compare to the installed version (reusing OSUpdateGate); the +// fetch + bundle→repo mapping are the seam. +// + +import Foundation + +enum GitHubReleaseResolver { + /// Latest non-prerelease, non-draft tag from a `/releases` JSON array; + /// strips a leading "v". + static func latestTag(_ json: String) -> String? { + guard let data = json.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return nil } + for r in arr { + if (r["prerelease"] as? Bool) == true || (r["draft"] as? Bool) == true { continue } + if let tag = r["tag_name"] as? String, !tag.isEmpty { + return tag.hasPrefix("v") ? String(tag.dropFirst()) : tag + } + } + return nil + } + + /// Strictly-newer version available, or nil. + static func newerVersion(json: String, installed: String) -> String? { + guard let latest = latestTag(json) else { return nil } + let l = OSUpdateGate.parse(latest), i = OSUpdateGate.parse(installed) + let strictlyNewer = OSUpdateGate.atLeast(l, i) && !OSUpdateGate.atLeast(i, l) + return strictlyNewer ? latest : nil + } +} diff --git a/macos/Sources/HardlinkAwareSizer.swift b/macos/Sources/HardlinkAwareSizer.swift new file mode 100644 index 0000000..6b311c1 --- /dev/null +++ b/macos/Sources/HardlinkAwareSizer.swift @@ -0,0 +1,22 @@ +// +// HardlinkAwareSizer.swift +// Burrow +// +// Computes exclusive on-disk bytes, de-counting hardlinked files that share an +// inode (PRD §Clean) — so "space freed" is honest, not double-counted. Pure: +// feed (inode, nlink, size) entries; the stat() is the seam. +// + +import Foundation + +enum HardlinkAwareSizer { + struct Entry { let inode: UInt64; let nlink: Int; let size: Int64 } + + /// Sum sizes counting each unique inode once. + static func exclusiveBytes(_ entries: [Entry]) -> Int64 { + var seen = Set() + var total: Int64 = 0 + for e in entries where seen.insert(e.inode).inserted { total += e.size } + return total + } +} diff --git a/macos/Sources/LoginItemsReader.swift b/macos/Sources/LoginItemsReader.swift new file mode 100644 index 0000000..3236213 --- /dev/null +++ b/macos/Sources/LoginItemsReader.swift @@ -0,0 +1,69 @@ +// +// LoginItemsReader.swift +// Burrow +// +// Parses `sfltool dumpbtm` into the modern Login/background items macOS manages +// via the Background Task Management database (PRD §Startup) — the ones a loose +// LaunchAgent-plist scan misses. Pure; the sfltool spawn is the seam. +// + +import Foundation + +enum LoginItemsReader { + struct Item: Equatable { + let name: String + let identifier: String + let developer: String + let type: String // "developer", "legacy daemon", "agent", … + let enabled: Bool + } + + static func parse(_ dump: String) -> [Item] { + var items: [Item] = [] + for block in blocks(dump) { + let f = fields(block) + let id = f["Identifier"] ?? "" + let name = f["Name"].flatMap { $0 == "(null)" ? nil : $0 } + guard !id.isEmpty || name != nil else { continue } // skip empty placeholder records + let disp = (f["Disposition"] ?? "").lowercased() + let enabled = !disp.contains("disabled") && disp.contains("enabled") + items.append(Item( + name: name ?? id, + identifier: id, + developer: f["Developer Name"].flatMap { $0 == "(null)" ? nil : $0 } ?? "", + type: f["Type"]?.components(separatedBy: " (").first ?? "", + enabled: enabled)) + } + return items + } + + /// Each record begins with a line that trims to exactly "#:". + private static func blocks(_ s: String) -> [String] { + var out: [String] = [] + var cur: [String] = [] + var inItem = false + for raw in s.components(separatedBy: "\n") { + let t = raw.trimmingCharacters(in: .whitespaces) + if t.range(of: #"^#\d+:$"#, options: .regularExpression) != nil { + if inItem, !cur.isEmpty { out.append(cur.joined(separator: "\n")) } + cur = []; inItem = true + } else if inItem { + cur.append(raw) + } + } + if inItem, !cur.isEmpty { out.append(cur.joined(separator: "\n")) } + return out + } + + /// " Key: Value" → [Key: Value], first occurrence wins. + private static func fields(_ block: String) -> [String: String] { + var out: [String: String] = [:] + for line in block.components(separatedBy: "\n") { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = String(line[.. = ["zsh", "bash", "fish", "sh", "tcsh", "csh", "ksh", "dash"] + + static func classify(pid: Int, table: [Int: Info]) -> Origin { + var cur = table[pid]?.ppid ?? 0 + var hops = 0 + while let info = table[cur], hops < 64 { + let n = info.name.lowercased() + if n.contains("sshd") { return .ssh } + if shells.contains(n) { return .shell(info.name) } + if info.ppid <= 1 || info.ppid == cur { break } + cur = info.ppid + hops += 1 + } + return .login + } +} diff --git a/macos/Sources/ReceiptLinker.swift b/macos/Sources/ReceiptLinker.swift new file mode 100644 index 0000000..18e6033 --- /dev/null +++ b/macos/Sources/ReceiptLinker.swift @@ -0,0 +1,33 @@ +// +// ReceiptLinker.swift +// Burrow +// +// Links macOS installer receipts (pkgutil) to an app for the uninstall set +// (PRD §Uninstall). Pure parsing + matching; the pkgutil spawns are the seam. +// + +import Foundation + +enum ReceiptLinker { + struct Receipt: Equatable { let packageID: String; let version: String; let location: String } + + /// Parse `pkgutil --pkg-info ` key:value output. + static func parseInfo(_ out: String) -> Receipt? { + var f: [String: String] = [:] + for line in out.components(separatedBy: "\n") { + guard let c = line.firstIndex(of: ":") else { continue } + f[String(line[.. [String] { + let parts = bundleID.lowercased().split(separator: ".") + guard parts.count >= 2 else { return [] } + let stem = parts.suffix(2).joined(separator: ".") // e.g. "docker.docker" + return packageIDs.filter { $0.lowercased().contains(stem) } + } +} diff --git a/macos/Sources/TreemapTail.swift b/macos/Sources/TreemapTail.swift new file mode 100644 index 0000000..bd75d0e --- /dev/null +++ b/macos/Sources/TreemapTail.swift @@ -0,0 +1,23 @@ +// +// TreemapTail.swift +// Burrow +// +// Folds the long tail of small treemap cells into a single inert "Other" cell +// (PRD §Analyze), so a folder of thousands of files stays legible and the map +// matches the total it claims. Pure — total bytes are preserved. +// + +import Foundation + +enum TreemapTail { + struct Cell: Equatable { let name: String; let size: Int64 } + + /// Keep the largest `keep` cells; sum the rest into one "Other" cell. + static func fold(_ cells: [Cell], keep: Int, otherName: String = "Other") -> [Cell] { + guard keep >= 0, cells.count > keep else { return cells } + let sorted = cells.sorted { $0.size > $1.size } + let head = Array(sorted.prefix(keep)) + let other = sorted.dropFirst(keep).reduce(Int64(0)) { $0 + $1.size } + return other > 0 ? head + [Cell(name: otherName, size: other)] : head + } +} diff --git a/macos/Sources/UninstallPlan.swift b/macos/Sources/UninstallPlan.swift new file mode 100644 index 0000000..fe3a1af --- /dev/null +++ b/macos/Sources/UninstallPlan.swift @@ -0,0 +1,31 @@ +// +// UninstallPlan.swift +// Burrow +// +// Pure planning helpers for the Uninstall flow (PRD §Uninstall): the +// "Clear Data" subset (keep the app, remove its data), input-method +// classification, and alias-aware search matching. +// + +import Foundation + +enum UninstallPlan { + /// "Clear Data" = every enumerated leftover EXCEPT the .app bundle itself. + static func dataOnly(paths: [String]) -> [String] { + paths.filter { !$0.hasSuffix(".app") } + } + + /// A leftover that is an input method (e.g. WeChat/Doubao keyboards). + static func isInputMethod(_ path: String) -> Bool { + let p = path.lowercased() + return p.contains("/library/input methods/") || p.hasSuffix(".inputmethod") + } + + /// Alias-aware search: match a query against name, bundle id, or any alias. + static func matches(query: String, name: String, bundleID: String, aliases: [String]) -> Bool { + let q = query.lowercased().trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return true } + if name.lowercased().contains(q) || bundleID.lowercased().contains(q) { return true } + return aliases.contains { $0.lowercased().contains(q) } + } +} diff --git a/macos/Tests/BinaryIntegrityTests.swift b/macos/Tests/BinaryIntegrityTests.swift new file mode 100644 index 0000000..ea99111 --- /dev/null +++ b/macos/Tests/BinaryIntegrityTests.swift @@ -0,0 +1,10 @@ +import XCTest +@testable import Burrow + +final class BinaryIntegrityTests: XCTestCase { + func testVerdicts() { + XCTAssertEqual(BinaryIntegrity.classify(launchInode: 42, onDiskInode: 42), .intact) + XCTAssertEqual(BinaryIntegrity.classify(launchInode: 42, onDiskInode: 99), .replaced) + XCTAssertEqual(BinaryIntegrity.classify(launchInode: 42, onDiskInode: nil), .deleted) + } +} diff --git a/macos/Tests/GitHubReleaseResolverTests.swift b/macos/Tests/GitHubReleaseResolverTests.swift new file mode 100644 index 0000000..2eaa850 --- /dev/null +++ b/macos/Tests/GitHubReleaseResolverTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import Burrow + +final class GitHubReleaseResolverTests: XCTestCase { + private let json = """ + [{"tag_name":"v2.1.0","prerelease":false,"draft":false}, + {"tag_name":"v2.0.0","prerelease":false}] + """ + + func testLatestAndNewer() { + XCTAssertEqual(GitHubReleaseResolver.latestTag(json), "2.1.0") + XCTAssertEqual(GitHubReleaseResolver.newerVersion(json: json, installed: "2.0.0"), "2.1.0") + XCTAssertNil(GitHubReleaseResolver.newerVersion(json: json, installed: "2.1.0")) + XCTAssertNil(GitHubReleaseResolver.newerVersion(json: json, installed: "2.2.0")) + } + + func testSkipsPrerelease() { + let pre = """ + [{"tag_name":"v3.0.0-beta","prerelease":true},{"tag_name":"v2.5.0","prerelease":false}] + """ + XCTAssertEqual(GitHubReleaseResolver.latestTag(pre), "2.5.0") + } +} diff --git a/macos/Tests/HardlinkAwareSizerTests.swift b/macos/Tests/HardlinkAwareSizerTests.swift new file mode 100644 index 0000000..608430e --- /dev/null +++ b/macos/Tests/HardlinkAwareSizerTests.swift @@ -0,0 +1,13 @@ +import XCTest +@testable import Burrow + +final class HardlinkAwareSizerTests: XCTestCase { + func testCountsSharedInodeOnce() { + let entries = [ + HardlinkAwareSizer.Entry(inode: 1, nlink: 2, size: 100), + HardlinkAwareSizer.Entry(inode: 1, nlink: 2, size: 100), // hardlink, same inode + HardlinkAwareSizer.Entry(inode: 2, nlink: 1, size: 50), + ] + XCTAssertEqual(HardlinkAwareSizer.exclusiveBytes(entries), 150) // 100 (once) + 50 + } +} diff --git a/macos/Tests/LoginItemsReaderTests.swift b/macos/Tests/LoginItemsReaderTests.swift new file mode 100644 index 0000000..3820ec0 --- /dev/null +++ b/macos/Tests/LoginItemsReaderTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import Burrow + +final class LoginItemsReaderTests: XCTestCase { + // Trimmed real `sfltool dumpbtm` output. + private let sample = """ + #1: + UUID: 903AE041-3324-4304-B0DF-4BAB525E0E31 + Name: (null) + Developer Name: (null) + Type: developer (0x20) + Disposition: [disabled, allowed, not notified] (0x2) + Identifier: Unknown Developer + + #2: + UUID: 5A133E95-92FB-42BA-859C-2A0675FC20AF + Name: studio-route-guard.sh + Developer Name: (null) + Type: legacy daemon (0x10010) + Disposition: [enabled, allowed, notified] (0xb) + Identifier: 16.com.henry.studio-route-guard + Executable Path: /usr/local/bin/studio-route-guard.sh + + #3: + UUID: F499CCD9-B4EF-4401-BF89-FC10AE47C520 + Name: Docker + Developer Name: Docker + Type: developer (0x20) + Disposition: [enabled, allowed, notified] (0xb) + Identifier: com.docker.docker + """ + + func testParsesTypeAndEnabled() { + let items = LoginItemsReader.parse(sample) + let srg = items.first { $0.identifier == "16.com.henry.studio-route-guard" } + XCTAssertEqual(srg?.name, "studio-route-guard.sh") + XCTAssertEqual(srg?.type, "legacy daemon") + XCTAssertEqual(srg?.enabled, true) + let docker = items.first { $0.name == "Docker" } + XCTAssertEqual(docker?.developer, "Docker") + XCTAssertEqual(docker?.enabled, true) + } + + func testDisabledRecord() { + let unknown = LoginItemsReader.parse(sample).first { $0.identifier == "Unknown Developer" } + XCTAssertEqual(unknown?.enabled, false) + } +} diff --git a/macos/Tests/ProcessOriginTests.swift b/macos/Tests/ProcessOriginTests.swift new file mode 100644 index 0000000..6a299c7 --- /dev/null +++ b/macos/Tests/ProcessOriginTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import Burrow + +final class ProcessOriginTests: XCTestCase { + func testShellAncestor() { + let t: [Int: ProcessOrigin.Info] = [ + 500: .init(name: "node", ppid: 400), + 400: .init(name: "zsh", ppid: 300), + 300: .init(name: "login", ppid: 1), + 1: .init(name: "launchd", ppid: 0), + ] + XCTAssertEqual(ProcessOrigin.classify(pid: 500, table: t), .shell("zsh")) + } + + func testSSHAncestor() { + let t: [Int: ProcessOrigin.Info] = [ + 700: .init(name: "scp", ppid: 650), + 650: .init(name: "sshd", ppid: 1), + ] + XCTAssertEqual(ProcessOrigin.classify(pid: 700, table: t), .ssh) + } + + func testLoginDefault() { + let t: [Int: ProcessOrigin.Info] = [ + 800: .init(name: "Safari", ppid: 1), + 1: .init(name: "launchd", ppid: 0), + ] + XCTAssertEqual(ProcessOrigin.classify(pid: 800, table: t), .login) + } +} diff --git a/macos/Tests/ReceiptLinkerTests.swift b/macos/Tests/ReceiptLinkerTests.swift new file mode 100644 index 0000000..6834426 --- /dev/null +++ b/macos/Tests/ReceiptLinkerTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import Burrow + +final class ReceiptLinkerTests: XCTestCase { + func testParseInfo() { + let out = """ + package-id: com.apple.pkg.XProtectPlistConfigData_10_15.16U4423 + version: 5338.1.1775522733 + volume: / + location: / + install-time: 1775838074 + """ + let r = ReceiptLinker.parseInfo(out) + XCTAssertEqual(r?.packageID, "com.apple.pkg.XProtectPlistConfigData_10_15.16U4423") + XCTAssertEqual(r?.version, "5338.1.1775522733") + XCTAssertEqual(r?.location, "/") + XCTAssertNil(ReceiptLinker.parseInfo("no package id here")) + } + + func testMatching() { + let ids = ["com.docker.docker", "com.apple.pkg.X", "io.foo.bar"] + XCTAssertEqual(ReceiptLinker.matching(bundleID: "com.docker.docker", packageIDs: ids), ["com.docker.docker"]) + XCTAssertTrue(ReceiptLinker.matching(bundleID: "single", packageIDs: ids).isEmpty) + } +} diff --git a/macos/Tests/TreemapTailTests.swift b/macos/Tests/TreemapTailTests.swift new file mode 100644 index 0000000..bed2599 --- /dev/null +++ b/macos/Tests/TreemapTailTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import Burrow + +final class TreemapTailTests: XCTestCase { + private let cells = [ + TreemapTail.Cell(name: "a", size: 100), TreemapTail.Cell(name: "b", size: 50), + TreemapTail.Cell(name: "c", size: 10), TreemapTail.Cell(name: "d", size: 5), + ] + + func testFoldsTailAndPreservesTotal() { + let folded = TreemapTail.fold(cells, keep: 2) + XCTAssertEqual(folded.map(\.name), ["a", "b", "Other"]) + XCTAssertEqual(folded.last?.size, 15) // 10 + 5 + XCTAssertEqual(folded.reduce(Int64(0)) { $0 + $1.size }, 165) // total preserved + } + + func testNothingToFold() { + XCTAssertEqual(TreemapTail.fold(cells, keep: 10).count, 4) + } +} diff --git a/macos/Tests/UninstallPlanTests.swift b/macos/Tests/UninstallPlanTests.swift new file mode 100644 index 0000000..cd7104a --- /dev/null +++ b/macos/Tests/UninstallPlanTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import Burrow + +final class UninstallPlanTests: XCTestCase { + func testDataOnlyKeepsApp() { + let paths = ["/Applications/Foo.app", + "/Users/x/Library/Caches/com.foo", + "/Users/x/Library/Preferences/com.foo.plist"] + XCTAssertEqual(UninstallPlan.dataOnly(paths: paths), [paths[1], paths[2]]) + } + + func testInputMethod() { + XCTAssertTrue(UninstallPlan.isInputMethod("/Library/Input Methods/WeChat.app")) + XCTAssertFalse(UninstallPlan.isInputMethod("/Applications/Foo.app")) + } + + func testAliasMatch() { + XCTAssertTrue(UninstallPlan.matches(query: "studio", name: "Visual Studio Code", bundleID: "com.microsoft.VSCode", aliases: [])) + XCTAssertTrue(UninstallPlan.matches(query: "vscode", name: "Visual Studio Code", bundleID: "com.microsoft.VSCode", aliases: ["vscode"])) + XCTAssertFalse(UninstallPlan.matches(query: "xyz", name: "Foo", bundleID: "com.foo", aliases: [])) + XCTAssertTrue(UninstallPlan.matches(query: " ", name: "Foo", bundleID: "x", aliases: [])) + } +} From caaa194abbe57b528668cd141e3899f34c403339 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:10:02 -0700 Subject: [PATCH 05/33] feat(parity): process export/filter, speed-test, nearby-networks, electron resolver (+ tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the pure decision-module layer for PRD §α (export, typed predicate filter) and §β (speed-test jitter/loss, nearby-networks read-out) + §Software (Electron version resolver). All type-checked standalone. --- macos/Sources/ElectronVersionResolver.swift | 27 ++++++++++ macos/Sources/NearbyNetworks.swift | 26 ++++++++++ macos/Sources/ProcessExport.swift | 33 ++++++++++++ macos/Sources/ProcessFilter.swift | 51 +++++++++++++++++++ macos/Sources/SpeedTest.swift | 34 +++++++++++++ .../Tests/ElectronVersionResolverTests.swift | 15 ++++++ macos/Tests/NearbyNetworksTests.swift | 23 +++++++++ macos/Tests/ProcessExportTests.swift | 19 +++++++ macos/Tests/ProcessFilterTests.swift | 24 +++++++++ macos/Tests/SpeedTestTests.swift | 17 +++++++ 10 files changed, 269 insertions(+) create mode 100644 macos/Sources/ElectronVersionResolver.swift create mode 100644 macos/Sources/NearbyNetworks.swift create mode 100644 macos/Sources/ProcessExport.swift create mode 100644 macos/Sources/ProcessFilter.swift create mode 100644 macos/Sources/SpeedTest.swift create mode 100644 macos/Tests/ElectronVersionResolverTests.swift create mode 100644 macos/Tests/NearbyNetworksTests.swift create mode 100644 macos/Tests/ProcessExportTests.swift create mode 100644 macos/Tests/ProcessFilterTests.swift create mode 100644 macos/Tests/SpeedTestTests.swift diff --git a/macos/Sources/ElectronVersionResolver.swift b/macos/Sources/ElectronVersionResolver.swift new file mode 100644 index 0000000..e8ea583 --- /dev/null +++ b/macos/Sources/ElectronVersionResolver.swift @@ -0,0 +1,27 @@ +// +// ElectronVersionResolver.swift +// Burrow +// +// Resolves an Electron app's latest version from its Squirrel.Mac update feed +// (PRD §Software) — so Electron rows show a real available version, not just a +// badge. Pure parse over the fetched payload (reusing OSUpdateGate to compare); +// the fetch is the seam. +// + +import Foundation + +enum ElectronVersionResolver { + /// Squirrel.Mac feeds return JSON carrying "version" (or "name"); strip "v". + static func version(fromFeed json: String) -> String? { + guard let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + guard let raw = (obj["version"] as? String) ?? (obj["name"] as? String), !raw.isEmpty else { return nil } + return raw.hasPrefix("v") ? String(raw.dropFirst()) : raw + } + + static func newerVersion(feed: String, installed: String) -> String? { + guard let latest = version(fromFeed: feed) else { return nil } + let l = OSUpdateGate.parse(latest), i = OSUpdateGate.parse(installed) + return (OSUpdateGate.atLeast(l, i) && !OSUpdateGate.atLeast(i, l)) ? latest : nil + } +} diff --git a/macos/Sources/NearbyNetworks.swift b/macos/Sources/NearbyNetworks.swift new file mode 100644 index 0000000..2855fff --- /dev/null +++ b/macos/Sources/NearbyNetworks.swift @@ -0,0 +1,26 @@ +// +// NearbyNetworks.swift +// Burrow +// +// Read-out helpers for the nearby-networks scan (PRD §β — Home mode). Pure — +// the CoreWLAN scan (needs Location) is the seam; this sorts by signal and +// flags channel congestion. +// + +import Foundation + +enum NearbyNetworks { + struct Net: Equatable { let ssid: String; let rssi: Int; let channel: Int; let security: String } + + /// Strongest first (rssi is negative dBm; closer to 0 = stronger). + static func byStrength(_ nets: [Net]) -> [Net] { + nets.sorted { $0.rssi > $1.rssi } + } + + /// Channels carrying more than `threshold` networks (congested). + static func congestedChannels(_ nets: [Net], threshold: Int = 2) -> Set { + var count: [Int: Int] = [:] + for n in nets { count[n.channel, default: 0] += 1 } + return Set(count.filter { $0.value > threshold }.keys) + } +} diff --git a/macos/Sources/ProcessExport.swift b/macos/Sources/ProcessExport.swift new file mode 100644 index 0000000..362005e --- /dev/null +++ b/macos/Sources/ProcessExport.swift @@ -0,0 +1,33 @@ +// +// ProcessExport.swift +// Burrow +// +// Serializes a visible process list to CSV or JSON for export (PRD §α). Pure. +// + +import Foundation + +enum ProcessExport { + struct Row { let pid: Int; let name: String; let cpu: Double; let memBytes: Int64; let threads: Int } + + static func csv(_ rows: [Row]) -> String { + var out = "pid,name,cpu,memoryBytes,threads\n" + for r in rows { + out += "\(r.pid),\(escape(r.name)),\(r.cpu),\(r.memBytes),\(r.threads)\n" + } + return out + } + + static func json(_ rows: [Row]) -> String { + let arr: [[String: Any]] = rows.map { + ["pid": $0.pid, "name": $0.name, "cpu": $0.cpu, "memoryBytes": $0.memBytes, "threads": $0.threads] + } + guard let data = try? JSONSerialization.data(withJSONObject: arr, options: [.sortedKeys]) else { return "[]" } + return String(decoding: data, as: UTF8.self) + } + + private static func escape(_ s: String) -> String { + (s.contains(",") || s.contains("\"") || s.contains("\n")) + ? "\"\(s.replacingOccurrences(of: "\"", with: "\"\""))\"" : s + } +} diff --git a/macos/Sources/ProcessFilter.swift b/macos/Sources/ProcessFilter.swift new file mode 100644 index 0000000..64dae20 --- /dev/null +++ b/macos/Sources/ProcessFilter.swift @@ -0,0 +1,51 @@ +// +// ProcessFilter.swift +// Burrow +// +// Typed predicate filter over a process record (PRD §α). Chosen over embedding +// a JS runtime so the same predicates work from MCP/agents and ship no JS +// engine (Cross-cutting decision). Pure. +// + +import Foundation + +enum ProcessFilter { + enum Field: String { case cpu, memory, threads, name, pid } + enum Op: String { case gt = ">", lt = "<", ge = ">=", le = "<=", eq = "==", contains = "~" } + struct Predicate { let field: Field; let op: Op; let value: String } + struct Record { let pid: Int; let name: String; let cpu: Double; let memBytes: Int64; let threads: Int } + + private static func numeric(_ r: Record, _ f: Field) -> Double? { + switch f { + case .cpu: return r.cpu + case .memory: return Double(r.memBytes) + case .threads: return Double(r.threads) + case .pid: return Double(r.pid) + case .name: return nil + } + } + + static func matches(_ r: Record, _ p: Predicate) -> Bool { + if p.field == .name { + let v = p.value.lowercased(), n = r.name.lowercased() + switch p.op { + case .eq: return n == v + case .contains: return n.contains(v) + default: return false + } + } + guard let lhs = numeric(r, p.field), let rhs = Double(p.value) else { return false } + switch p.op { + case .gt: return lhs > rhs + case .lt: return lhs < rhs + case .ge: return lhs >= rhs + case .le: return lhs <= rhs + case .eq: return lhs == rhs + case .contains: return false + } + } + + static func apply(_ records: [Record], _ p: Predicate) -> [Record] { + records.filter { matches($0, p) } + } +} diff --git a/macos/Sources/SpeedTest.swift b/macos/Sources/SpeedTest.swift new file mode 100644 index 0000000..1c4b66b --- /dev/null +++ b/macos/Sources/SpeedTest.swift @@ -0,0 +1,34 @@ +// +// SpeedTest.swift +// Burrow +// +// Aggregates a speed test's per-sample measurements into a result (PRD §β). +// Pure — the multi-stream transfer + latency pings are the seam. Multi-stream +// byte samples are summed by the caller (single-stream undercounts badly). +// + +import Foundation + +enum SpeedTest { + struct Result: Equatable { let mbps: Double; let jitterMs: Double; let lossPercent: Double } + + /// `byteSamples` = bytes transferred per 1s window. `latenciesMs` = ping RTTs + /// (nil entry = a lost packet). + static func aggregate(byteSamples: [Int64], latenciesMs: [Double?]) -> Result { + let mbps = byteSamples.isEmpty ? 0 + : (Double(byteSamples.reduce(0, +)) / Double(byteSamples.count)) * 8 / 1_000_000 + let got = latenciesMs.compactMap { $0 } + let jitter = meanAbsDiff(got) + let loss = latenciesMs.isEmpty ? 0 + : Double(latenciesMs.count - got.count) / Double(latenciesMs.count) * 100 + return Result(mbps: round2(mbps), jitterMs: round2(jitter), lossPercent: round2(loss)) + } + + private static func meanAbsDiff(_ xs: [Double]) -> Double { + guard xs.count >= 2 else { return 0 } + var sum = 0.0 + for i in 1.. Double { (x * 100).rounded() / 100 } +} diff --git a/macos/Tests/ElectronVersionResolverTests.swift b/macos/Tests/ElectronVersionResolverTests.swift new file mode 100644 index 0000000..2dcaafa --- /dev/null +++ b/macos/Tests/ElectronVersionResolverTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import Burrow + +final class ElectronVersionResolverTests: XCTestCase { + func testParsesVersionOrName() { + XCTAssertEqual(ElectronVersionResolver.version(fromFeed: #"{"url":"x","version":"1.2.3"}"#), "1.2.3") + XCTAssertEqual(ElectronVersionResolver.version(fromFeed: #"{"name":"v2.0.0"}"#), "2.0.0") + XCTAssertNil(ElectronVersionResolver.version(fromFeed: "not json")) + } + + func testNewer() { + XCTAssertEqual(ElectronVersionResolver.newerVersion(feed: #"{"version":"1.2.3"}"#, installed: "1.2.0"), "1.2.3") + XCTAssertNil(ElectronVersionResolver.newerVersion(feed: #"{"version":"1.2.3"}"#, installed: "1.2.3")) + } +} diff --git a/macos/Tests/NearbyNetworksTests.swift b/macos/Tests/NearbyNetworksTests.swift new file mode 100644 index 0000000..981bf36 --- /dev/null +++ b/macos/Tests/NearbyNetworksTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import Burrow + +final class NearbyNetworksTests: XCTestCase { + func testSortByStrength() { + let nets = [ + NearbyNetworks.Net(ssid: "a", rssi: -80, channel: 1, security: "WPA2"), + NearbyNetworks.Net(ssid: "b", rssi: -40, channel: 6, security: "WPA3"), + NearbyNetworks.Net(ssid: "c", rssi: -60, channel: 6, security: "WPA2"), + ] + XCTAssertEqual(NearbyNetworks.byStrength(nets).map(\.ssid), ["b", "c", "a"]) + } + + func testCongestedChannels() { + let nets = [ + NearbyNetworks.Net(ssid: "a", rssi: -40, channel: 6, security: ""), + NearbyNetworks.Net(ssid: "b", rssi: -50, channel: 6, security: ""), + NearbyNetworks.Net(ssid: "c", rssi: -60, channel: 6, security: ""), + NearbyNetworks.Net(ssid: "d", rssi: -40, channel: 1, security: ""), + ] + XCTAssertEqual(NearbyNetworks.congestedChannels(nets, threshold: 2), [6]) + } +} diff --git a/macos/Tests/ProcessExportTests.swift b/macos/Tests/ProcessExportTests.swift new file mode 100644 index 0000000..851a8c9 --- /dev/null +++ b/macos/Tests/ProcessExportTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import Burrow + +final class ProcessExportTests: XCTestCase { + func testCSVEscapes() { + let rows = [ProcessExport.Row(pid: 1, name: "launchd", cpu: 0.5, memBytes: 1024, threads: 2), + ProcessExport.Row(pid: 2, name: "a,b", cpu: 1, memBytes: 2048, threads: 3)] + let csv = ProcessExport.csv(rows) + XCTAssertTrue(csv.hasPrefix("pid,name,cpu,memoryBytes,threads\n")) + XCTAssertTrue(csv.contains("1,launchd,0.5,1024,2")) + XCTAssertTrue(csv.contains("\"a,b\"")) + } + + func testJSON() { + let json = ProcessExport.json([ProcessExport.Row(pid: 1, name: "x", cpu: 0, memBytes: 10, threads: 1)]) + XCTAssertTrue(json.contains("\"pid\":1")) + XCTAssertTrue(json.contains("\"memoryBytes\":10")) + } +} diff --git a/macos/Tests/ProcessFilterTests.swift b/macos/Tests/ProcessFilterTests.swift new file mode 100644 index 0000000..a7666dd --- /dev/null +++ b/macos/Tests/ProcessFilterTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import Burrow + +final class ProcessFilterTests: XCTestCase { + private let records = [ + ProcessFilter.Record(pid: 1, name: "node", cpu: 90, memBytes: 1 << 30, threads: 10), + ProcessFilter.Record(pid: 2, name: "Finder", cpu: 1, memBytes: 1 << 20, threads: 3), + ] + + func testNumericGreater() { + let p = ProcessFilter.Predicate(field: .cpu, op: .gt, value: "50") + XCTAssertEqual(ProcessFilter.apply(records, p).map(\.pid), [1]) + } + + func testNameContains() { + let p = ProcessFilter.Predicate(field: .name, op: .contains, value: "find") + XCTAssertEqual(ProcessFilter.apply(records, p).map(\.pid), [2]) + } + + func testMemoryAtLeast() { + let p = ProcessFilter.Predicate(field: .memory, op: .ge, value: "\(1 << 30)") + XCTAssertEqual(ProcessFilter.apply(records, p).map(\.pid), [1]) + } +} diff --git a/macos/Tests/SpeedTestTests.swift b/macos/Tests/SpeedTestTests.swift new file mode 100644 index 0000000..e8ee65c --- /dev/null +++ b/macos/Tests/SpeedTestTests.swift @@ -0,0 +1,17 @@ +import XCTest +@testable import Burrow + +final class SpeedTestTests: XCTestCase { + func testThroughputAndJitter() { + // 1,250,000 bytes/s × 8 / 1e6 = 10 Mbps + let r = SpeedTest.aggregate(byteSamples: [1_250_000, 1_250_000], latenciesMs: [10, 12, 11]) + XCTAssertEqual(r.mbps, 10, accuracy: 0.01) + XCTAssertEqual(r.lossPercent, 0) + XCTAssertGreaterThan(r.jitterMs, 0) + } + + func testPacketLoss() { + let r = SpeedTest.aggregate(byteSamples: [], latenciesMs: [10, nil, 12, nil]) + XCTAssertEqual(r.lossPercent, 50) // 2 of 4 lost + } +} From 43396e9e5442fe09ea51f99c43fcf25eddae22eb Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:23:43 -0700 Subject: [PATCH 06/33] feat(doctor): security posture (SIP/Gatekeeper/FileVault/firewall) + high-CPU check + Copy diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First GUI integration of the parity modules: Doctor.report gains optional Security + CPU-load checks (omitted when data absent → MCP seam + existing count==7 test unaffected). DoctorView runs the posture commands off-main via a self-contained Process runner, feeds SecurityPosture, and adds a Copy button. +tests. --- macos/Sources/Doctor.swift | 44 ++++++++++++++++++++++++++++-- macos/Sources/DoctorView.swift | 49 +++++++++++++++++++++++++++++++--- macos/Tests/DoctorTests.swift | 27 +++++++++++++++++++ 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Doctor.swift b/macos/Sources/Doctor.swift index 2f60c56..4560673 100644 --- a/macos/Sources/Doctor.swift +++ b/macos/Sources/Doctor.swift @@ -33,12 +33,52 @@ enum Doctor { var lastBackupDaysAgo: Int? = nil /// SMART verdict: true = verified, false = failing, nil = unreadable. var smartVerified: Bool? = nil + // Security posture (PRD §Doctor). `.unknown` on every facet = no data, + // so the Security check is omitted (keeps the MCP seam + old tests valid). + var sip: SecurityPosture.State = .unknown + var gatekeeper: SecurityPosture.State = .unknown + var fileVault: SecurityPosture.State = .unknown + var firewall: SecurityPosture.State = .unknown + /// Battery max-capacity %; nil = desktop / no battery → omitted. + var batteryHealthPct: Int? = nil + /// System-wide CPU load %; nil = unknown → omitted. + var cpuLoadPercent: Double? = nil } /// One `Check` per facet, in a stable order. Each verdict is independent; - /// callers can sort by `level` to surface failures first. + /// callers can sort by `level` to surface failures first. The security / + /// battery / CPU checks are appended only when their facts are present. static func report(_ i: Input) -> [Check] { - [engine(i), permissions(i), memory(i), disk(i), diskHealth(i), backup(i), errors(i)] + var checks = [engine(i), permissions(i), memory(i), disk(i), diskHealth(i), backup(i)] + if let s = security(i) { checks.append(s) } + if let b = battery(i) { checks.append(b) } + if let c = highCPU(i) { checks.append(c) } + checks.append(errors(i)) + return checks + } + + private static func security(_ i: Input) -> Check? { + let facets: [(String, SecurityPosture.State)] = [ + ("SIP", i.sip), ("Gatekeeper", i.gatekeeper), ("FileVault", i.fileVault), ("Firewall", i.firewall)] + guard facets.contains(where: { $0.1 != .unknown }) else { return nil } // no data → omit + let off = facets.filter { $0.1 == .off }.map(\.0) + return off.isEmpty + ? Check(name: "Security", level: .ok, detail: "SIP, Gatekeeper, FileVault, firewall all on") + : Check(name: "Security", level: .warn, detail: "off: " + off.joined(separator: ", ")) + } + + private static func battery(_ i: Input) -> Check? { + guard let h = i.batteryHealthPct else { return nil } + return h < 80 + ? Check(name: "Battery health", level: .warn, detail: "\(h)% of original capacity") + : Check(name: "Battery health", level: .ok, detail: "\(h)% capacity") + } + + private static func highCPU(_ i: Input) -> Check? { + guard let c = i.cpuLoadPercent else { return nil } + return c >= 90 + ? Check(name: "CPU load", level: .warn, detail: "high — \(Int(c.rounded()))%") + : Check(name: "CPU load", level: .ok, detail: "\(Int(c.rounded()))%") } private static func engine(_ i: Input) -> Check { diff --git a/macos/Sources/DoctorView.swift b/macos/Sources/DoctorView.swift index 5ce6309..d483bf6 100644 --- a/macos/Sources/DoctorView.swift +++ b/macos/Sources/DoctorView.swift @@ -12,6 +12,7 @@ // import SwiftUI +import AppKit struct DoctorView: View { let db: DB @@ -20,8 +21,16 @@ struct DoctorView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - Text(NSLocalizedString("Diagnostics", comment: "")) - .font(Brand.serif(22, .medium)).foregroundStyle(Brand.textPrimary) + HStack { + Text(NSLocalizedString("Diagnostics", comment: "")) + .font(Brand.serif(22, .medium)).foregroundStyle(Brand.textPrimary) + Spacer() + Button { copyDiagnostics() } label: { + Label(NSLocalizedString("Copy", comment: ""), systemImage: "doc.on.doc") + .font(Brand.mono(11)).foregroundStyle(Brand.textSecondary) + } + .buttonStyle(.plain).disabled(checks.isEmpty) + } if checks.isEmpty { HStack(spacing: 8) { @@ -113,8 +122,14 @@ struct DoctorView: View { // takes seconds. Running them inline here (`.task` is MainActor-isolated) // froze the main thread long enough to trip Sentry's ≥2000ms App Hang // detector. Probe off the main thread, then publish on the main actor. + let cpuLoad = latest?.cpu.usage let probes = await Task.detached(priority: .utility) { - (backup: BackupStatus.lastBackupDaysAgo(), smart: DiskHealth.smartVerified()) + (backup: BackupStatus.lastBackupDaysAgo(), + smart: DiskHealth.smartVerified(), + sip: SecurityPosture.sip(DoctorView.run("/usr/bin/csrutil", ["status"])), + gatekeeper: SecurityPosture.gatekeeper(DoctorView.run("/usr/sbin/spctl", ["--status"])), + fileVault: SecurityPosture.fileVault(DoctorView.run("/usr/bin/fdesetup", ["status"])), + firewall: SecurityPosture.firewall(DoctorView.run("/usr/libexec/ApplicationFirewall/socketfilterfw", ["--getglobalstate"]))) }.value checks = Doctor.report(.init( fullDiskAccess: fullDiskAccess, @@ -122,6 +137,32 @@ struct DoctorView: View { diskFreeBytes: free, diskTotalBytes: total, recentErrorCount: recentErrorCount, lastBackupDaysAgo: probes.backup, - smartVerified: probes.smart)) + smartVerified: probes.smart, + sip: probes.sip, gatekeeper: probes.gatekeeper, + fileVault: probes.fileVault, firewall: probes.firewall, + cpuLoadPercent: cpuLoad)) + } + + /// Capture a short system command's stdout (off-main; used for the security + /// posture probes). Self-contained so it doesn't depend on the engine seam. + private static func run(_ path: String, _ args: [String]) -> String { + guard FileManager.default.isExecutableFile(atPath: path) else { return "" } + let p = Process() + p.executableURL = URL(fileURLWithPath: path) + p.arguments = args + let pipe = Pipe() + p.standardOutput = pipe + p.standardError = pipe + do { try p.run() } catch { return "" } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + p.waitUntilExit() + return String(decoding: data, as: UTF8.self) + } + + private func copyDiagnostics() { + let text = checks.map { "[\(label($0.level).uppercased())] \($0.name): \($0.detail)" } + .joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) } } diff --git a/macos/Tests/DoctorTests.swift b/macos/Tests/DoctorTests.swift index c603ccc..287c0d1 100644 --- a/macos/Tests/DoctorTests.swift +++ b/macos/Tests/DoctorTests.swift @@ -79,4 +79,31 @@ final class DoctorTests: XCTestCase { let worst = Doctor.report(i).max { $0.level.rawValue < $1.level.rawValue } XCTAssertEqual(worst?.level, .fail, "Level.rawValue orders ok Date: Thu, 25 Jun 2026 12:26:45 -0700 Subject: [PATCH 07/33] feat(startup): don't flag a LaunchAgent on an unplugged drive as broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires RemovableVolumeGuard into the dangling-executable check — a missing exe under /Volumes/ whose volume isn't mounted is classed on-unplugged-drive, not broken (PRD §Startup). --- macos/Sources/StartupInventory.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/StartupInventory.swift b/macos/Sources/StartupInventory.swift index 7ce242a..c8767c6 100644 --- a/macos/Sources/StartupInventory.swift +++ b/macos/Sources/StartupInventory.swift @@ -137,7 +137,13 @@ enum StartupInventory { ?? (dict["ProgramArguments"] as? [String])?.first var problem: StartupItem.Problem? if let exe = executable, !FileManager.default.fileExists(atPath: exe) { - problem = .danglingExecutable + // A target on an external drive that's currently UNPLUGGED isn't + // broken — don't flag it (PRD §Startup, RemovableVolumeGuard). + let mounted = Set((try? FileManager.default.contentsOfDirectory(atPath: "/Volumes"))? + .map { "/Volumes/\($0)" } ?? []) + if RemovableVolumeGuard.classify(missingPath: exe, mountedVolumes: mounted) == .broken { + problem = .danglingExecutable + } } return StartupItem(label: label, kind: kind, scope: scope, plistPath: url.path, executable: executable, problem: problem) From f982d65eede8d8807a42be4eadd8146fad933823 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:28:05 -0700 Subject: [PATCH 08/33] feat(clean): sort review by deletion impact + flag sensitive (keychain/credential) paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CleanImpactRanker orders categories safest/regenerable-first; SensitiveRemnantMatcher adds a caution badge on credential/keychain leftovers (PRD §Clean). --- macos/Sources/CleanReviewView.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/macos/Sources/CleanReviewView.swift b/macos/Sources/CleanReviewView.swift index 48975da..ce30b9f 100644 --- a/macos/Sources/CleanReviewView.swift +++ b/macos/Sources/CleanReviewView.swift @@ -45,7 +45,8 @@ struct CleanReviewView: View { Rectangle().fill(Brand.hairline).frame(height: 1) ScrollView { LazyVStack(spacing: 10) { - ForEach(list.categories) { category in + // Safest / most-regenerable categories first (PRD §Clean). + ForEach(CleanImpactRanker.sorted(list.categories.map { (category: $0.name, value: $0) })) { category in categoryCard(category) } } @@ -215,6 +216,13 @@ struct CleanReviewView: View { .lineLimit(1).truncationMode(.middle) } Spacer() + if SensitiveRemnantMatcher.isSensitive(item.path) { + Text(NSLocalizedString("sensitive", comment: "")) + .font(Brand.mono(9, .medium)).foregroundStyle(Brand.amber) + .padding(.horizontal, 5).padding(.vertical, 1.5) + .background(Capsule().fill(Brand.amber.opacity(0.16))) + .help(NSLocalizedString("Looks like a credential/keychain path — review before removing.", comment: "")) + } badge(for: lockReason) if let count = item.itemCount { Text(String(format: NSLocalizedString("%d items", comment: ""), count)) From c839443fead59c4b07ac32103606e930c82c7b44 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:30:01 -0700 Subject: [PATCH 09/33] feat(updates): hide App Store updates needing a newer macOS (OSUpdateGate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse minimumOsVersion from the iTunes lookup into MASResult/AppUpdateItem and gate updateAvailable with OSUpdateGate.isInstallable vs the running OS — no more false 'update' prompts the Mac can't install (PRD §Software). --- macos/Sources/UpdateSources.swift | 4 +++- macos/Sources/UpdatesView.swift | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/macos/Sources/UpdateSources.swift b/macos/Sources/UpdateSources.swift index 15a631c..9e72ee2 100644 --- a/macos/Sources/UpdateSources.swift +++ b/macos/Sources/UpdateSources.swift @@ -89,6 +89,7 @@ enum UpdateSources { struct MASResult { let version: String let pageURL: URL? + let minimumOsVersion: String? } static func parseITunesLookup(_ data: Data) -> MASResult? { @@ -97,7 +98,8 @@ enum UpdateSources { let first = results.first, let version = first["version"] as? String else { return nil } let page = (first["trackViewUrl"] as? String).flatMap(URL.init(string:)) - return MASResult(version: version, pageURL: page) + return MASResult(version: version, pageURL: page, + minimumOsVersion: first["minimumOsVersion"] as? String) } static func itunesLookupURL(bundleID: String) -> URL { diff --git a/macos/Sources/UpdatesView.swift b/macos/Sources/UpdatesView.swift index 0c911c4..cdee3f2 100644 --- a/macos/Sources/UpdatesView.swift +++ b/macos/Sources/UpdatesView.swift @@ -38,10 +38,16 @@ struct AppUpdateItem: Identifiable { var latestVersion: String? var pageURL: URL? var lastUsed: Date? + /// App Store: the macOS this update requires (from the iTunes lookup). + var minimumOS: String? var updateAvailable: Bool { guard let latest = latestVersion else { return false } - return UpdateCheck.isNewer(latest, than: installedVersion) + guard UpdateCheck.isNewer(latest, than: installedVersion) else { return false } + // Hide an App Store update that needs a newer macOS than we run (PRD §Software). + let v = Foundation.ProcessInfo.processInfo.operatingSystemVersion + let running = "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + return OSUpdateGate.isInstallable(minimumOS: minimumOS, running: running) } } @@ -375,6 +381,7 @@ final class UpdatesModel: ObservableObject { let lookup = UpdateSources.parseITunesLookup(data) else { return result } result.latestVersion = lookup.version result.pageURL = lookup.pageURL + result.minimumOS = lookup.minimumOsVersion case .electron, .homebrew: break // v1: badge only; their own updaters handle it } From f3a8b3f69001514a8e99f2208f91b4cf02c314bb Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:18:54 -0700 Subject: [PATCH 10/33] feat(analyze+awake): treemap 'Other' fold + lid-closed Keep Screen On MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Analyze: the long tail past 120 cells folds into one inert 'Other' cell so the map stays legible and its total matches (PRD §Analyze, TreemapTail logic). - Keep Screen On: opt-in PreventSystemSleep assertion so a backup/render survives a closed lid (Store.keepAwakeLidClosed + Settings toggle, default off; §Everyday). --- macos/Sources/AnalyzeView.swift | 12 +++++++++++- macos/Sources/Awake.swift | 9 +++++++++ macos/Sources/SettingsView.swift | 8 ++++++++ macos/Sources/Store.swift | 7 +++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/macos/Sources/AnalyzeView.swift b/macos/Sources/AnalyzeView.swift index fdf0cc1..69a52c9 100644 --- a/macos/Sources/AnalyzeView.swift +++ b/macos/Sources/AnalyzeView.swift @@ -192,7 +192,17 @@ struct TreemapView: View { var body: some View { GeometryReader { geo in - let shown = Array(entries.filter { $0.size > 0 }.prefix(120)) + // Largest 120 cells; the long tail folds into one inert "Other" + // cell so the map stays legible AND its total matches (PRD §Analyze). + let shown: [DiskScanEntry] = { + let sorted = entries.filter { $0.size > 0 }.sorted { $0.size > $1.size } + guard sorted.count > 120 else { return sorted } + let tail = sorted.dropFirst(120).reduce(Int64(0)) { $0 + $1.size } + return Array(sorted.prefix(120)) + (tail > 0 + ? [DiskScanEntry(id: "__other__", name: NSLocalizedString("Other", comment: ""), + path: "", size: tail, isDir: false, lastAccess: nil)] + : []) + }() let rects = Treemap.layout(weights: shown.map { Double($0.size) }, in: CGRect(x: 0, y: 0, width: geo.size.width, height: geo.size.height)) // One immediate-mode draw pass, not 120 nested SwiftUI cells. The diff --git a/macos/Sources/Awake.swift b/macos/Sources/Awake.swift index 95b0a7f..1a185e8 100644 --- a/macos/Sources/Awake.swift +++ b/macos/Sources/Awake.swift @@ -48,6 +48,7 @@ final class Awake: ObservableObject { private var displayAssertion: IOPMAssertionID = 0 private var systemAssertion: IOPMAssertionID = 0 + private var lidAssertion: IOPMAssertionID = 0 private var expiryTimer: Timer? private init() {} @@ -61,6 +62,13 @@ final class Awake: ObservableObject { ok = IOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleSystemSleep as CFString, IOPMAssertionLevel(kIOPMAssertionLevelOn), reason, &systemAssertion) == kIOReturnSuccess && ok + // Opt-in (PRD §Everyday): also prevent system sleep so a backup/render + // survives a closed lid. Default off — it changes power behaviour. + if Store.keepAwakeLidClosed { + _ = IOPMAssertionCreateWithName(kIOPMAssertionTypePreventSystemSleep as CFString, + IOPMAssertionLevel(kIOPMAssertionLevelOn), + reason, &lidAssertion) + } isActive = ok if let secs = duration.seconds { expiresAt = Date().addingTimeInterval(secs) @@ -75,6 +83,7 @@ final class Awake: ObservableObject { func stop() { if displayAssertion != 0 { IOPMAssertionRelease(displayAssertion); displayAssertion = 0 } if systemAssertion != 0 { IOPMAssertionRelease(systemAssertion); systemAssertion = 0 } + if lidAssertion != 0 { IOPMAssertionRelease(lidAssertion); lidAssertion = 0 } expiryTimer?.invalidate() expiryTimer = nil isActive = false diff --git a/macos/Sources/SettingsView.swift b/macos/Sources/SettingsView.swift index fcefc0b..00b06c0 100644 --- a/macos/Sources/SettingsView.swift +++ b/macos/Sources/SettingsView.swift @@ -86,6 +86,7 @@ struct SettingsView: View { @State private var popupTiles: Set = Store.popupTiles @State private var runnerConfig: RunnerConfig = Store.runnerConfig @State private var inputLock: Bool = Store.cleanScreenInputLock + @State private var keepAwakeLidClosed: Bool = Store.keepAwakeLidClosed @State private var axTrusted = CleanScreen.inputLockPermitted() // Advanced @@ -467,6 +468,13 @@ struct SettingsView: View { footnote("System-wide. Click a chip, press a combination with ⌃, ⌥ or ⌘; Esc cancels, × clears.") } + section("Keep Screen On", "sun.max") { + toggleRow("Also keep working with the lid closed", isOn: $keepAwakeLidClosed) { on in + Store.keepAwakeLidClosed = on + } + footnote("Adds a stronger sleep assertion so a backup or render survives shutting the lid. Off by default — it changes power behaviour; best on AC power. Takes effect next time you start Keep Screen On.") + } + section("Clean Screen", "rectangle.inset.filled") { toggleRow("Block keys while wiping", isOn: $inputLock) { on in Store.cleanScreenInputLock = on diff --git a/macos/Sources/Store.swift b/macos/Sources/Store.swift index f3eb384..105afc2 100644 --- a/macos/Sources/Store.swift +++ b/macos/Sources/Store.swift @@ -104,6 +104,13 @@ enum Store { set { write(newValue, "auto_vacuum") } } + /// Keep Screen On also prevents system sleep (survives a closed lid). Off by + /// default — it changes power behaviour (PRD §Everyday). + static var keepAwakeLidClosed: Bool { + get { d.object(forKey: "keep_awake_lid_closed") as? Bool ?? false } + set { write(newValue, "keep_awake_lid_closed") } + } + // MARK: - Menu bar /// Whether to install the menu-bar status item (issue #4). On by From bc2dce8ec5d95a40ffe825ed1b6204ce74446a3a Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:21:15 -0700 Subject: [PATCH 11/33] =?UTF-8?q?feat(updates+uninstall):=20=E2=8C=98R=20r?= =?UTF-8?q?efresh=20+=20cache-bypass;=20alias-aware=20app=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updates: ⌘R refreshes the check; manual fetch sets reloadIgnoringLocalCacheData so it bypasses a stale appcast/iTunes HTTP cache (PRD §Software). - Uninstall: search now matches bundle id + the engine's uninstall name, not just the display name (PRD §Uninstall, alias-aware). --- macos/Sources/SoftwareView.swift | 7 ++++++- macos/Sources/UpdatesView.swift | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/macos/Sources/SoftwareView.swift b/macos/Sources/SoftwareView.swift index 12b86fc..48b973f 100644 --- a/macos/Sources/SoftwareView.swift +++ b/macos/Sources/SoftwareView.swift @@ -536,7 +536,12 @@ final class SoftwareModel: ObservableObject { var filtered: [InstalledApp] { let q = query.trimmingCharacters(in: .whitespaces).lowercased() - let base = q.isEmpty ? apps : apps.filter { (loweredNames[$0.id] ?? $0.name.lowercased()).contains(q) } + // Alias-aware: also match bundle id + the engine's uninstall name (PRD §Uninstall). + let base = q.isEmpty ? apps : apps.filter { + (loweredNames[$0.id] ?? $0.name.lowercased()).contains(q) + || $0.bundleId.lowercased().contains(q) + || $0.uninstallName.lowercased().contains(q) + } let sorted: [InstalledApp] switch sort { case .size: sorted = base.sorted { $0.sizeBytes > $1.sizeBytes } diff --git a/macos/Sources/UpdatesView.swift b/macos/Sources/UpdatesView.swift index cdee3f2..4a71d4c 100644 --- a/macos/Sources/UpdatesView.swift +++ b/macos/Sources/UpdatesView.swift @@ -93,6 +93,7 @@ struct UpdatesView: View { PillButton(title: model.checked ? "Check again" : "Check for updates", filled: !model.checked) { model.checkNow() } + .keyboardShortcut("r", modifiers: .command) if model.checked, !model.brewItems.isEmpty { PillButton(title: model.upgrading.isEmpty ? "Update all brews" : "Updating…", filled: false) { model.upgradeAll() @@ -391,6 +392,7 @@ final class UpdatesModel: ObservableObject { private static func fetch(_ url: URL) async -> Data? { var request = URLRequest(url: url) request.timeoutInterval = 10 + request.cachePolicy = .reloadIgnoringLocalCacheData // manual check = fresh metadata (PRD §Software) return try? await URLSession.shared.data(for: request).0 } From b2ab873dd2cec98917202f8071a2f21208730f54 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:27:29 -0700 Subject: [PATCH 12/33] feat(optimize): pre-run safety guard banner (VPN / external display active) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires OptimizeGuards — before a maintenance run, warns if a VPN is connected (Connectivity.vpnConnected) or an external display is attached (NSScreen). Audio/Bluetooth-input detectors are a follow-up (PRD §Optimize). --- macos/Sources/OptimizeView.swift | 53 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/macos/Sources/OptimizeView.swift b/macos/Sources/OptimizeView.swift index dd64e10..55624a7 100644 --- a/macos/Sources/OptimizeView.swift +++ b/macos/Sources/OptimizeView.swift @@ -12,6 +12,7 @@ // import SwiftUI +import AppKit /// Groups + summary for the receipt, ticker state for the live view — /// one reduce pass over the same lines. @@ -20,14 +21,33 @@ typealias OptimizeReport = (groups: [TaskGroup], summary: TaskSummary?, ticker: struct OptimizeView: View { @StateObject private var flow = OperationFlow() @State private var preview = false + @State private var guardWarnings: [String] = [] var body: some View { switch flow.state { case .idle: - ToolHero(tool: .optimize, title: "Optimize", subtitle: Tool.optimize.tagline) { - PillButton(title: "Optimize") { runOptimize() } - PillButton(title: "Preview", filled: false) { runPreview() } + VStack(spacing: 12) { + if !guardWarnings.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(guardWarnings, id: \.self) { w in + HStack(alignment: .top, spacing: 7) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)).foregroundStyle(Brand.gold) + Text(w).font(Brand.sans(12)).foregroundStyle(Brand.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Brand.gold.opacity(0.12))) + .padding(.horizontal, 20) + } + ToolHero(tool: .optimize, title: "Optimize", subtitle: Tool.optimize.tagline) { + PillButton(title: "Optimize") { runOptimize() } + PillButton(title: "Preview", filled: false) { runPreview() } + } } + .task { await computeGuards() } case .gated(let pending): FullDiskAccessRequired( accent: Tool.optimize.accent, @@ -148,4 +168,31 @@ struct OptimizeView: View { gate: .fullDiskAccess(adminBypass: true), elevated: false, label: NSLocalizedString("Optimize preview", comment: ""))) } + + /// Pre-run safety guards (PRD §Optimize): warn if a VPN or an external + /// display is active. (Audio / Bluetooth-input detectors are a follow-up.) + private func computeGuards() async { + let displays = NSScreen.screens.count + let vpn = await Task.detached(priority: .utility) { + Connectivity.vpnConnected(fromScutilNC: OptimizeView.runCmd("/usr/sbin/scutil", ["--nc", "list"])) + }.value + var s = OptimizeGuards.State() + s.vpnActive = vpn + s.externalDisplay = displays > 1 + guardWarnings = OptimizeGuards.warnings(s) + } + + private static func runCmd(_ path: String, _ args: [String]) -> String { + guard FileManager.default.isExecutableFile(atPath: path) else { return "" } + let p = Process() + p.executableURL = URL(fileURLWithPath: path) + p.arguments = args + let pipe = Pipe() + p.standardOutput = pipe + p.standardError = pipe + do { try p.run() } catch { return "" } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + p.waitUntilExit() + return String(decoding: data, as: UTF8.self) + } } From 6c765aac55cb10b6739fe98f70ad3ff5f417daf0 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:29:00 -0700 Subject: [PATCH 13/33] feat(doctor): battery-health check (capacity %, omitted on desktops) --- macos/Sources/DoctorView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/DoctorView.swift b/macos/Sources/DoctorView.swift index d483bf6..e9e3c72 100644 --- a/macos/Sources/DoctorView.swift +++ b/macos/Sources/DoctorView.swift @@ -123,6 +123,7 @@ struct DoctorView: View { // froze the main thread long enough to trip Sentry's ≥2000ms App Hang // detector. Probe off the main thread, then publish on the main actor. let cpuLoad = latest?.cpu.usage + let battHealth: Int? = (latest?.batteries?.first?.capacity).flatMap { $0 > 0 ? $0 : nil } let probes = await Task.detached(priority: .utility) { (backup: BackupStatus.lastBackupDaysAgo(), smart: DiskHealth.smartVerified(), @@ -140,6 +141,7 @@ struct DoctorView: View { smartVerified: probes.smart, sip: probes.sip, gatekeeper: probes.gatekeeper, fileVault: probes.fileVault, firewall: probes.firewall, + batteryHealthPct: battHealth, cpuLoadPercent: cpuLoad)) } From 3150c99484ceccd0027518ffcc2e1c9fa4e887c0 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:42:05 -0700 Subject: [PATCH 14/33] feat(clean): show all-time cleaned total on the done screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads CleanWatch lifetime totals off-main (MoleHistory.load) and appends 'Lifetime: X cleaned' to the Cleaned done-banner, where the user just acted (PRD §Clean — the figure previously lived only in the menu-bar popup). --- macos/Sources/CleanView.swift | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/macos/Sources/CleanView.swift b/macos/Sources/CleanView.swift index e8e6b04..8976446 100644 --- a/macos/Sources/CleanView.swift +++ b/macos/Sources/CleanView.swift @@ -44,6 +44,8 @@ struct CleanView: View { /// Trash-mode result line, shown as a done banner. @State private var trashResult: String? @State private var fdaGranted = Privacy.hasFullDiskAccess() + /// All-time bytes cleaned (PRD §Clean), loaded off-main for the done screen. + @State private var lifetimeCleaned: Int64 = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -101,6 +103,22 @@ struct CleanView: View { } } + /// Done-screen detail: this run's freed line + the all-time total. + private var cleanedDetail: String? { + let thisRun = realFlow.report?.summary.map(\.completionLine) + let life = lifetimeCleaned > 0 + ? String(format: NSLocalizedString("Lifetime: %@ cleaned", comment: ""), Fmt.bytes(lifetimeCleaned)) + : nil + let parts = [thisRun, life].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private func loadLifetime() async { + lifetimeCleaned = await Task.detached(priority: .utility) { + CleanWatch.totals(from: MoleHistory.load()).cleanedBytes + }.value + } + private var dryRunFinished: Bool { if case .finished = dryFlow.state { return true } return false @@ -361,8 +379,8 @@ struct CleanView: View { .padding(.horizontal, 18).padding(.top, 4).padding(.bottom, 12) Rectangle().fill(Brand.hairline).frame(height: 1) if case .finished(.done) = realFlow.state { - DoneBanner(accent: Tool.clean.accent, title: "Cleaned", - detail: realFlow.report?.summary.map(\.completionLine)) + DoneBanner(accent: Tool.clean.accent, title: "Cleaned", detail: cleanedDetail) + .task { await loadLifetime() } } TaskReportView(groups: realFlow.report?.groups ?? [], accent: Tool.clean.accent) if case .finished = realFlow.state { From faab4a132077c7b202610a9ed495c18e5f87d498 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:49:10 -0700 Subject: [PATCH 15/33] feat(startup): surface modern Login (BTM) items in the inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanLiveIncludingLoginItems merges sfltool dumpbtm records (parsed by LoginItemsReader) into the plist-derived list, deduping by container-prefixed identifier and adding only what the LaunchAgent/Daemon scan misses. BTM items are review-only (never controllable). scanLive stays plist-only so the StartupWatcher baseline isn't polluted by the volatile BTM layer. (PRD §Startup) --- macos/Sources/SoftwareView.swift | 2 +- macos/Sources/StartupInventory.swift | 56 +++++++++++++++++++++++-- macos/Tests/StartupInventoryTests.swift | 47 +++++++++++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/macos/Sources/SoftwareView.swift b/macos/Sources/SoftwareView.swift index 48b973f..1751aa9 100644 --- a/macos/Sources/SoftwareView.swift +++ b/macos/Sources/SoftwareView.swift @@ -874,7 +874,7 @@ final class StartupModel: ObservableObject { func reload() { loading = true DispatchQueue.global(qos: .userInitiated).async { [weak self] in - let scanned = StartupInventory.scanLive() + let scanned = StartupInventory.scanLiveIncludingLoginItems() let disabled = StartupControl.disabledLabels() Task { @MainActor in self?.items = scanned diff --git a/macos/Sources/StartupInventory.swift b/macos/Sources/StartupInventory.swift index c8767c6..3664151 100644 --- a/macos/Sources/StartupInventory.swift +++ b/macos/Sources/StartupInventory.swift @@ -19,12 +19,13 @@ import Foundation struct StartupItem: Identifiable, Equatable { enum Kind: Equatable { - case launchAgent, launchDaemon + case launchAgent, launchDaemon, loginItem var label: String { switch self { case .launchAgent: return NSLocalizedString("Launch agent", comment: "startup kind") case .launchDaemon: return NSLocalizedString("Launch daemon", comment: "startup kind") + case .loginItem: return NSLocalizedString("Login item", comment: "startup kind") } } } @@ -57,11 +58,16 @@ struct StartupItem: Identifiable, Equatable { /// User-scope, not bundled in an app, and not broken — the only items /// Burrow can safely enable/disable without admin (launchctl in the - /// per-user gui domain). Everything else stays review-only. - var controllable: Bool { scope == .user && !bundledInApp && problem == nil } + /// per-user gui domain). Everything else stays review-only. Modern Login + /// (BTM) items aren't launchctl-toggleable — the owning app or System + /// Settings manages them — so they're never controllable. + var controllable: Bool { scope == .user && kind != .loginItem && !bundledInApp && problem == nil } /// The classification subline: kind + who manages it. var subline: String { + if kind == .loginItem { + return NSLocalizedString("Login item · System Settings manages it; review only", comment: "") + } let management = bundledInApp ? NSLocalizedString("Bundled inside an app; review only", comment: "") : (scope == .system @@ -160,7 +166,9 @@ enum StartupInventory { } /// The live no-admin inventory: user agents + world-readable system - /// agents/daemons. + /// agents/daemons. This is the stable baseline source — the StartupWatcher + /// diff (#8/#12) reads it, so it deliberately excludes the volatile BTM + /// layer (`scanLiveIncludingLoginItems` adds that for the UI only). static func scanLive() -> [StartupItem] { let home = FileManager.default.homeDirectoryForCurrentUser var items = scan(directory: home.appendingPathComponent("Library/LaunchAgents"), @@ -171,4 +179,44 @@ enum StartupInventory { kind: .launchDaemon, scope: .system) return items } + + /// Merge modern Login/Background items (BTM, from `sfltool dumpbtm`) into the + /// plist-derived inventory, adding only those the LaunchAgent/Daemon scan + /// didn't already surface (PRD §Startup). Pure — the sfltool spawn lives in + /// `scanLiveIncludingLoginItems`. BTM items are review-only: toggling needs + /// the owning app or admin, so they never become `controllable`. + static func merge(plistItems: [StartupItem], login: [LoginItemsReader.Item]) -> [StartupItem] { + let known = Set(plistItems.map { $0.label.lowercased() }) + // BTM identifiers carry a "." prefix, e.g. + // "16.com.henry.studio-route-guard" → "com.henry.studio-route-guard". + func normalized(_ id: String) -> String { + id.replacingOccurrences(of: #"^\d+\."#, with: "", options: .regularExpression).lowercased() + } + var extras: [StartupItem] = [] + for li in login { + let norm = normalized(li.identifier) + // Skip placeholder container records and anything a plist covers. + if norm.isEmpty || li.identifier.lowercased() == "unknown developer" { continue } + if known.contains(norm) || known.contains(li.identifier.lowercased()) { continue } + extras.append(StartupItem( + label: li.name.isEmpty ? li.identifier : li.name, + kind: .loginItem, scope: .user, + plistPath: "btm:\(li.identifier)", // synthetic, stable id + executable: nil, problem: nil)) + } + let sortedExtras = extras.sorted { + $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending + } + return plistItems + sortedExtras + } + + /// The inventory the Startup UI shows: `scanLive` plus modern Login items. + /// `sfltool dumpbtm` needs root for the full list, so unelevated this just + /// returns the plist inventory (graceful) rather than failing. + static func scanLiveIncludingLoginItems() -> [StartupItem] { + let base = scanLive() + let dump = (try? MoEngine.shared.capture( + MoCommand(target: .executable("/usr/bin/sfltool"), args: ["dumpbtm"], timeout: 10)).stdout) ?? "" + return merge(plistItems: base, login: LoginItemsReader.parse(dump)) + } } diff --git a/macos/Tests/StartupInventoryTests.swift b/macos/Tests/StartupInventoryTests.swift index 82dd21f..19a05ca 100644 --- a/macos/Tests/StartupInventoryTests.swift +++ b/macos/Tests/StartupInventoryTests.swift @@ -91,4 +91,51 @@ final class StartupInventoryTests: XCTestCase { kind: .launchDaemon, scope: .system) XCTAssertTrue(items.isEmpty) } + + // MARK: - Modern Login (BTM) items merge + + private func plistItem(_ label: String) -> StartupItem { + StartupItem(label: label, kind: .launchAgent, scope: .user, + plistPath: "/x/\(label).plist", executable: "/bin/echo", problem: nil) + } + + private func btm(_ name: String, _ id: String) -> LoginItemsReader.Item { + LoginItemsReader.Item(name: name, identifier: id, developer: "", type: "developer", enabled: true) + } + + func testMerge_addsBtmItemsNotCoveredByPlists() { + let merged = StartupInventory.merge( + plistItems: [plistItem("com.docker.docker")], + login: [btm("Docker", "com.docker.docker"), + btm("studio-route-guard.sh", "16.com.henry.studio-route-guard")]) + // Docker is already enumerated as a plist agent → not duplicated. + XCTAssertEqual(merged.filter { $0.label == "Docker" }.count, 0) + XCTAssertEqual(merged.filter { $0.kind == .loginItem }.count, 1) + let rg = merged.first { $0.label == "studio-route-guard.sh" } + XCTAssertEqual(rg?.kind, .loginItem) + } + + func testMerge_btmItemsAreReviewOnly() { + let merged = StartupInventory.merge( + plistItems: [], login: [btm("studio-route-guard.sh", "16.com.henry.studio-route-guard")]) + let rg = merged.first { $0.kind == .loginItem } + XCTAssertNotNil(rg) + XCTAssertFalse(rg!.controllable, "BTM items can't be launchctl-toggled") + XCTAssertEqual(rg!.id, "btm:16.com.henry.studio-route-guard", "synthetic, stable id") + } + + func testMerge_dedupsByContainerPrefixedIdentifier() { + // A plist agent labelled "com.henry.studio-route-guard" already covers + // the BTM record "16.com.henry.studio-route-guard". + let merged = StartupInventory.merge( + plistItems: [plistItem("com.henry.studio-route-guard")], + login: [btm("studio-route-guard.sh", "16.com.henry.studio-route-guard")]) + XCTAssertEqual(merged.filter { $0.kind == .loginItem }.count, 0) + } + + func testMerge_skipsPlaceholderRecords() { + let merged = StartupInventory.merge( + plistItems: [], login: [btm("Unknown Developer", "Unknown Developer")]) + XCTAssertTrue(merged.filter { $0.kind == .loginItem }.isEmpty) + } } From 01e7429c86dc9ae57ee4510e884a2dbec6e992ce Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:52:22 -0700 Subject: [PATCH 16/33] feat(uninstall): Clear-Data subset + input-method leftover warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear Data ticks every enumerated leftover except the .app bundle (UninstallPlan.dataOnly), routing through the existing subset-trash path so the app stays installed but its data is removed; shown only when there's a bundle to exclude. Input-method leftovers get a warning badge (UninstallPlan.isInputMethod) since removing one can disable typing. (PRD §Uninstall) --- macos/Sources/SoftwareView.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/macos/Sources/SoftwareView.swift b/macos/Sources/SoftwareView.swift index 1751aa9..9552a8c 100644 --- a/macos/Sources/SoftwareView.swift +++ b/macos/Sources/SoftwareView.swift @@ -331,8 +331,12 @@ struct AppRow: View { .padding(10) } else if let preview, !preview.isEmpty { VStack(alignment: .leading, spacing: 8) { - // Header: name + mono bundle path · k/n selected · select all + // Header: name + mono bundle path · k/n selected · clear-data · select all HStack { + // "Clear Data" = every leftover except the .app bundle. Shown only + // when there's a bundle to exclude, so it's always a true subset + // (kept app, removed data) routed through the native-trash path. + let dataPaths = UninstallPlan.dataOnly(preview.entries.map(\.path)) VStack(alignment: .leading, spacing: 1) { Text(app.name).font(Brand.sans(12, .semibold)).foregroundStyle(Brand.textPrimary) Text(prettyPath).font(Brand.mono(9)).foregroundStyle(Brand.textTertiary) @@ -341,6 +345,13 @@ struct AppRow: View { Spacer() Text(verbatim: "\(pathSelection.count)/\(preview.entries.count) selected") .font(Brand.mono(10)).foregroundStyle(Brand.textSecondary) + if dataPaths.count < preview.entries.count { + Button(NSLocalizedString("Clear Data", comment: "")) { + pathSelection = Set(dataPaths) + } + .buttonStyle(.plain).font(Brand.sans(10, .semibold)).foregroundStyle(Tool.apps.accent) + .help(NSLocalizedString("Select everything except the app itself — removes its data but keeps the app installed.", comment: "")) + } Button(NSLocalizedString("Select all", comment: "")) { pathSelection = Set(preview.entries.map(\.path)) } @@ -420,6 +431,11 @@ struct AppRow: View { .frame(width: 96, alignment: .leading) Text(entry.path).font(Brand.mono(9)).foregroundStyle(Brand.textTertiary) .lineLimit(1).truncationMode(.middle) + if UninstallPlan.isInputMethod(entry.path) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 9)).foregroundStyle(Brand.gold) + .help(NSLocalizedString("Input method — removing this can disable typing for its language until you log out.", comment: "")) + } Spacer() Button { AnalyzeIcons.reveal(entry.expandedPath) } label: { Image(systemName: "magnifyingglass.circle").font(.system(size: 11)).foregroundStyle(Brand.textTertiary) From f07cbdb10328a5d223bb3631068dc0e4256f0407 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:58:32 -0700 Subject: [PATCH 17/33] =?UTF-8?q?feat(status):=20suspend/resume=20+=20expo?= =?UTF-8?q?rt=20for=20the=20process=20table=20(epic=20=CE=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row menu gains reversible Suspend/Resume (SIGSTOP/SIGCONT, own-user only, no confirm) alongside Quit/Force Kill. Table header gains an export menu that copies the current sorted process set as CSV or JSON (ProcessExport). (PRD §α) --- macos/Sources/ProcessActions.swift | 9 ++++++++ macos/Sources/StatusView.swift | 33 +++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/macos/Sources/ProcessActions.swift b/macos/Sources/ProcessActions.swift index b139573..1d11185 100644 --- a/macos/Sources/ProcessActions.swift +++ b/macos/Sources/ProcessActions.swift @@ -70,6 +70,15 @@ enum ProcessActions { /// SIGKILL — the hammer. Caller double-confirms first. @discardableResult static func forceKill(pid: Int) -> Bool { kill(Int32(pid), SIGKILL) == 0 } + + /// SIGSTOP — pause a process (freeze it without killing). Reversible via + /// `resume`; own-user processes only (PRD §α Process Inspector). + @discardableResult + static func suspend(pid: Int) -> Bool { kill(Int32(pid), SIGSTOP) == 0 } + + /// SIGCONT — resume a previously suspended process. + @discardableResult + static func resume(pid: Int) -> Bool { kill(Int32(pid), SIGCONT) == 0 } } /// The full live process list for the Status table. `mo status --json` diff --git a/macos/Sources/StatusView.swift b/macos/Sources/StatusView.swift index b3f067f..f848553 100644 --- a/macos/Sources/StatusView.swift +++ b/macos/Sources/StatusView.swift @@ -638,12 +638,38 @@ struct ProcessCard: View { sortButton("CPU", .cpu).frame(width: 92, alignment: .trailing) sortButton("PWR", .pwr).frame(width: 44, alignment: .trailing) sortButton("MEM", .mem).frame(width: 64, alignment: .trailing) - Color.clear.frame(width: 20) // the … column + exportMenu // aligns with the per-row … column } .padding(.horizontal, 14) .padding(.vertical, 9) } + /// Table-level export of the current (sorted) process set to the clipboard + /// (PRD §α Process Inspector). Threads aren't in the `ps` sample → 0. + private var exportMenu: some View { + Menu { + Button(NSLocalizedString("Copy as CSV", comment: "")) { copyExport(asCSV: true) } + Button(NSLocalizedString("Copy as JSON", comment: "")) { copyExport(asCSV: false) } + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 10)).foregroundStyle(Brand.textTertiary) + .frame(width: 20, height: 20).contentShape(Rectangle()) + } + .menuStyle(.borderlessButton).menuIndicator(.hidden).frame(width: 20) + .help(NSLocalizedString("Export the process list", comment: "")) + .accessibilityLabel(NSLocalizedString("Export the process list", comment: "")) + } + + private func copyExport(asCSV: Bool) { + let rows = model.sortedRows.map { + ProcessExport.Row(pid: $0.pid, name: $0.name, cpu: $0.cpu, + memBytes: Int64($0.memoryBytes ?? 0), threads: 0) + } + let text = asCSV ? ProcessExport.csv(rows) : ProcessExport.json(rows) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + private func sortButton(_ title: String, _ key: ProcSort) -> some View { Button { model.setSort(key) } label: { HStack(spacing: 3) { @@ -726,6 +752,8 @@ struct ProcRow: View { static let copyPID = NSLocalizedString("Copy PID", comment: "") static let quit = NSLocalizedString("Quit…", comment: "") static let forceKill = NSLocalizedString("Force Kill…", comment: "") + static let suspend = NSLocalizedString("Suspend", comment: "") + static let resume = NSLocalizedString("Resume", comment: "") } /// Per-row "…" menu: pin, reveal, copy; Quit / Force Kill for @@ -748,6 +776,9 @@ struct ProcRow: View { } if ProcessActions.isOwnProcess(pid: p.pid) { Divider() + // Suspend/Resume are reversible (SIGSTOP/SIGCONT) — no confirm. + Button(L.suspend) { ProcessActions.suspend(pid: p.pid) } + Button(L.resume) { ProcessActions.resume(pid: p.pid) } Button(L.quit, role: .destructive) { confirmQuit(force: false) } Button(L.forceKill, role: .destructive) { confirmQuit(force: true) } } From 15de84f7fefb614baf4ea05aedef27dcdc758753 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:01:05 -0700 Subject: [PATCH 18/33] =?UTF-8?q?feat(status):=20typed=20predicate=20filte?= =?UTF-8?q?r=20over=20the=20process=20table=20(epic=20=CE=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessFilter.parse turns 'cpu > 20' / 'name ~ chrome' / a bare term into a predicate (multi-char operators beat their prefixes; mem aliases memory; bare term → name-contains). StatusModel.filterText feeds it into recomputeSortedRows, and a filter bar drives it live. Chosen over an embedded JS runtime so the same predicates can serve agents later. (PRD §α) --- macos/Sources/ProcessFilter.swift | 29 +++++++++++++++++++ macos/Sources/StatusView.swift | 42 +++++++++++++++++++++++++++- macos/Tests/ProcessFilterTests.swift | 37 ++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/macos/Sources/ProcessFilter.swift b/macos/Sources/ProcessFilter.swift index 64dae20..e04c4b4 100644 --- a/macos/Sources/ProcessFilter.swift +++ b/macos/Sources/ProcessFilter.swift @@ -48,4 +48,33 @@ enum ProcessFilter { static func apply(_ records: [Record], _ p: Predicate) -> [Record] { records.filter { matches($0, p) } } + + /// Parse a filter expression: "cpu > 20", "mem >= 1e8", "name ~ chrome", + /// "pid == 1". A bare term with no operator is a name-contains filter + /// ("chrome" → name ~ chrome). "mem" aliases "memory". nil for empty input + /// or an unknown field. Multi-char operators are matched before their + /// single-char prefixes so ">=" wins over ">". + static func parse(_ raw: String) -> Predicate? { + let s = raw.trimmingCharacters(in: .whitespaces) + guard !s.isEmpty else { return nil } + for op in [Op.ge, .le, .eq, .gt, .lt, .contains] { + guard let r = s.range(of: op.rawValue) else { continue } + let fieldStr = s[.. Field? { + switch s { + case "cpu": return .cpu + case "mem", "memory": return .memory + case "thread", "threads": return .threads + case "name": return .name + case "pid": return .pid + default: return nil + } + } } diff --git a/macos/Sources/StatusView.swift b/macos/Sources/StatusView.swift index f848553..159e0ee 100644 --- a/macos/Sources/StatusView.swift +++ b/macos/Sources/StatusView.swift @@ -590,6 +590,8 @@ struct ProcessCard: View { let hidden = all.count - rows.count return GlassCard(padding: 0) { VStack(spacing: 0) { + filterBar + Rectangle().fill(Brand.hairline).frame(height: 1) header(count: all.count) Rectangle().fill(Brand.hairline).frame(height: 1) // The table scrolls on its own, under a sticky header, @@ -630,6 +632,28 @@ struct ProcessCard: View { .accessibilityLabel(NSLocalizedString("Show all processes", comment: "")) } + /// Typed filter bar (PRD §α): "cpu > 20", "name ~ chrome", or a bare term + /// for name-contains. Recomputes the sorted rows as you type. + private var filterBar: some View { + HStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 11)).foregroundStyle(Brand.textTertiary) + TextField(NSLocalizedString("Filter — e.g. cpu > 20, name ~ chrome", comment: ""), + text: Binding(get: { model.filterText }, set: { model.setFilter($0) })) + .textFieldStyle(.plain) + .font(Brand.mono(11)).foregroundStyle(Brand.textPrimary) + if !model.filterText.isEmpty { + Button { model.setFilter("") } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 11)).foregroundStyle(Brand.textTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("Clear filter", comment: "")) + } + } + .padding(.horizontal, 14).padding(.vertical, 7) + } + private func header(count: Int) -> some View { HStack(spacing: 10) { sortButton(String(format: NSLocalizedString("NAME (%d)", comment: ""), count), .name) @@ -953,6 +977,9 @@ final class StatusModel: ObservableObject { /// re-evaluates `ProcessCard.body` no longer re-sorts hundreds of rows /// on the main thread (Sentry BURROW-1 / BURROW-N App Hang). @Published var sortedRows: [ProcessInfo] = [] + /// Typed predicate filter over the table (PRD §α), e.g. "cpu > 20" or + /// "name ~ chrome". Empty = no filter. Parsed once per change, not per row. + @Published var filterText: String = "" let db: DB private let live: LiveFeed @@ -1043,13 +1070,26 @@ final class StatusModel: ObservableObject { recomputeSortedRows() } + func setFilter(_ text: String) { + guard text != filterText else { return } + filterText = text + recomputeSortedRows() + } + /// Re-sort the table from the current inputs and publish the result into /// `sortedRows`. O(n log n) over a few hundred rows, but run once per /// real change instead of once per `ProcessCard.body` evaluation — the /// body now just reads the cached array. Must run on the main actor (it /// mutates a `@Published`); every caller already does. func recomputeSortedRows() { - let procs = processes.isEmpty ? (snap?.topProcesses ?? []) : processes + var procs = processes.isEmpty ? (snap?.topProcesses ?? []) : processes + if let pred = ProcessFilter.parse(filterText) { + procs = procs.filter { + ProcessFilter.matches(ProcessFilter.Record( + pid: $0.pid, name: $0.name, cpu: $0.cpu, + memBytes: Int64($0.memoryBytes ?? 0), threads: 0), pred) + } + } let sorted = procs.sorted { a, b in switch sortKey { case .name: return sortAsc ? a.name < b.name : a.name > b.name diff --git a/macos/Tests/ProcessFilterTests.swift b/macos/Tests/ProcessFilterTests.swift index a7666dd..e3cf9f4 100644 --- a/macos/Tests/ProcessFilterTests.swift +++ b/macos/Tests/ProcessFilterTests.swift @@ -21,4 +21,41 @@ final class ProcessFilterTests: XCTestCase { let p = ProcessFilter.Predicate(field: .memory, op: .ge, value: "\(1 << 30)") XCTAssertEqual(ProcessFilter.apply(records, p).map(\.pid), [1]) } + + // MARK: - parse + + func testParse_numericPredicate() { + let p = ProcessFilter.parse("cpu > 50") + XCTAssertEqual(p?.field, .cpu) + XCTAssertEqual(p?.op, .gt) + XCTAssertEqual(p?.value, "50") + XCTAssertEqual(ProcessFilter.apply(records, p!).map(\.pid), [1]) + } + + func testParse_multiCharOperatorBeatsPrefix() { + // ">=" must win over ">", else value becomes "= 90". + let p = ProcessFilter.parse("cpu >= 90") + XCTAssertEqual(p?.op, .ge) + XCTAssertEqual(p?.value, "90") + } + + func testParse_memAliasAndNoSpaces() { + let p = ProcessFilter.parse("mem>=1073741824") + XCTAssertEqual(p?.field, .memory) + XCTAssertEqual(p?.op, .ge) + XCTAssertEqual(ProcessFilter.apply(records, p!).map(\.pid), [1]) + } + + func testParse_bareTermIsNameContains() { + let p = ProcessFilter.parse("find") + XCTAssertEqual(p?.field, .name) + XCTAssertEqual(p?.op, .contains) + XCTAssertEqual(ProcessFilter.apply(records, p!).map(\.pid), [2]) + } + + func testParse_emptyAndUnknownField() { + XCTAssertNil(ProcessFilter.parse(" ")) + XCTAssertNil(ProcessFilter.parse("bogus > 1")) + XCTAssertNil(ProcessFilter.parse("cpu >")) // missing value + } } From ca0666623604a489d30defdcf3e14d7af8461e23 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:05:03 -0700 Subject: [PATCH 19/33] fix(uninstall): add missing 'paths:' label on UninstallPlan.dataOnly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compile error from the Clear-Data commit (01e7429) — dataOnly(paths:) was called positionally. Tip now compiles. --- macos/Sources/SoftwareView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/SoftwareView.swift b/macos/Sources/SoftwareView.swift index 9552a8c..6ad2fb2 100644 --- a/macos/Sources/SoftwareView.swift +++ b/macos/Sources/SoftwareView.swift @@ -336,7 +336,7 @@ struct AppRow: View { // "Clear Data" = every leftover except the .app bundle. Shown only // when there's a bundle to exclude, so it's always a true subset // (kept app, removed data) routed through the native-trash path. - let dataPaths = UninstallPlan.dataOnly(preview.entries.map(\.path)) + let dataPaths = UninstallPlan.dataOnly(paths: preview.entries.map(\.path)) VStack(alignment: .leading, spacing: 1) { Text(app.name).font(Brand.sans(12, .semibold)).foregroundStyle(Brand.textPrimary) Text(prettyPath).font(Brand.mono(9)).foregroundStyle(Brand.textTertiary) From cb426b0f7cb149cd2ccbf89edd5ae0c096e37629 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:13:09 -0700 Subject: [PATCH 20/33] =?UTF-8?q?feat(status):=20per-process=20inspector?= =?UTF-8?q?=20sheet=20(epic=20=CE=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row menu gains Inspect…, opening a sheet that shows where the process was launched from (ProcessOrigin parent-chain walk: login/Dock, a shell, or SSH), its parent, executable path, and whether that binary still exists on disk (deleted/replaced-since-launch signal). Pure classification is reused from the tested ProcessOrigin module; an Identifiable wrapper drives .sheet(item:) without touching ProcessInfo. (PRD §α) --- macos/Sources/ProcessInspectorView.swift | 123 +++++++++++++++++++++++ macos/Sources/StatusView.swift | 11 +- 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/ProcessInspectorView.swift diff --git a/macos/Sources/ProcessInspectorView.swift b/macos/Sources/ProcessInspectorView.swift new file mode 100644 index 0000000..9bb1afa --- /dev/null +++ b/macos/Sources/ProcessInspectorView.swift @@ -0,0 +1,123 @@ +// +// ProcessInspectorView.swift +// Burrow +// +// Per-process inspector (PRD §α Process Inspector): where the process was +// launched from (ProcessOrigin — a pure parent-chain walk), its executable +// path, and whether that binary still exists on disk (a "deleted/replaced +// since launch" signal — the cheap, honest part of BinaryIntegrity that needs +// no launch-inode read). Presented as a sheet from the process table's row +// menu. The classification logic is pure + tested (ProcessOriginTests); this +// view only renders it. +// + +import SwiftUI +import AppKit + +/// Identifiable wrapper so `.sheet(item:)` can drive the inspector without +/// making the widely-Codable `ProcessInfo` itself Identifiable. +struct ProcessInspectTarget: Identifiable { + let proc: ProcessInfo + var id: Int { proc.pid } +} + +struct ProcessInspectorView: View { + let proc: ProcessInfo + /// The current process set — the parent-chain map ProcessOrigin walks. + let processes: [ProcessInfo] + @Environment(\.dismiss) private var dismiss + + private var table: [Int: ProcessOrigin.Info] { + Dictionary(processes.map { ($0.pid, ProcessOrigin.Info(name: $0.name, ppid: $0.ppid ?? 0)) }, + uniquingKeysWith: { a, _ in a }) + } + private var origin: ProcessOrigin.Origin { ProcessOrigin.classify(pid: proc.pid, table: table) } + private var path: String? { ProcessActions.executablePath(pid: proc.pid) } + private var binaryMissing: Bool { + guard let path else { return false } + return !FileManager.default.fileExists(atPath: path) + } + private var parentName: String? { proc.ppid.flatMap { table[$0]?.name } } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + AppIconView(proc: proc).frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 1) { + Text(proc.name).font(Brand.sans(15, .semibold)).foregroundStyle(Brand.textPrimary) + Text(verbatim: "PID \(proc.pid)").font(Brand.mono(11)).foregroundStyle(Brand.textTertiary) + } + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill").font(.system(size: 16)).foregroundStyle(Brand.textTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("Close", comment: "")) + } + + VStack(alignment: .leading, spacing: 10) { + field("ORIGIN", originText, glyph: originGlyph, tint: originTint) + if let parentName { + field("PARENT", "\(parentName) (PID \(proc.ppid ?? 0))", + glyph: "arrow.up.right", tint: Brand.textSecondary) + } + field("PROGRAM", path ?? NSLocalizedString("Unknown", comment: ""), + glyph: binaryMissing ? "exclamationmark.triangle.fill" : "checkmark.seal", + tint: binaryMissing ? Brand.red : Brand.green, mono: true) + if binaryMissing { + Text(NSLocalizedString("The program file no longer exists on disk — it was deleted or replaced after this process started.", comment: "")) + .font(Brand.mono(10)).foregroundStyle(Brand.red.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + } + field("CPU", String(format: "%.1f%%", proc.cpu), glyph: "cpu", tint: Brand.textSecondary) + } + + HStack { + Spacer() + if let path { + Button(NSLocalizedString("Reveal in Finder", comment: "")) { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + .buttonStyle(.plain).font(Brand.sans(12, .semibold)).foregroundStyle(Tool.status.accent) + } + } + } + .padding(20) + .frame(width: 460) + } + + private func field(_ label: String, _ value: String, glyph: String, tint: Color, mono: Bool = false) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: glyph).font(.system(size: 12)).foregroundStyle(tint).frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString(label, comment: "")).font(Brand.mono(9, .bold)).tracking(0.5) + .foregroundStyle(Brand.textTertiary) + Text(value).font(mono ? Brand.mono(11) : Brand.sans(12)).foregroundStyle(Brand.textPrimary) + .textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + } + + private var originText: String { + switch origin { + case .login: return NSLocalizedString("Launched at login / from the Dock", comment: "") + case .shell(let s): return String(format: NSLocalizedString("Started from a %@ shell", comment: ""), s) + case .ssh: return NSLocalizedString("Started over SSH (remote session)", comment: "") + } + } + private var originGlyph: String { + switch origin { + case .login: return "person.crop.circle" + case .shell: return "terminal" + case .ssh: return "network" + } + } + private var originTint: Color { + switch origin { + case .login: return Brand.green + case .shell: return Brand.gold + case .ssh: return Brand.orange + } + } +} diff --git a/macos/Sources/StatusView.swift b/macos/Sources/StatusView.swift index 159e0ee..f7e7247 100644 --- a/macos/Sources/StatusView.swift +++ b/macos/Sources/StatusView.swift @@ -583,6 +583,7 @@ struct ProcessCard: View { /// that bounded; "Show all" opts back into the full list. private static let rowCap = 100 @State private var showAll = false + @State private var inspecting: ProcessInspectTarget? var body: some View { let all = model.sortedRows @@ -604,7 +605,8 @@ struct ProcessCard: View { ForEach(rows, id: \.pid) { p in ProcRow(p: p, pinned: model.pinned.contains(p.pid), - energy: model.energies[p.pid]) { + energy: model.energies[p.pid], + onInspect: { inspecting = ProcessInspectTarget(proc: p) }) { model.togglePin(p.pid) } } @@ -616,6 +618,9 @@ struct ProcessCard: View { .frame(height: 195) } } + .sheet(item: $inspecting) { target in + ProcessInspectorView(proc: target.proc, processes: model.processes) + } } /// Footer row that reveals the remaining processes (hidden by default to @@ -715,6 +720,7 @@ struct ProcRow: View { let pinned: Bool /// Cumulative billed energy (nJ) — nil renders "—", never estimated. var energy: UInt64? = nil + var onInspect: () -> Void = {} let onPin: () -> Void @State private var hover = false @@ -771,6 +777,7 @@ struct ProcRow: View { private enum L { static let pin = NSLocalizedString("Pin", comment: "") static let unpin = NSLocalizedString("Unpin", comment: "") + static let inspect = NSLocalizedString("Inspect…", comment: "") static let reveal = NSLocalizedString("Reveal in Finder", comment: "") static let copyName = NSLocalizedString("Copy name", comment: "") static let copyPID = NSLocalizedString("Copy PID", comment: "") @@ -785,6 +792,8 @@ struct ProcRow: View { private var rowMenu: some View { Menu { Button(pinned ? L.unpin : L.pin) { onPin() } + Button(L.inspect) { onInspect() } + Divider() Button(L.reveal) { if let path = ProcessActions.executablePath(pid: p.pid) { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) From bae61bd491707e0baa342ee8a90e2668b39c2334 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:16:34 -0700 Subject: [PATCH 21/33] =?UTF-8?q?feat(status):=20per-process=20network=20i?= =?UTF-8?q?n=20the=20inspector=20(epic=20=CE=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inspector measures the selected process's live up/down bandwidth on demand (NetUsage/nettop, ~1s, off-main) and shows '↓ X/s ↑ Y/s' or Idle — the signature ProcessSpy per-process-network read, scoped to one process so the perf-sensitive table pump is untouched. (PRD §α) --- macos/Sources/ProcessInspectorView.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/macos/Sources/ProcessInspectorView.swift b/macos/Sources/ProcessInspectorView.swift index 9bb1afa..d95be58 100644 --- a/macos/Sources/ProcessInspectorView.swift +++ b/macos/Sources/ProcessInspectorView.swift @@ -26,6 +26,9 @@ struct ProcessInspectorView: View { /// The current process set — the parent-chain map ProcessOrigin walks. let processes: [ProcessInfo] @Environment(\.dismiss) private var dismiss + /// Per-process bandwidth (nettop, ~1s) — measured on demand, off-main. + @State private var net: NetUsage.Rates? + @State private var measuringNet = true private var table: [Int: ProcessOrigin.Info] { Dictionary(processes.map { ($0.pid, ProcessOrigin.Info(name: $0.name, ppid: $0.ppid ?? 0)) }, @@ -70,6 +73,7 @@ struct ProcessInspectorView: View { .fixedSize(horizontal: false, vertical: true) } field("CPU", String(format: "%.1f%%", proc.cpu), glyph: "cpu", tint: Brand.textSecondary) + field("NETWORK", netText, glyph: "network", tint: Brand.textSecondary) } HStack { @@ -84,6 +88,19 @@ struct ProcessInspectorView: View { } .padding(20) .frame(width: 460) + .task { + let pid = proc.pid + let r = await Task.detached(priority: .utility) { NetUsage.sample()[pid] }.value + net = r + measuringNet = false + } + } + + private var netText: String { + if measuringNet { return NSLocalizedString("Measuring…", comment: "") } + guard let net, net.down > 0 || net.up > 0 else { return NSLocalizedString("Idle", comment: "") } + return String(format: NSLocalizedString("↓ %@/s ↑ %@/s", comment: ""), + Fmt.bytes(net.down), Fmt.bytes(net.up)) } private func field(_ label: String, _ value: String, glyph: String, tint: Color, mono: Bool = false) -> some View { From c24c3a7219b830b10fbc7ff1b88519e65c4b9fe2 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:18:58 -0700 Subject: [PATCH 22/33] =?UTF-8?q?feat(status):=20process=20tree=20view=20(?= =?UTF-8?q?epic=20=CE=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table actions menu gains Process Tree…, a sheet that folds the flat list into a parent→children hierarchy with rolled-up subtree CPU/memory (ProcessTree, pure + tested), rendered with a lazy OutlineGroup so a few-hundred-node tree stays cheap. The flat table's perf-sensitive pump is untouched. (PRD §α) --- macos/Sources/ProcessTreeView.swift | 78 +++++++++++++++++++++++++++++ macos/Sources/StatusView.swift | 19 ++++--- 2 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 macos/Sources/ProcessTreeView.swift diff --git a/macos/Sources/ProcessTreeView.swift b/macos/Sources/ProcessTreeView.swift new file mode 100644 index 0000000..d3fbcc4 --- /dev/null +++ b/macos/Sources/ProcessTreeView.swift @@ -0,0 +1,78 @@ +// +// ProcessTreeView.swift +// Burrow +// +// Process hierarchy sheet (PRD §α — Process Inspector): the flat table folded +// into a parent→children tree where each node reports its whole subtree's +// CPU/memory (ProcessTree, pure + tested). Opened from the table's actions +// menu; collapsed by default so a few-hundred-node tree stays cheap. The flat +// table's perf-sensitive pump is untouched. +// + +import SwiftUI + +extension ProcessTree.Node: Identifiable { + public var id: Int { proc.pid } + /// OutlineGroup wants nil for leaves, not an empty array. + var childrenOpt: [ProcessTree.Node]? { children.isEmpty ? nil : children } +} + +struct ProcessTreeView: View { + let processes: [ProcessInfo] + @Environment(\.dismiss) private var dismiss + + @State private var roots: [ProcessTree.Node] = [] + @State private var names: [Int: String] = [:] + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text(NSLocalizedString("Process Tree", comment: "")) + .font(Brand.serif(18, .medium)).foregroundStyle(Brand.textPrimary) + Spacer() + Text(NSLocalizedString("Subtree totals", comment: "")) + .font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill").font(.system(size: 15)).foregroundStyle(Brand.textTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("Close", comment: "")) + } + .padding(.horizontal, 16).padding(.vertical, 12) + Rectangle().fill(Brand.hairline).frame(height: 1) + List { + OutlineGroup(roots, children: \.childrenOpt) { node in + row(node) + } + } + .listStyle(.sidebar) + } + .frame(width: 520, height: 560) + .onAppear(perform: build) + } + + private func row(_ node: ProcessTree.Node) -> some View { + HStack(spacing: 8) { + Text(names[node.proc.pid] ?? "pid \(node.proc.pid)") + .font(Brand.sans(12)).foregroundStyle(Brand.textPrimary).lineLimit(1) + Text("\(node.proc.pid)").font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + Spacer(minLength: 8) + Text(String(format: "%.1f%%", node.totalCPU)) + .font(Brand.mono(11)).foregroundStyle(Brand.textSecondary) + .frame(width: 56, alignment: .trailing) + Text(Fmt.bytes(node.totalMem)) + .font(Brand.mono(11)).foregroundStyle(Brand.textSecondary) + .frame(width: 72, alignment: .trailing) + } + } + + /// Build the tree once on appear — name lookup is O(1) thereafter. + private func build() { + names = Dictionary(processes.map { ($0.pid, $0.name) }, uniquingKeysWith: { a, _ in a }) + let procs = processes.map { + ProcessTree.Proc(pid: $0.pid, ppid: $0.ppid ?? 0, + cpu: $0.cpu, mem: Int64($0.memoryBytes ?? 0), threads: 0) + } + roots = ProcessTree.build(procs).sorted { $0.totalCPU > $1.totalCPU } + } +} diff --git a/macos/Sources/StatusView.swift b/macos/Sources/StatusView.swift index f7e7247..44e812c 100644 --- a/macos/Sources/StatusView.swift +++ b/macos/Sources/StatusView.swift @@ -584,6 +584,7 @@ struct ProcessCard: View { private static let rowCap = 100 @State private var showAll = false @State private var inspecting: ProcessInspectTarget? + @State private var showTree = false var body: some View { let all = model.sortedRows @@ -621,6 +622,9 @@ struct ProcessCard: View { .sheet(item: $inspecting) { target in ProcessInspectorView(proc: target.proc, processes: model.processes) } + .sheet(isPresented: $showTree) { + ProcessTreeView(processes: model.processes) + } } /// Footer row that reveals the remaining processes (hidden by default to @@ -673,20 +677,23 @@ struct ProcessCard: View { .padding(.vertical, 9) } - /// Table-level export of the current (sorted) process set to the clipboard - /// (PRD §α Process Inspector). Threads aren't in the `ps` sample → 0. + /// Table-level actions (PRD §α Process Inspector): a process-tree view and + /// export of the current (sorted) set to the clipboard. Threads aren't in + /// the `ps` sample → 0. private var exportMenu: some View { Menu { + Button(NSLocalizedString("Process Tree…", comment: "")) { showTree = true } + Divider() Button(NSLocalizedString("Copy as CSV", comment: "")) { copyExport(asCSV: true) } Button(NSLocalizedString("Copy as JSON", comment: "")) { copyExport(asCSV: false) } } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 10)).foregroundStyle(Brand.textTertiary) + Image(systemName: "ellipsis.circle") + .font(.system(size: 11)).foregroundStyle(Brand.textTertiary) .frame(width: 20, height: 20).contentShape(Rectangle()) } .menuStyle(.borderlessButton).menuIndicator(.hidden).frame(width: 20) - .help(NSLocalizedString("Export the process list", comment: "")) - .accessibilityLabel(NSLocalizedString("Export the process list", comment: "")) + .help(NSLocalizedString("Process tree and export", comment: "")) + .accessibilityLabel(NSLocalizedString("Process tree and export", comment: "")) } private func copyExport(asCSV: Bool) { From ce104e02479356167cbab52945e67c3fb59a4568 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:26:11 -0700 Subject: [PATCH 23/33] =?UTF-8?q?feat(status):=20per-process=20CPU=20watch?= =?UTF-8?q?dog=20engine=20(epic=20=CE=B1,=20slice=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessWatchdog wraps the tested ProcessRule.fires: a per-pid rolling CPU buffer fed by the Status pump, converting the rule's sustain-seconds to the 2s cadence, firing once per process until it calms, then dispatching the configured action (notify/suspend/quit; suspend/quit own-user only). Store keys are opt-in and off by default, so this is inert until the Settings editor (next slice) turns it on. (PRD §α) --- macos/Sources/ProcessWatchdog.swift | 93 +++++++++++++++++++++++++++++ macos/Sources/StatusView.swift | 6 ++ macos/Sources/Store.swift | 24 ++++++++ 3 files changed, 123 insertions(+) create mode 100644 macos/Sources/ProcessWatchdog.swift diff --git a/macos/Sources/ProcessWatchdog.swift b/macos/Sources/ProcessWatchdog.swift new file mode 100644 index 0000000..df36a3d --- /dev/null +++ b/macos/Sources/ProcessWatchdog.swift @@ -0,0 +1,93 @@ +// +// ProcessWatchdog.swift +// Burrow +// +// The impure seam around ProcessRule (PRD §α): keeps a per-pid rolling CPU +// buffer fed by the Status process pump, evaluates the opt-in watchdog rule +// each tick (pure ProcessRule.fires), and dispatches the configured action — +// notify / suspend / quit. Disabled by default (Store.processWatchdogEnabled); +// when off it clears its buffers and does nothing. Suspend/quit only touch +// own-user processes; notify always posts. +// + +import Foundation +import UserNotifications + +final class ProcessWatchdog { + private var samples: [Int: [Double]] = [:] // pid → recent cpu%, oldest→newest + private var fired: Set = [] // pids already actioned (dedup until they calm) + private let cap = 64 + + /// Feed one process tick. Returns the processes that NEWLY fired the rule so + /// the caller can dispatch. The pump cadence is ~2s, so the rule's + /// sustain-seconds are converted to a sample count here. + func step(processes: [ProcessInfo], cadenceSeconds: Int) -> [(pid: Int, name: String)] { + guard Store.processWatchdogEnabled else { + if !samples.isEmpty { samples.removeAll() } + if !fired.isEmpty { fired.removeAll() } + return [] + } + let live = Set(processes.map { $0.pid }) + samples = samples.filter { live.contains($0.key) } // drop exited pids + fired = fired.intersection(live) + + let threshold = Store.processWatchdogCPU + let sustainSamples = max(1, Store.processWatchdogSeconds / max(1, cadenceSeconds)) + let rule = ProcessRule.Rule(metric: .cpu, threshold: threshold, + sustainSeconds: sustainSamples, action: action) + + var newlyFired: [(pid: Int, name: String)] = [] + for p in processes { + var buf = samples[p.pid] ?? [] + buf.append(p.cpu) + if buf.count > cap { buf.removeFirst(buf.count - cap) } + samples[p.pid] = buf + if p.cpu <= threshold { fired.remove(p.pid) } // re-arm once it calms down + guard !fired.contains(p.pid) else { continue } + if ProcessRule.fires(rule, samples: buf) { + fired.insert(p.pid) + newlyFired.append((pid: p.pid, name: p.name)) + } + } + return newlyFired + } + + /// Dispatch the configured action for a fired process. + func dispatch(pid: Int, name: String) { + let threshold = Store.processWatchdogCPU + switch action { + case .notify: + Self.notify(pid: pid, name: name, threshold: threshold) + case .suspend: + if ProcessActions.isOwnProcess(pid: pid) { ProcessActions.suspend(pid: pid) } + Self.notify(pid: pid, name: name, threshold: threshold, + suffix: NSLocalizedString("suspended", comment: "")) + case .quit: + if ProcessActions.isOwnProcess(pid: pid) { ProcessActions.quit(pid: pid) } + Self.notify(pid: pid, name: name, threshold: threshold, + suffix: NSLocalizedString("asked to quit", comment: "")) + } + } + + private var action: ProcessRule.Action { + ProcessRule.Action(rawValue: Store.processWatchdogAction) ?? .notify + } + + private static func notify(pid: Int, name: String, threshold: Double, suffix: String? = nil) { + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("High-CPU process", comment: "") + let base = String(format: NSLocalizedString("%@ stayed above %.0f%% CPU", comment: ""), name, threshold) + content.body = suffix.map { "\(base) — \($0)." } ?? "\(base)." + content.userInfo = ["pane": "status"] + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized, .provisional: + center.add(UNNotificationRequest(identifier: "burrow.watchdog.\(pid)", + content: content, trigger: nil)) + default: + break + } + } + } +} diff --git a/macos/Sources/StatusView.swift b/macos/Sources/StatusView.swift index 44e812c..c6942f7 100644 --- a/macos/Sources/StatusView.swift +++ b/macos/Sources/StatusView.swift @@ -1000,6 +1000,8 @@ final class StatusModel: ObservableObject { let db: DB private let live: LiveFeed private let feeds: FeedHub + /// Opt-in per-process CPU watchdog (PRD §α). Inert until enabled in Settings. + private let watchdog = ProcessWatchdog() init(db: DB, live: LiveFeed, feeds: FeedHub) { self.db = db @@ -1072,6 +1074,10 @@ final class StatusModel: ObservableObject { processes = v.processes energies = v.energies recomputeSortedRows() + // Opt-in watchdog: evaluate this tick, dispatch any new firings. + for f in watchdog.step(processes: v.processes, cadenceSeconds: 2) { + watchdog.dispatch(pid: f.pid, name: f.name) + } } } diff --git a/macos/Sources/Store.swift b/macos/Sources/Store.swift index 105afc2..d8bcbab 100644 --- a/macos/Sources/Store.swift +++ b/macos/Sources/Store.swift @@ -111,6 +111,30 @@ enum Store { set { write(newValue, "keep_awake_lid_closed") } } + // MARK: - Process watchdog (PRD §α) + + /// Auto-act on a process that stays over a CPU threshold. Off by default — + /// it can quit/suspend processes, so it's strictly opt-in. + static var processWatchdogEnabled: Bool { + get { d.object(forKey: "proc_watchdog_enabled") as? Bool ?? false } + set { write(newValue, "proc_watchdog_enabled") } + } + /// Sustained-CPU threshold, percent. + static var processWatchdogCPU: Double { + get { d.object(forKey: "proc_watchdog_cpu") as? Double ?? 90 } + set { write(newValue, "proc_watchdog_cpu") } + } + /// How long it must stay over threshold before the rule fires, seconds. + static var processWatchdogSeconds: Int { + get { d.object(forKey: "proc_watchdog_seconds") as? Int ?? 30 } + set { write(newValue, "proc_watchdog_seconds") } + } + /// "notify" | "suspend" | "quit" (ProcessRule.Action raw value). + static var processWatchdogAction: String { + get { d.string(forKey: "proc_watchdog_action") ?? "notify" } + set { write(newValue, "proc_watchdog_action") } + } + // MARK: - Menu bar /// Whether to install the menu-bar status item (issue #4). On by From 32bb3b0833f22d139bc5148d5333693779219525 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:30:36 -0700 Subject: [PATCH 24/33] =?UTF-8?q?feat(settings):=20process=20watchdog=20ed?= =?UTF-8?q?itor=20(epic=20=CE=B1,=20slice=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications section gains the High-CPU process watchdog: enable toggle, CPU threshold + sustain-seconds steppers, and a notify/suspend/quit action picker — all persisted to the Store keys the ProcessWatchdog engine reads. Off by default. Completes the epic α Process Inspector watchdog. (PRD §α) --- macos/Sources/SettingsView.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/macos/Sources/SettingsView.swift b/macos/Sources/SettingsView.swift index 00b06c0..f818746 100644 --- a/macos/Sources/SettingsView.swift +++ b/macos/Sources/SettingsView.swift @@ -60,6 +60,10 @@ struct SettingsView: View { @State private var thresholdAlerts: Bool = Store.thresholdAlertsEnabled @State private var cpuAlertThreshold: Int = Store.cpuAlertThreshold @State private var memAlertThreshold: Int = Store.memAlertThreshold + @State private var processWatchdog: Bool = Store.processWatchdogEnabled + @State private var procWatchdogCPU: Int = Int(Store.processWatchdogCPU) + @State private var procWatchdogSeconds: Int = Store.processWatchdogSeconds + @State private var procWatchdogAction: String = Store.processWatchdogAction @State private var showRestore = false @State private var brewBusy = false @State private var brewSnapshotStatus = "" @@ -283,6 +287,30 @@ struct SettingsView: View { } .onChange(of: memAlertThreshold) { _, v in Store.memAlertThreshold = v } } + toggleRow("High-CPU process watchdog", isOn: $processWatchdog) { Store.processWatchdogEnabled = $0 } + footnote("Acts automatically on a process that stays pegged. Off by default — Suspend and Quit only affect your own processes; Notify just warns you.") + if processWatchdog { + Stepper(value: $procWatchdogCPU, in: 50...100, step: 5) { + Text(String(format: NSLocalizedString("When a process stays above %d%% CPU", comment: ""), procWatchdogCPU)) + .font(Brand.sans(12)).foregroundStyle(Brand.textSecondary) + } + .onChange(of: procWatchdogCPU) { _, v in Store.processWatchdogCPU = Double(v) } + Stepper(value: $procWatchdogSeconds, in: 10...300, step: 10) { + Text(String(format: NSLocalizedString("for at least %d seconds", comment: ""), procWatchdogSeconds)) + .font(Brand.sans(12)).foregroundStyle(Brand.textSecondary) + } + .onChange(of: procWatchdogSeconds) { _, v in Store.processWatchdogSeconds = v } + Picker(selection: $procWatchdogAction) { + Text(NSLocalizedString("Notify me", comment: "")).tag("notify") + Text(NSLocalizedString("Suspend it", comment: "")).tag("suspend") + Text(NSLocalizedString("Quit it", comment: "")).tag("quit") + } label: { + Text(NSLocalizedString("Then", comment: "")) + .font(Brand.sans(12)).foregroundStyle(Brand.textSecondary) + } + .pickerStyle(.menu) + .onChange(of: procWatchdogAction) { _, v in Store.processWatchdogAction = v } + } } section("About", "info.circle") { From ca97d98270651bacbf37771a79735f699edc1347 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:10:25 -0700 Subject: [PATCH 25/33] =?UTF-8?q?feat(get-online):=20venue-specific=20capt?= =?UTF-8?q?ive-portal=20tips=20(epic=20=CE=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get Online recognises the current Wi-Fi SSID (CoreWLAN, off-main, gated behind Location like macOS requires — hidden when unavailable) and, when it matches a known hotel/airline portal, shows that venue's tips card above the fixes (VenueMatcher, pure + tested). (PRD §β) --- macos/Sources/ConnectivityView.swift | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/macos/Sources/ConnectivityView.swift b/macos/Sources/ConnectivityView.swift index 1a0b673..8d829f9 100644 --- a/macos/Sources/ConnectivityView.swift +++ b/macos/Sources/ConnectivityView.swift @@ -14,6 +14,7 @@ import SwiftUI import AppKit +import CoreWLAN struct ConnectivityView: View { var isActive: Bool = true @@ -24,6 +25,8 @@ struct ConnectivityView: View { @State private var loaded = false @State private var actionBusy: Connectivity.Fix? @State private var actionResult: String? + /// Venue-specific captive-portal tips when the SSID is recognised (PRD §β). + @State private var venue: VenueMatcher.Venue? private var accent: Color { Tool.status.accent } @@ -31,6 +34,7 @@ struct ConnectivityView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { header + if let venue { venueCard(venue) } actionsCard ForEach(checks) { checkRow($0) } if loaded, checks.isEmpty { @@ -118,6 +122,30 @@ struct ConnectivityView: View { } } + /// Venue-specific captive-portal tips when the SSID is recognised (PRD §β). + private func venueCard(_ v: VenueMatcher.Venue) -> some View { + GlassCard { + VStack(alignment: .leading, spacing: 10) { + Eyebrow(text: v.name, glyph: "mappin.and.ellipse", color: accent) + ForEach(Array(v.tips.enumerated()), id: \.offset) { _, tip in + HStack(alignment: .top, spacing: 7) { + Image(systemName: "lightbulb") + .font(.system(size: 11)).foregroundStyle(accent) + Text(tip).font(Brand.sans(12)).foregroundStyle(Brand.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + /// Current Wi-Fi SSID. nil when not on Wi-Fi, or when Location access (which + /// macOS now gates the SSID behind) hasn't been granted — the venue card just + /// stays hidden. Off-main: CoreWLAN reads can block. + private static func currentSSID() -> String? { + CWWiFiClient.shared().interface()?.ssid() + } + private func checkRow(_ c: Connectivity.Check) -> some View { GlassCard { HStack(alignment: .top, spacing: 12) { @@ -180,5 +208,10 @@ struct ConnectivityView: View { loading = false loaded = true } + // Recognise the venue from the SSID, off-main (CoreWLAN can block). + Task.detached(priority: .utility) { + let v = ConnectivityView.currentSSID().flatMap { VenueMatcher.match(ssid: $0) } + await MainActor.run { venue = v } + } } } From 697c0c465d8fcb51e3d505fb461cb14e7e1e9fd6 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:15:56 -0700 Subject: [PATCH 26/33] =?UTF-8?q?feat(get-online):=20nearby=20Wi-Fi=20scan?= =?UTF-8?q?=20with=20channel=20congestion=20(epic=20=CE=B2=20Home=20mode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-initiated CoreWLAN scan lists the strongest nearby networks with their channel and security, flagging congested channels (NearbyNetworks.byStrength + congestedChannels, pure + tested). Scan is opt-in (it briefly disrupts the link) and degrades to a Location-permission hint when unavailable. (PRD §β) --- macos/Sources/ConnectivityView.swift | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/macos/Sources/ConnectivityView.swift b/macos/Sources/ConnectivityView.swift index 8d829f9..1c80621 100644 --- a/macos/Sources/ConnectivityView.swift +++ b/macos/Sources/ConnectivityView.swift @@ -27,6 +27,11 @@ struct ConnectivityView: View { @State private var actionResult: String? /// Venue-specific captive-portal tips when the SSID is recognised (PRD §β). @State private var venue: VenueMatcher.Venue? + /// On-demand nearby-Wi-Fi scan (PRD §β Home mode): strongest networks + + /// congested channels. User-initiated — a scan briefly disrupts the link. + @State private var nearby: [NearbyNetworks.Net] = [] + @State private var scanning = false + @State private var scanned = false private var accent: Color { Tool.status.accent } @@ -41,6 +46,7 @@ struct ConnectivityView: View { Text(NSLocalizedString("Couldn't run the checks.", comment: "")) .font(Brand.sans(13)).foregroundStyle(Brand.textSecondary) } + nearbyCard } .padding(20) .frame(maxWidth: .infinity, alignment: .leading) @@ -146,6 +152,78 @@ struct ConnectivityView: View { CWWiFiClient.shared().interface()?.ssid() } + /// Nearby-Wi-Fi card (PRD §β Home mode): strongest networks with their + /// channel, plus a "busy ch" marker for congested channels. + private var nearbyCard: some View { + GlassCard { + VStack(alignment: .leading, spacing: 10) { + HStack { + Eyebrow(text: "Nearby Wi-Fi", glyph: "dot.radiowaves.left.and.right", color: accent) + Spacer() + if scanning { + ProgressView().controlSize(.small).tint(accent) + } else { + Button(scanned ? NSLocalizedString("Rescan", comment: "") + : NSLocalizedString("Scan", comment: "")) { scanNearby() } + .buttonStyle(.plain).font(Brand.sans(11, .semibold)).foregroundStyle(accent) + } + } + if !nearby.isEmpty { + let congested = NearbyNetworks.congestedChannels(nearby) + let strongest = Array(NearbyNetworks.byStrength(nearby).prefix(8)) + ForEach(Array(strongest.enumerated()), id: \.offset) { _, n in + HStack(spacing: 8) { + Image(systemName: n.security == "Open" ? "lock.open" : "lock") + .font(.system(size: 9)).foregroundStyle(Brand.textTertiary) + Text(n.ssid).font(Brand.sans(12)).foregroundStyle(Brand.textPrimary).lineLimit(1) + Spacer(minLength: 8) + if congested.contains(n.channel) { + Text(NSLocalizedString("busy", comment: "")) + .font(Brand.mono(9)).foregroundStyle(Brand.amber) + } + Text(verbatim: "ch \(n.channel)").font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + Text(verbatim: "\(n.rssi) dBm").font(Brand.mono(10)) + .foregroundStyle(Brand.textSecondary).frame(width: 64, alignment: .trailing) + } + } + } else if scanned, !scanning { + Text(NSLocalizedString("No networks found — scanning needs Location access (System Settings ▸ Privacy & Security ▸ Location).", comment: "")) + .font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } else if !scanned { + Text(NSLocalizedString("See which channels are crowded near you.", comment: "")) + .font(Brand.sans(12)).foregroundStyle(Brand.textSecondary) + } + } + } + } + + private func scanNearby() { + scanning = true + Task.detached(priority: .utility) { + let nets = ConnectivityView.scanNearbyNetworks() + await MainActor.run { + nearby = nets + scanning = false + scanned = true + } + } + } + + /// Active scan via CoreWLAN. Throws/empty without Location access or off + /// Wi-Fi → the card shows the permission hint. A scan briefly disrupts the + /// link, so it's only ever user-initiated. + private static func scanNearbyNetworks() -> [NearbyNetworks.Net] { + guard let iface = CWWiFiClient.shared().interface() else { return [] } + let nets = (try? iface.scanForNetworks(withSSID: nil)) ?? [] + return nets.compactMap { n in + guard let ssid = n.ssid, !ssid.isEmpty else { return nil } + return NearbyNetworks.Net(ssid: ssid, rssi: n.rssiValue, + channel: n.wlanChannel?.channelNumber ?? 0, + security: n.supportsSecurity(.none) ? "Open" : "Secured") + } + } + private func checkRow(_ c: Connectivity.Check) -> some View { GlassCard { HStack(alignment: .top, spacing: 12) { From 61d494db238e125dccff78080801f4b201cc1568 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:21:28 -0700 Subject: [PATCH 27/33] =?UTF-8?q?feat(get-online):=20on-demand=20speed=20t?= =?UTF-8?q?est=20(epic=20=CE=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get Online gains a Test Speed card: a few timed Cloudflare downloads give per-second byte rates and small round trips give latency, aggregated into Mbps / jitter / loss by the tested SpeedTest module. User-initiated (it transfers data) and degrades to idle copy when offline. (PRD §β) --- macos/Sources/ConnectivityView.swift | 83 ++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/macos/Sources/ConnectivityView.swift b/macos/Sources/ConnectivityView.swift index 1c80621..5d514d7 100644 --- a/macos/Sources/ConnectivityView.swift +++ b/macos/Sources/ConnectivityView.swift @@ -32,6 +32,10 @@ struct ConnectivityView: View { @State private var nearby: [NearbyNetworks.Net] = [] @State private var scanning = false @State private var scanned = false + /// On-demand throughput + latency test (PRD §β). User-initiated — it + /// transfers data to/from Cloudflare's public speed endpoint. + @State private var speed: SpeedTest.Result? + @State private var speedTesting = false private var accent: Color { Tool.status.accent } @@ -46,6 +50,7 @@ struct ConnectivityView: View { Text(NSLocalizedString("Couldn't run the checks.", comment: "")) .font(Brand.sans(13)).foregroundStyle(Brand.textSecondary) } + speedCard nearbyCard } .padding(20) @@ -198,6 +203,84 @@ struct ConnectivityView: View { } } + /// Speed-test card (PRD §β): throughput + latency, on demand. + private var speedCard: some View { + GlassCard { + VStack(alignment: .leading, spacing: 10) { + HStack { + Eyebrow(text: "Speed Test", glyph: "speedometer", color: accent) + Spacer() + if speedTesting { + ProgressView().controlSize(.small).tint(accent) + } else { + Button(speed == nil ? NSLocalizedString("Test", comment: "") + : NSLocalizedString("Retest", comment: "")) { runSpeedTest() } + .buttonStyle(.plain).font(Brand.sans(11, .semibold)).foregroundStyle(accent) + } + } + if let speed { + HStack(spacing: 20) { + metric(String(format: "%.0f", speed.mbps), NSLocalizedString("Mbps down", comment: "")) + metric(String(format: "%.0f ms", speed.jitterMs), NSLocalizedString("jitter", comment: "")) + metric(String(format: "%.0f%%", speed.lossPercent), NSLocalizedString("loss", comment: "")) + } + } else if speedTesting { + Text(NSLocalizedString("Measuring…", comment: "")) + .font(Brand.mono(11)).foregroundStyle(Brand.textTertiary) + } else { + Text(NSLocalizedString("Measures download throughput and latency against Cloudflare.", comment: "")) + .font(Brand.sans(12)).foregroundStyle(Brand.textSecondary) + } + } + } + } + + private func metric(_ value: String, _ label: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(value).font(Brand.mono(16, .medium)).foregroundStyle(Brand.textPrimary) + Text(label).font(Brand.mono(9)).foregroundStyle(Brand.textTertiary) + } + } + + private func runSpeedTest() { + speedTesting = true + Task.detached(priority: .userInitiated) { + let r = await ConnectivityView.measureSpeed() + await MainActor.run { + speed = r + speedTesting = false + } + } + } + + /// Throughput from a few timed downloads (per-second byte rates) + latency + /// from a few small-payload round trips, aggregated by the tested SpeedTest + /// module. Cloudflare's `__down` endpoint is public + CORS-open. nil on + /// total failure (offline) → the card keeps its idle copy. + private static func measureSpeed() async -> SpeedTest.Result? { + let down = URL(string: "https://speed.cloudflare.com/__down?bytes=8000000")! + let ping = URL(string: "https://speed.cloudflare.com/__down?bytes=1")! + var rates: [Int64] = [] + for _ in 0..<3 { + let start = Date() + guard let (data, _) = try? await URLSession.shared.data(from: down) else { continue } + let elapsed = Date().timeIntervalSince(start) + guard elapsed > 0 else { continue } + rates.append(Int64(Double(data.count) / elapsed)) // bytes per second + } + var lats: [Double?] = [] + for _ in 0..<5 { + let start = Date() + if (try? await URLSession.shared.data(from: ping)) != nil { + lats.append(Date().timeIntervalSince(start) * 1000) + } else { + lats.append(nil) + } + } + guard !rates.isEmpty else { return nil } + return SpeedTest.aggregate(byteSamples: rates, latenciesMs: lats) + } + private func scanNearby() { scanning = true Task.detached(priority: .utility) { From 087febecd35d9e7a2bc1d3c302eae2eccf6662a1 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:29:21 -0700 Subject: [PATCH 28/33] =?UTF-8?q?feat(get-online):=20connection=20history?= =?UTF-8?q?=20log=20(epic=20=CE=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Get-Online check records the network + classified outcome (ConnectionFailureClassifier: online/portal/offline) into a persisted, capped, repeat-collapsing log (ConnectionHistory — pure core + tested), shown as a Recent card. Folds venue + history SSID reads into one off-main pass. Completes the epic β Get Online companion. (PRD §β) --- macos/Sources/ConnectionHistory.swift | 53 ++++++++++++++++++++ macos/Sources/ConnectivityView.swift | 64 +++++++++++++++++++++--- macos/Tests/ConnectionHistoryTests.swift | 39 +++++++++++++++ 3 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 macos/Sources/ConnectionHistory.swift create mode 100644 macos/Tests/ConnectionHistoryTests.swift diff --git a/macos/Sources/ConnectionHistory.swift b/macos/Sources/ConnectionHistory.swift new file mode 100644 index 0000000..6f2bc7a --- /dev/null +++ b/macos/Sources/ConnectionHistory.swift @@ -0,0 +1,53 @@ +// +// ConnectionHistory.swift +// Burrow +// +// Persisted log of Get-Online attempts (PRD §β): when, which network, and the +// classified outcome (ConnectionFailureClassifier). The append/cap/collapse +// core is pure + tested; the UserDefaults read/write is the seam. +// + +import Foundation + +enum ConnectionHistory { + struct Entry: Codable, Equatable { + let at: Date + let ssid: String? + let reason: String // ConnectionFailureClassifier.Reason.rawValue + } + + static let cap = 50 + + /// Append newest-first, capped. A repeat of the most-recent (ssid, reason) + /// just refreshes that row's timestamp instead of growing the log — so + /// re-checking the same network doesn't spam identical rows. + static func appended(_ list: [Entry], _ e: Entry) -> [Entry] { + var out = list + if let first = out.first, first.ssid == e.ssid, first.reason == e.reason { + out[0] = e + } else { + out.insert(e, at: 0) + } + if out.count > cap { out = Array(out.prefix(cap)) } + return out + } + + // MARK: - Store (UserDefaults JSON) + + private static let key = "connection_history_v1" + + static func load() -> [Entry] { + guard let data = UserDefaults.standard.data(forKey: key), + let list = try? JSONDecoder().decode([Entry].self, from: data) else { return [] } + return list + } + + @discardableResult + static func record(ssid: String?, reason: String, at: Date) -> [Entry] { + let list = appended(load(), Entry(at: at, ssid: ssid, reason: reason)) + if let data = try? JSONEncoder().encode(list) { + UserDefaults.standard.set(data, forKey: key) + } + return list + } +} diff --git a/macos/Sources/ConnectivityView.swift b/macos/Sources/ConnectivityView.swift index 5d514d7..faba95b 100644 --- a/macos/Sources/ConnectivityView.swift +++ b/macos/Sources/ConnectivityView.swift @@ -36,6 +36,8 @@ struct ConnectivityView: View { /// transfers data to/from Cloudflare's public speed endpoint. @State private var speed: SpeedTest.Result? @State private var speedTesting = false + /// Persisted log of recent attempts (PRD §β). + @State private var history: [ConnectionHistory.Entry] = [] private var accent: Color { Tool.status.accent } @@ -52,12 +54,16 @@ struct ConnectivityView: View { } speedCard nearbyCard + historyCard } .padding(20) .frame(maxWidth: .infinity, alignment: .leading) } .scrollIndicators(.hidden) - .onAppear { if isActive, !loaded { reload() } } + .onAppear { + if history.isEmpty { history = ConnectionHistory.load() } + if isActive, !loaded { reload() } + } .onChange(of: isActive) { _, now in if now, !loaded { reload() } } } @@ -281,6 +287,50 @@ struct ConnectivityView: View { return SpeedTest.aggregate(byteSamples: rates, latenciesMs: lats) } + /// Recent-attempts log (PRD §β): the last few Get-Online checks with their + /// network and classified outcome. + @ViewBuilder private var historyCard: some View { + if !history.isEmpty { + GlassCard { + VStack(alignment: .leading, spacing: 8) { + Eyebrow(text: "Recent", glyph: "clock.arrow.circlepath", color: accent) + ForEach(Array(history.prefix(5).enumerated()), id: \.offset) { _, h in + HStack(spacing: 8) { + Circle().fill(Self.reasonColor(h.reason)).frame(width: 6, height: 6) + Text(h.ssid ?? NSLocalizedString("Wi-Fi", comment: "")) + .font(Brand.sans(12)).foregroundStyle(Brand.textPrimary).lineLimit(1) + Spacer(minLength: 8) + Text(Self.reasonLabel(h.reason)).font(Brand.mono(10)).foregroundStyle(Brand.textSecondary) + Text(Self.relative(h.at)).font(Brand.mono(9)) + .foregroundStyle(Brand.textTertiary).frame(width: 56, alignment: .trailing) + } + } + } + } + } + } + + private static func reasonLabel(_ r: String) -> String { + switch r { + case "ok": return NSLocalizedString("online", comment: "") + case "captivePortal": return NSLocalizedString("portal", comment: "") + case "loginUnreachable": return NSLocalizedString("login down", comment: "") + default: return NSLocalizedString("offline", comment: "") + } + } + private static func reasonColor(_ r: String) -> Color { + switch r { + case "ok": return Brand.green + case "captivePortal": return Brand.amber + default: return Brand.red + } + } + private static func relative(_ d: Date) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f.localizedString(for: d, relativeTo: Date()) + } + private func scanNearby() { scanning = true Task.detached(priority: .utility) { @@ -368,11 +418,13 @@ struct ConnectivityView: View { iface = result.interface loading = false loaded = true - } - // Recognise the venue from the SSID, off-main (CoreWLAN can block). - Task.detached(priority: .utility) { - let v = ConnectivityView.currentSSID().flatMap { VenueMatcher.match(ssid: $0) } - await MainActor.run { venue = v } + // Venue + history both need the SSID — read it once, off-main. + let ssid = await Task.detached(priority: .utility) { ConnectivityView.currentSSID() }.value + venue = ssid.flatMap { VenueMatcher.match(ssid: $0) } + let online = result.checks.contains { $0.id == "internet" && $0.status == .ok } + let portal = result.checks.contains { $0.id == "portal" } + let reason = ConnectionFailureClassifier.classify(online: online, portal: portal, loginReachable: portal) + history = ConnectionHistory.record(ssid: ssid, reason: reason.rawValue, at: Date()) } } } diff --git a/macos/Tests/ConnectionHistoryTests.swift b/macos/Tests/ConnectionHistoryTests.swift new file mode 100644 index 0000000..9f067e7 --- /dev/null +++ b/macos/Tests/ConnectionHistoryTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import Burrow + +final class ConnectionHistoryTests: XCTestCase { + private func e(_ ssid: String?, _ reason: String, _ t: TimeInterval) -> ConnectionHistory.Entry { + ConnectionHistory.Entry(at: Date(timeIntervalSince1970: t), ssid: ssid, reason: reason) + } + + func testAppend_newestFirst() { + var list: [ConnectionHistory.Entry] = [] + list = ConnectionHistory.appended(list, e("Home", "ok", 1)) + list = ConnectionHistory.appended(list, e("Cafe", "captivePortal", 2)) + XCTAssertEqual(list.map(\.ssid), ["Cafe", "Home"]) + } + + func testAppend_collapsesConsecutiveRepeat() { + var list: [ConnectionHistory.Entry] = [] + list = ConnectionHistory.appended(list, e("Home", "ok", 1)) + list = ConnectionHistory.appended(list, e("Home", "ok", 5)) + XCTAssertEqual(list.count, 1, "same ssid+reason collapses") + XCTAssertEqual(list.first?.at, Date(timeIntervalSince1970: 5), "timestamp refreshed") + } + + func testAppend_doesNotCollapseWhenReasonChanges() { + var list: [ConnectionHistory.Entry] = [] + list = ConnectionHistory.appended(list, e("Home", "ok", 1)) + list = ConnectionHistory.appended(list, e("Home", "noInternet", 2)) + XCTAssertEqual(list.count, 2) + } + + func testAppend_capsAtMaximum() { + var list: [ConnectionHistory.Entry] = [] + for i in 0..<(ConnectionHistory.cap + 10) { + list = ConnectionHistory.appended(list, e("net\(i)", "ok", Double(i))) + } + XCTAssertEqual(list.count, ConnectionHistory.cap) + XCTAssertEqual(list.first?.ssid, "net\(ConnectionHistory.cap + 9)", "newest kept") + } +} From 1a46094aefece67cd4e56c21b299d8bd1b16c034 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:37:38 -0700 Subject: [PATCH 29/33] feat(doctor): display / external-volume / network context (story 47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three optional context checks — display count, mounted external-volume count, and the primary network interface (reused Connectivity.defaultRoute) — appended only when their facts are present, so a shared diagnostics report carries the details a maintainer needs. Omitted-when-absent keeps the count==7 test + MCP seam valid. (PRD §Doctor) --- macos/Sources/Doctor.swift | 23 +++++++++++++++++++++++ macos/Sources/DoctorView.swift | 20 ++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Doctor.swift b/macos/Sources/Doctor.swift index 4560673..2264cec 100644 --- a/macos/Sources/Doctor.swift +++ b/macos/Sources/Doctor.swift @@ -43,6 +43,10 @@ enum Doctor { var batteryHealthPct: Int? = nil /// System-wide CPU load %; nil = unknown → omitted. var cpuLoadPercent: Double? = nil + // Shared-report context (PRD §Doctor story 47). nil = unknown → omitted. + var displayCount: Int? = nil + var externalVolumeCount: Int? = nil + var networkInterface: String? = nil } /// One `Check` per facet, in a stable order. Each verdict is independent; @@ -53,10 +57,29 @@ enum Doctor { if let s = security(i) { checks.append(s) } if let b = battery(i) { checks.append(b) } if let c = highCPU(i) { checks.append(c) } + if let d = displays(i) { checks.append(d) } + if let v = volumes(i) { checks.append(v) } + if let n = network(i) { checks.append(n) } checks.append(errors(i)) return checks } + private static func displays(_ i: Input) -> Check? { + guard let n = i.displayCount else { return nil } + return Check(name: "Displays", level: .ok, detail: n == 1 ? "1 display" : "\(n) displays") + } + + private static func volumes(_ i: Input) -> Check? { + guard let n = i.externalVolumeCount else { return nil } + return Check(name: "External volumes", level: .ok, + detail: n == 0 ? "none mounted" : "\(n) mounted") + } + + private static func network(_ i: Input) -> Check? { + guard let iface = i.networkInterface, !iface.isEmpty else { return nil } + return Check(name: "Network", level: .ok, detail: "primary interface: \(iface)") + } + private static func security(_ i: Input) -> Check? { let facets: [(String, SecurityPosture.State)] = [ ("SIP", i.sip), ("Gatekeeper", i.gatekeeper), ("FileVault", i.fileVault), ("Firewall", i.firewall)] diff --git a/macos/Sources/DoctorView.swift b/macos/Sources/DoctorView.swift index e9e3c72..ecbde72 100644 --- a/macos/Sources/DoctorView.swift +++ b/macos/Sources/DoctorView.swift @@ -124,13 +124,16 @@ struct DoctorView: View { // detector. Probe off the main thread, then publish on the main actor. let cpuLoad = latest?.cpu.usage let battHealth: Int? = (latest?.batteries?.first?.capacity).flatMap { $0 > 0 ? $0 : nil } + let displays = NSScreen.screens.count // main-actor; reload is @MainActor let probes = await Task.detached(priority: .utility) { (backup: BackupStatus.lastBackupDaysAgo(), smart: DiskHealth.smartVerified(), sip: SecurityPosture.sip(DoctorView.run("/usr/bin/csrutil", ["status"])), gatekeeper: SecurityPosture.gatekeeper(DoctorView.run("/usr/sbin/spctl", ["--status"])), fileVault: SecurityPosture.fileVault(DoctorView.run("/usr/bin/fdesetup", ["status"])), - firewall: SecurityPosture.firewall(DoctorView.run("/usr/libexec/ApplicationFirewall/socketfilterfw", ["--getglobalstate"]))) + firewall: SecurityPosture.firewall(DoctorView.run("/usr/libexec/ApplicationFirewall/socketfilterfw", ["--getglobalstate"])), + volumes: DoctorView.externalVolumeCount(), + iface: Connectivity.defaultRoute(fromRouteGet: DoctorView.run("/sbin/route", ["-n", "get", "default"])).interface) }.value checks = Doctor.report(.init( fullDiskAccess: fullDiskAccess, @@ -142,7 +145,20 @@ struct DoctorView: View { sip: probes.sip, gatekeeper: probes.gatekeeper, fileVault: probes.fileVault, firewall: probes.firewall, batteryHealthPct: battHealth, - cpuLoadPercent: cpuLoad)) + cpuLoadPercent: cpuLoad, + displayCount: displays, + externalVolumeCount: probes.volumes, + networkInterface: probes.iface)) + } + + /// Count of mounted non-internal (external/removable) volumes, for the + /// Doctor context line. Off-main (FileManager volume reads can touch disk). + private static func externalVolumeCount() -> Int { + let vols = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: [.volumeIsInternalKey], + options: [.skipHiddenVolumes]) ?? [] + return vols.filter { + (try? $0.resourceValues(forKeys: [.volumeIsInternalKey]).volumeIsInternal) == false + }.count } /// Capture a short system command's stdout (off-main; used for the security From cd073e351445b84b9e000df2950fd90f8c934c6f Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:40:22 -0700 Subject: [PATCH 30/33] =?UTF-8?q?feat(status):=20deep=20per-process=20metr?= =?UTF-8?q?ics=20in=20the=20inspector=20(epic=20=CE=B1=2056)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessDeepMetrics reads threads, memory footprint + lifetime peak, page-ins, user/sys CPU split, and per-process disk I/O via proc_pid_rusage(v4) + proc_pidinfo; the inspector now shows Memory, Threads, Disk I/O and CPU Time rows. Syscall is the seam, the user/sys split helper is pure + tested. (PRD §α) --- macos/Sources/ProcessDeepMetrics.swift | 57 +++++++++++++++++++++++ macos/Sources/ProcessInspectorView.swift | 20 ++++++++ macos/Tests/ProcessDeepMetricsTests.swift | 16 +++++++ 3 files changed, 93 insertions(+) create mode 100644 macos/Sources/ProcessDeepMetrics.swift create mode 100644 macos/Tests/ProcessDeepMetricsTests.swift diff --git a/macos/Sources/ProcessDeepMetrics.swift b/macos/Sources/ProcessDeepMetrics.swift new file mode 100644 index 0000000..b228f34 --- /dev/null +++ b/macos/Sources/ProcessDeepMetrics.swift @@ -0,0 +1,57 @@ +// +// ProcessDeepMetrics.swift +// Burrow +// +// Per-process deep metrics for the inspector (PRD §α 56): threads, memory +// footprint + lifetime peak, page-ins, user/sys CPU split, and per-process +// disk I/O — from proc_pid_rusage (flavor 4) + proc_pidinfo. The syscalls are +// the seam; the split/format helpers are pure + tested. +// + +import Foundation +import Darwin + +enum ProcessDeepMetrics { + struct Metrics: Equatable { + let threads: Int + let footprintBytes: Int64 + let peakFootprintBytes: Int64 + let pageIns: Int64 + let userSeconds: Double + let systemSeconds: Double + let diskReadBytes: Int64 + let diskWriteBytes: Int64 + } + + /// Live deep metrics for a pid. nil when the kernel won't report (permission + /// or exited). ri_*_time are nanoseconds. `proc_pidinfo` for the thread + /// count is best-effort (0 when unavailable). + static func read(pid: Int) -> Metrics? { + var usage = rusage_info_v4() + let r = withUnsafeMutablePointer(to: &usage) { + $0.withMemoryRebound(to: (rusage_info_t?).self, capacity: 1) { + proc_pid_rusage(Int32(pid), 4, $0) + } + } + guard r == 0 else { return nil } + var ti = proc_taskinfo() + let tiSize = Int32(MemoryLayout.size) + let got = proc_pidinfo(Int32(pid), PROC_PIDTASKINFO, 0, &ti, tiSize) + let threads = got == tiSize ? Int(ti.pti_threadnum) : 0 + return Metrics( + threads: threads, + footprintBytes: Int64(bitPattern: usage.ri_phys_footprint), + peakFootprintBytes: Int64(bitPattern: usage.ri_lifetime_max_phys_footprint), + pageIns: Int64(bitPattern: usage.ri_pageins), + userSeconds: Double(usage.ri_user_time) / 1_000_000_000, + systemSeconds: Double(usage.ri_system_time) / 1_000_000_000, + diskReadBytes: Int64(bitPattern: usage.ri_diskio_bytesread), + diskWriteBytes: Int64(bitPattern: usage.ri_diskio_byteswritten)) + } + + /// User share of CPU time, 0…1 — nil when the process has used no CPU yet. + static func userFraction(userSeconds: Double, systemSeconds: Double) -> Double? { + let total = userSeconds + systemSeconds + return total > 0 ? userSeconds / total : nil + } +} diff --git a/macos/Sources/ProcessInspectorView.swift b/macos/Sources/ProcessInspectorView.swift index d95be58..ec6cd79 100644 --- a/macos/Sources/ProcessInspectorView.swift +++ b/macos/Sources/ProcessInspectorView.swift @@ -29,6 +29,8 @@ struct ProcessInspectorView: View { /// Per-process bandwidth (nettop, ~1s) — measured on demand, off-main. @State private var net: NetUsage.Rates? @State private var measuringNet = true + /// Deep metrics (threads/memory/disk/CPU split) — fast syscall on appear. + @State private var deep: ProcessDeepMetrics.Metrics? private var table: [Int: ProcessOrigin.Info] { Dictionary(processes.map { ($0.pid, ProcessOrigin.Info(name: $0.name, ppid: $0.ppid ?? 0)) }, @@ -74,6 +76,14 @@ struct ProcessInspectorView: View { } field("CPU", String(format: "%.1f%%", proc.cpu), glyph: "cpu", tint: Brand.textSecondary) field("NETWORK", netText, glyph: "network", tint: Brand.textSecondary) + if let deep { + field("MEMORY", "\(Fmt.bytes(deep.footprintBytes)) · peak \(Fmt.bytes(deep.peakFootprintBytes))", + glyph: "memorychip", tint: Brand.textSecondary) + field("THREADS", "\(deep.threads)", glyph: "square.stack.3d.up", tint: Brand.textSecondary) + field("DISK I/O", "\(Fmt.bytes(deep.diskReadBytes)) read · \(Fmt.bytes(deep.diskWriteBytes)) written", + glyph: "internaldrive", tint: Brand.textSecondary) + field("CPU TIME", cpuTimeText(deep), glyph: "clock", tint: Brand.textSecondary) + } } HStack { @@ -90,12 +100,22 @@ struct ProcessInspectorView: View { .frame(width: 460) .task { let pid = proc.pid + deep = ProcessDeepMetrics.read(pid: pid) // fast syscall let r = await Task.detached(priority: .utility) { NetUsage.sample()[pid] }.value net = r measuringNet = false } } + private func cpuTimeText(_ d: ProcessDeepMetrics.Metrics) -> String { + let total = d.userSeconds + d.systemSeconds + guard let frac = ProcessDeepMetrics.userFraction(userSeconds: d.userSeconds, systemSeconds: d.systemSeconds) else { + return String(format: NSLocalizedString("%.1fs total", comment: ""), total) + } + return String(format: NSLocalizedString("%.1fs · %.0f%% user / %.0f%% system", comment: ""), + total, frac * 100, (1 - frac) * 100) + } + private var netText: String { if measuringNet { return NSLocalizedString("Measuring…", comment: "") } guard let net, net.down > 0 || net.up > 0 else { return NSLocalizedString("Idle", comment: "") } diff --git a/macos/Tests/ProcessDeepMetricsTests.swift b/macos/Tests/ProcessDeepMetricsTests.swift new file mode 100644 index 0000000..6f2b9ce --- /dev/null +++ b/macos/Tests/ProcessDeepMetricsTests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import Burrow + +final class ProcessDeepMetricsTests: XCTestCase { + func testUserFraction_split() { + XCTAssertEqual(ProcessDeepMetrics.userFraction(userSeconds: 3, systemSeconds: 1), 0.75) + } + + func testUserFraction_allUser() { + XCTAssertEqual(ProcessDeepMetrics.userFraction(userSeconds: 2, systemSeconds: 0), 1.0) + } + + func testUserFraction_noCPUYetIsNil() { + XCTAssertNil(ProcessDeepMetrics.userFraction(userSeconds: 0, systemSeconds: 0)) + } +} From 71d0eecac226bc023eb8543e6f89d850809a1fa4 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:46:03 -0700 Subject: [PATCH 31/33] feat(analyze): one-tap whole-disk scan (story 38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolbar gains a disk button that re-roots Analyze at / for system-wide usage (reachable by climbing Up too; this is the direct entry). Disabled once already at the root. (PRD §Analyze) --- macos/Sources/AnalyzeView.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/macos/Sources/AnalyzeView.swift b/macos/Sources/AnalyzeView.swift index 69a52c9..298b115 100644 --- a/macos/Sources/AnalyzeView.swift +++ b/macos/Sources/AnalyzeView.swift @@ -167,6 +167,12 @@ struct AnalyzeView: View { } Spacer() Text(model.usageLine).font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + Button { model.scanWholeDisk() } label: { + Image(systemName: "externaldrive").font(.system(size: 11, weight: .semibold)) + .foregroundStyle(model.crumbs.first?.path == "/" ? Brand.textTertiary.opacity(0.35) : Brand.textSecondary) + } + .buttonStyle(.plain).disabled(model.crumbs.first?.path == "/") + .help(NSLocalizedString("Scan the whole disk", comment: "")) Button { model.refresh() } label: { Image(systemName: "arrow.clockwise").font(.system(size: 11, weight: .semibold)) .foregroundStyle(Brand.textSecondary) @@ -374,6 +380,14 @@ final class AnalyzeModel: ObservableObject { scan(last.path, name: last.name, push: false, force: true) } + /// Re-root the scan at the whole disk (PRD §Analyze 38). Reachable by + /// climbing Up too, but this is the one-tap "show me everything" entry. + func scanWholeDisk() { + guard crumbs.first?.path != "/" else { return } + crumbs = [] + scan("/", name: "/", push: true) + } + /// Whether there's a parent to climb to (Home isn't the ceiling — you can /// go up to /Users, /, external volumes, …). False only at the filesystem root. var canGoUp: Bool { From 87e2b9e928f41b76d31ec78d3ccf642c5cbe7169 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:16:48 -0700 Subject: [PATCH 32/33] =?UTF-8?q?feat(status):=20redesign=20process=20insp?= =?UTF-8?q?ector=20as=20a=20structured=20panel=20(epic=20=CE=B1=2055/56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat row-sheet with a ProcessSpy-style panel in Burrow's identity: identity header + signing/arch/sandbox chips, then titled sections — Identity & Time, Process Details, Security, Resource Usage (with a user/sys split bar), Disk & Network, Hierarchy (children). Adds the missing native depth: CodeSignInfo (signer/team/hardened/sandbox via SecCode), MachOArch (Mach-O fat header → arch label, pure+tested), process runtime (ri_proc_start_abstime), and parent/children. (PRD §α) --- macos/Sources/CodeSignInfo.swift | 61 +++++ macos/Sources/MachOArch.swift | 71 +++++ macos/Sources/ProcessDeepMetrics.swift | 15 +- macos/Sources/ProcessInspectorView.swift | 326 ++++++++++++++++------- macos/Tests/MachOArchTests.swift | 29 ++ 5 files changed, 406 insertions(+), 96 deletions(-) create mode 100644 macos/Sources/CodeSignInfo.swift create mode 100644 macos/Sources/MachOArch.swift create mode 100644 macos/Tests/MachOArchTests.swift diff --git a/macos/Sources/CodeSignInfo.swift b/macos/Sources/CodeSignInfo.swift new file mode 100644 index 0000000..37d0e75 --- /dev/null +++ b/macos/Sources/CodeSignInfo.swift @@ -0,0 +1,61 @@ +// +// CodeSignInfo.swift +// Burrow +// +// Code-signing facts for the process inspector's Security section (PRD §α 55): +// signer, team id, hardened-runtime, app-sandbox, and signature validity — +// via the Security framework's SecCode APIs (unprivileged, by pid). All a +// syscall seam; there's no pure logic to unit-test here. +// + +import Foundation +import Security + +enum CodeSignInfo { + struct Info: Equatable { + let signer: String? // leaf-cert common name, else team id + let teamID: String? + let hardened: Bool + let sandboxed: Bool + let valid: Bool // signature validates against its designated requirement + } + + // SecCSFlags raw values (CSCommon.h) — used numerically to avoid the + // constants' uneven Swift import. + private static let kSigningInfo: UInt32 = 0x2 | 0x4 // signing + requirement information + private static let kRuntimeFlag: UInt32 = 0x1_0000 // hardened runtime (CS_RUNTIME) + + static func read(pid: Int) -> Info? { + let attrs = [kSecGuestAttributePid as String: NSNumber(value: pid)] as CFDictionary + var code: SecCode? + guard SecCodeCopyGuestWithAttributes(nil, attrs, [], &code) == errSecSuccess, let code else { return nil } + var stat: SecStaticCode? + guard SecCodeCopyStaticCode(code, [], &stat) == errSecSuccess, let stat else { return nil } + + let valid = SecStaticCodeCheckValidity(stat, [], nil) == errSecSuccess + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation(stat, SecCSFlags(rawValue: kSigningInfo), &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any] else { + return Info(signer: nil, teamID: nil, hardened: false, sandboxed: false, valid: valid) + } + + let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String + let csFlags = (info[kSecCodeInfoFlags as String] as? NSNumber)?.uint32Value ?? 0 + let hardened = (csFlags & kRuntimeFlag) != 0 + + var sandboxed = false + if let ent = info[kSecCodeInfoEntitlementsDict as String] as? [String: Any] { + sandboxed = (ent["com.apple.security.app-sandbox"] as? Bool) == true + } + + var signer: String? = teamID + if let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate], let leaf = certs.first { + var cn: CFString? + if SecCertificateCopyCommonName(leaf, &cn) == errSecSuccess, let name = cn as String? { + signer = name + } + } + return Info(signer: signer, teamID: teamID, hardened: hardened, sandboxed: sandboxed, valid: valid) + } +} diff --git a/macos/Sources/MachOArch.swift b/macos/Sources/MachOArch.swift new file mode 100644 index 0000000..3c967d0 --- /dev/null +++ b/macos/Sources/MachOArch.swift @@ -0,0 +1,71 @@ +// +// MachOArch.swift +// Burrow +// +// Reads the architecture slices of a Mach-O executable for the process +// inspector's "Format" line (PRD §α 55: native-vs-Rosetta / universal). Pure +// header parsing — feed the first bytes of the file; the file read is the seam. +// + +import Foundation + +enum MachOArch { + // cputype values (cpu.h): high bit 0x01000000 = 64-bit ABI. + private static let names: [UInt32: String] = [ + 0x0100_000C: "arm64", // CPU_TYPE_ARM64 + 0x0100_0007: "x86_64", // CPU_TYPE_X86_64 + 0x0000_0007: "i386", + 0x0000_000C: "arm", + ] + + /// Arch slice names from a Mach-O / fat header. Empty if not a Mach-O. + /// Handles thin (MH_MAGIC[_64], both endians) and fat (FAT_MAGIC[_64]). + static func archs(fromHeader b: [UInt8]) -> [String] { + guard b.count >= 8 else { return [] } + let m = be32(b, 0) + switch m { + case 0xFEED_FACE, 0xFEED_FACF: // thin, big-endian fields + return names[be32(b, 4)].map { [$0] } ?? ["unknown"] + case 0xCEFA_EDFE, 0xCFFA_EDFE: // thin, little-endian fields + return names[le32(b, 4)].map { [$0] } ?? ["unknown"] + case 0xCAFE_BABE, 0xCAFE_BABF: // fat (big-endian count + entries) + let count = Int(be32(b, 4)) + let is64 = m == 0xCAFE_BABF + let stride = is64 ? 32 : 20 + var out: [String] = [] + for i in 0.. String { + let known = archs.filter { $0 != "unknown" } + if known.isEmpty { return "" } + return known.count == 1 ? known[0] : "Universal (\(known.joined(separator: ", ")))" + } + + /// Read the leading bytes of the executable and resolve its arch label. + /// The file read is the seam; returns "" when unreadable. + static func label(path: String) -> String { + guard let h = FileHandle(forReadingAtPath: path) else { return "" } + defer { try? h.close() } + let data = (try? h.read(upToCount: 4096)) ?? Data() + return label(archs(fromHeader: [UInt8](data))) + } + + private static func be32(_ b: [UInt8], _ o: Int) -> UInt32 { + guard o + 4 <= b.count else { return 0 } + return UInt32(b[o]) << 24 | UInt32(b[o+1]) << 16 | UInt32(b[o+2]) << 8 | UInt32(b[o+3]) + } + private static func le32(_ b: [UInt8], _ o: Int) -> UInt32 { + guard o + 4 <= b.count else { return 0 } + return UInt32(b[o+3]) << 24 | UInt32(b[o+2]) << 16 | UInt32(b[o+1]) << 8 | UInt32(b[o]) + } +} diff --git a/macos/Sources/ProcessDeepMetrics.swift b/macos/Sources/ProcessDeepMetrics.swift index b228f34..d266a15 100644 --- a/macos/Sources/ProcessDeepMetrics.swift +++ b/macos/Sources/ProcessDeepMetrics.swift @@ -21,6 +21,8 @@ enum ProcessDeepMetrics { let systemSeconds: Double let diskReadBytes: Int64 let diskWriteBytes: Int64 + /// Wall-clock seconds since the process started (0 when unknown). + let runtimeSeconds: Double } /// Live deep metrics for a pid. nil when the kernel won't report (permission @@ -38,6 +40,16 @@ enum ProcessDeepMetrics { let tiSize = Int32(MemoryLayout.size) let got = proc_pidinfo(Int32(pid), PROC_PIDTASKINFO, 0, &ti, tiSize) let threads = got == tiSize ? Int(ti.pti_threadnum) : 0 + + // Runtime = (now − start) in mach units → seconds. + var tb = mach_timebase_info_data_t() + mach_timebase_info(&tb) + let now = mach_absolute_time() + let startAbs = usage.ri_proc_start_abstime + let runtime = (startAbs > 0 && now > startAbs && tb.denom > 0) + ? Double(now - startAbs) * Double(tb.numer) / Double(tb.denom) / 1_000_000_000 + : 0 + return Metrics( threads: threads, footprintBytes: Int64(bitPattern: usage.ri_phys_footprint), @@ -46,7 +58,8 @@ enum ProcessDeepMetrics { userSeconds: Double(usage.ri_user_time) / 1_000_000_000, systemSeconds: Double(usage.ri_system_time) / 1_000_000_000, diskReadBytes: Int64(bitPattern: usage.ri_diskio_bytesread), - diskWriteBytes: Int64(bitPattern: usage.ri_diskio_byteswritten)) + diskWriteBytes: Int64(bitPattern: usage.ri_diskio_byteswritten), + runtimeSeconds: runtime) } /// User share of CPU time, 0…1 — nil when the process has used no CPU yet. diff --git a/macos/Sources/ProcessInspectorView.swift b/macos/Sources/ProcessInspectorView.swift index ec6cd79..bbf97cd 100644 --- a/macos/Sources/ProcessInspectorView.swift +++ b/macos/Sources/ProcessInspectorView.swift @@ -2,13 +2,13 @@ // ProcessInspectorView.swift // Burrow // -// Per-process inspector (PRD §α Process Inspector): where the process was -// launched from (ProcessOrigin — a pure parent-chain walk), its executable -// path, and whether that binary still exists on disk (a "deleted/replaced -// since launch" signal — the cheap, honest part of BinaryIntegrity that needs -// no launch-inode read). Presented as a sheet from the process table's row -// menu. The classification logic is pure + tested (ProcessOriginTests); this -// view only renders it. +// Structured per-process inspector (PRD §α 55/56), modelled on ProcessSpy's +// panel but in Burrow's identity: an identity header with signing/arch/sandbox +// chips, then titled sections — Identity & Time, Process Details, Security, +// Resource Usage (with a user/sys split bar), Disk I/O, Network, Hierarchy. +// All facts come from unprivileged readers (ProcessOrigin, ProcessDeepMetrics, +// CodeSignInfo, MachOArch, NetUsage); each pure core is tested, the syscalls +// are the seam. // import SwiftUI @@ -23,118 +23,254 @@ struct ProcessInspectTarget: Identifiable { struct ProcessInspectorView: View { let proc: ProcessInfo - /// The current process set — the parent-chain map ProcessOrigin walks. + /// The current process set — parent/children + the ProcessOrigin chain. let processes: [ProcessInfo] @Environment(\.dismiss) private var dismiss - /// Per-process bandwidth (nettop, ~1s) — measured on demand, off-main. + @State private var net: NetUsage.Rates? @State private var measuringNet = true - /// Deep metrics (threads/memory/disk/CPU split) — fast syscall on appear. @State private var deep: ProcessDeepMetrics.Metrics? + @State private var sign: CodeSignInfo.Info? + @State private var arch = "" + @State private var path: String? + + private var accent: Color { Tool.status.accent } - private var table: [Int: ProcessOrigin.Info] { + private var originTable: [Int: ProcessOrigin.Info] { Dictionary(processes.map { ($0.pid, ProcessOrigin.Info(name: $0.name, ppid: $0.ppid ?? 0)) }, uniquingKeysWith: { a, _ in a }) } - private var origin: ProcessOrigin.Origin { ProcessOrigin.classify(pid: proc.pid, table: table) } - private var path: String? { ProcessActions.executablePath(pid: proc.pid) } - private var binaryMissing: Bool { - guard let path else { return false } - return !FileManager.default.fileExists(atPath: path) - } - private var parentName: String? { proc.ppid.flatMap { table[$0]?.name } } + private var origin: ProcessOrigin.Origin { ProcessOrigin.classify(pid: proc.pid, table: originTable) } + private var parent: ProcessInfo? { proc.ppid.flatMap { ppid in processes.first { $0.pid == ppid } } } + private var children: [ProcessInfo] { processes.filter { $0.ppid == proc.pid } } + private var binaryMissing: Bool { path.map { !FileManager.default.fileExists(atPath: $0) } ?? false } var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 10) { - AppIconView(proc: proc).frame(width: 28, height: 28) - VStack(alignment: .leading, spacing: 1) { - Text(proc.name).font(Brand.sans(15, .semibold)).foregroundStyle(Brand.textPrimary) - Text(verbatim: "PID \(proc.pid)").font(Brand.mono(11)).foregroundStyle(Brand.textTertiary) + VStack(spacing: 0) { + header + Rectangle().fill(Brand.hairline).frame(height: 1) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + identitySection + detailsSection + securitySection + resourceSection + ioNetworkSection + if !children.isEmpty { hierarchySection } } - Spacer() - Button { dismiss() } label: { - Image(systemName: "xmark.circle.fill").font(.system(size: 16)).foregroundStyle(Brand.textTertiary) - } - .buttonStyle(.plain) - .accessibilityLabel(NSLocalizedString("Close", comment: "")) + .padding(18) } + .scrollIndicators(.hidden) + } + .frame(width: 540, height: 620) + .task(id: proc.pid) { await load() } + } - VStack(alignment: .leading, spacing: 10) { - field("ORIGIN", originText, glyph: originGlyph, tint: originTint) - if let parentName { - field("PARENT", "\(parentName) (PID \(proc.ppid ?? 0))", - glyph: "arrow.up.right", tint: Brand.textSecondary) - } - field("PROGRAM", path ?? NSLocalizedString("Unknown", comment: ""), - glyph: binaryMissing ? "exclamationmark.triangle.fill" : "checkmark.seal", - tint: binaryMissing ? Brand.red : Brand.green, mono: true) - if binaryMissing { - Text(NSLocalizedString("The program file no longer exists on disk — it was deleted or replaced after this process started.", comment: "")) - .font(Brand.mono(10)).foregroundStyle(Brand.red.opacity(0.9)) - .fixedSize(horizontal: false, vertical: true) - } - field("CPU", String(format: "%.1f%%", proc.cpu), glyph: "cpu", tint: Brand.textSecondary) - field("NETWORK", netText, glyph: "network", tint: Brand.textSecondary) - if let deep { - field("MEMORY", "\(Fmt.bytes(deep.footprintBytes)) · peak \(Fmt.bytes(deep.peakFootprintBytes))", - glyph: "memorychip", tint: Brand.textSecondary) - field("THREADS", "\(deep.threads)", glyph: "square.stack.3d.up", tint: Brand.textSecondary) - field("DISK I/O", "\(Fmt.bytes(deep.diskReadBytes)) read · \(Fmt.bytes(deep.diskWriteBytes)) written", - glyph: "internaldrive", tint: Brand.textSecondary) - field("CPU TIME", cpuTimeText(deep), glyph: "clock", tint: Brand.textSecondary) + // MARK: Header + + private var header: some View { + HStack(spacing: 12) { + AppIconView(proc: proc).frame(width: 34, height: 34) + VStack(alignment: .leading, spacing: 2) { + Text(proc.name).font(Brand.serif(19, .medium)).foregroundStyle(Brand.textPrimary).lineLimit(1) + HStack(spacing: 6) { + Text(verbatim: "PID \(proc.pid)").font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + if let d = deep, d.runtimeSeconds > 0 { + Text(verbatim: "· up \(Fmt.uptime(UInt64(max(0, d.runtimeSeconds))))") + .font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + } } } + Spacer() + chips + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill").font(.system(size: 16)).foregroundStyle(Brand.textTertiary) + } + .buttonStyle(.plain).accessibilityLabel(NSLocalizedString("Close", comment: "")) + } + .padding(.horizontal, 18).padding(.vertical, 14) + } - HStack { - Spacer() - if let path { - Button(NSLocalizedString("Reveal in Finder", comment: "")) { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) - } - .buttonStyle(.plain).font(Brand.sans(12, .semibold)).foregroundStyle(Tool.status.accent) + @ViewBuilder private var chips: some View { + HStack(spacing: 5) { + if !arch.isEmpty { chip(arch, Brand.textSecondary) } + if let s = sign { + chip(s.valid ? NSLocalizedString("Signed", comment: "") : NSLocalizedString("Unsigned", comment: ""), + s.valid ? Brand.green : Brand.red) + if s.sandboxed { chip(NSLocalizedString("Sandboxed", comment: ""), Brand.blue) } + if s.hardened { chip(NSLocalizedString("Hardened", comment: ""), accent) } + } + } + } + + private func chip(_ text: String, _ color: Color) -> some View { + Text(text).font(Brand.mono(9, .medium)) + .foregroundStyle(color) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(Capsule().fill(color.opacity(0.14))) + } + + // MARK: Sections + + private var identitySection: some View { + section("Identity & Time", "clock") { + if let d = deep, d.runtimeSeconds > 0 { + kv("Started", Self.startedText(runtimeSeconds: d.runtimeSeconds)) + kv("Running", Fmt.uptime(UInt64(max(0, d.runtimeSeconds)))) + } + kv("Origin", originText, valueColor: originTint) + } + } + + private var detailsSection: some View { + section("Process Details", "doc.text") { + kvMono("Program", path ?? NSLocalizedString("Unknown", comment: ""), + valueColor: binaryMissing ? Brand.red : Brand.textPrimary) + if binaryMissing { + note(NSLocalizedString("Program file no longer exists — deleted or replaced after launch.", comment: ""), Brand.red) + } + if let p = parent { kv("Parent", "\(p.name) · \(p.pid)") } + kv("Command", proc.command, mono: true) + } + } + + private var securitySection: some View { + section("Security", "lock.shield") { + if let s = sign { + kv("Signature", s.signer ?? NSLocalizedString("ad-hoc / unsigned", comment: ""), + valueColor: s.valid ? Brand.textPrimary : Brand.red) + if let t = s.teamID, !t.isEmpty, t != s.signer { kvMono("Team ID", t) } + kv("Hardened runtime", s.hardened ? yes : no, valueColor: s.hardened ? Brand.green : Brand.textSecondary) + kv("App Sandbox", s.sandboxed ? yes : no, valueColor: s.sandboxed ? Brand.green : Brand.textSecondary) + } else { + kv("Signature", NSLocalizedString("not determined", comment: ""), valueColor: Brand.textTertiary) + } + if !arch.isEmpty { kv("Architecture", arch) } + } + } + + private var resourceSection: some View { + section("Resource Usage", "speedometer") { + kv("CPU", String(format: "%.1f%%", proc.cpu), + valueColor: proc.cpu > 50 ? Brand.orange : (proc.cpu > 20 ? Brand.gold : Brand.textPrimary)) + if let d = deep { + userSysRow(d) + kv("Memory", Fmt.bytes(d.footprintBytes)) + kv("Peak memory", Fmt.bytes(d.peakFootprintBytes), valueColor: Brand.textSecondary) + kv("Page-ins", Fmt.bytes(d.pageIns), valueColor: Brand.textSecondary) + kv("Threads", "\(d.threads)") + } + } + } + + private var ioNetworkSection: some View { + section("Disk & Network", "arrow.up.arrow.down") { + if let d = deep { + kv("Disk read", Fmt.bytes(d.diskReadBytes), valueColor: Brand.textSecondary) + kv("Disk written", Fmt.bytes(d.diskWriteBytes), valueColor: Brand.textSecondary) + } + kv("Network", netText, valueColor: Brand.textSecondary) + } + } + + private var hierarchySection: some View { + section("Hierarchy", "list.bullet.indent") { + ForEach(Array(children.prefix(10).enumerated()), id: \.offset) { _, c in + HStack(spacing: 8) { + Image(systemName: "arrow.turn.down.right").font(.system(size: 9)).foregroundStyle(Brand.textTertiary) + Text(c.name).font(Brand.sans(12)).foregroundStyle(Brand.textPrimary).lineLimit(1) + Spacer(minLength: 8) + Text(verbatim: "\(c.pid)").font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + Text(String(format: "%.1f%%", c.cpu)).font(Brand.mono(10)).foregroundStyle(Brand.textSecondary) + .frame(width: 44, alignment: .trailing) } } + if children.count > 10 { + Text(String(format: NSLocalizedString("+%d more", comment: ""), children.count - 10)) + .font(Brand.mono(9)).foregroundStyle(Brand.textTertiary) + } + } + } + + // MARK: Section + row builders + + private func section(_ title: String, _ glyph: String, @ViewBuilder _ rows: () -> C) -> some View { + VStack(alignment: .leading, spacing: 8) { + Eyebrow(text: title, glyph: glyph, color: accent) + VStack(alignment: .leading, spacing: 7) { rows() } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Color.black.opacity(0.18))) + .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous).strokeBorder(Brand.hairline, lineWidth: 1)) } - .padding(20) - .frame(width: 460) - .task { - let pid = proc.pid - deep = ProcessDeepMetrics.read(pid: pid) // fast syscall - let r = await Task.detached(priority: .utility) { NetUsage.sample()[pid] }.value - net = r - measuringNet = false + } + + private func kv(_ label: String, _ value: String, valueColor: Color = Brand.textPrimary, mono: Bool = false) -> some View { + HStack(alignment: .top, spacing: 10) { + Text(NSLocalizedString(label, comment: "")).font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + .frame(width: 116, alignment: .leading) + Text(value).font(mono ? Brand.mono(11) : Brand.sans(12)).foregroundStyle(valueColor) + .textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } } - private func cpuTimeText(_ d: ProcessDeepMetrics.Metrics) -> String { + private func kvMono(_ label: String, _ value: String, valueColor: Color = Brand.textPrimary) -> some View { + kv(label, value, valueColor: valueColor, mono: true) + } + + private func note(_ text: String, _ color: Color) -> some View { + Text(text).font(Brand.mono(9)).foregroundStyle(color.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + } + + /// CPU time row with a user/sys split bar (ProcessSpy's User/Sys). + private func userSysRow(_ d: ProcessDeepMetrics.Metrics) -> some View { let total = d.userSeconds + d.systemSeconds - guard let frac = ProcessDeepMetrics.userFraction(userSeconds: d.userSeconds, systemSeconds: d.systemSeconds) else { - return String(format: NSLocalizedString("%.1fs total", comment: ""), total) + let frac = ProcessDeepMetrics.userFraction(userSeconds: d.userSeconds, systemSeconds: d.systemSeconds) ?? 0 + return HStack(alignment: .center, spacing: 10) { + Text(NSLocalizedString("CPU time", comment: "")).font(Brand.mono(10)).foregroundStyle(Brand.textTertiary) + .frame(width: 116, alignment: .leading) + VStack(alignment: .leading, spacing: 3) { + Text(String(format: NSLocalizedString("%.1fs · %.0f%% user / %.0f%% system", comment: ""), + total, frac * 100, (1 - frac) * 100)) + .font(Brand.mono(10)).foregroundStyle(Brand.textSecondary) + GeometryReader { geo in + HStack(spacing: 0) { + Capsule().fill(accent).frame(width: max(0, geo.size.width * frac)) + Capsule().fill(Brand.textTertiary.opacity(0.5)) + } + } + .frame(height: 4) + } + .frame(maxWidth: .infinity, alignment: .leading) } - return String(format: NSLocalizedString("%.1fs · %.0f%% user / %.0f%% system", comment: ""), - total, frac * 100, (1 - frac) * 100) + } + + // MARK: Data + + private func load() async { + let pid = proc.pid + deep = ProcessDeepMetrics.read(pid: pid) + let p = ProcessActions.executablePath(pid: pid) + path = p + let (s, a): (CodeSignInfo.Info?, String) = await Task.detached(priority: .utility) { + (CodeSignInfo.read(pid: pid), p.map(MachOArch.label(path:)) ?? "") + }.value + sign = s; arch = a + let r = await Task.detached(priority: .utility) { NetUsage.sample()[pid] }.value + net = r; measuringNet = false } private var netText: String { - if measuringNet { return NSLocalizedString("Measuring…", comment: "") } - guard let net, net.down > 0 || net.up > 0 else { return NSLocalizedString("Idle", comment: "") } + if measuringNet { return NSLocalizedString("measuring…", comment: "") } + guard let net, net.down > 0 || net.up > 0 else { return NSLocalizedString("idle", comment: "") } return String(format: NSLocalizedString("↓ %@/s ↑ %@/s", comment: ""), Fmt.bytes(net.down), Fmt.bytes(net.up)) } - private func field(_ label: String, _ value: String, glyph: String, tint: Color, mono: Bool = false) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: glyph).font(.system(size: 12)).foregroundStyle(tint).frame(width: 18) - VStack(alignment: .leading, spacing: 2) { - Text(NSLocalizedString(label, comment: "")).font(Brand.mono(9, .bold)).tracking(0.5) - .foregroundStyle(Brand.textTertiary) - Text(value).font(mono ? Brand.mono(11) : Brand.sans(12)).foregroundStyle(Brand.textPrimary) - .textSelection(.enabled).fixedSize(horizontal: false, vertical: true) - } - Spacer() - } - } + private var yes: String { NSLocalizedString("Yes", comment: "") } + private var no: String { NSLocalizedString("No", comment: "") } private var originText: String { switch origin { @@ -143,18 +279,18 @@ struct ProcessInspectorView: View { case .ssh: return NSLocalizedString("Started over SSH (remote session)", comment: "") } } - private var originGlyph: String { - switch origin { - case .login: return "person.crop.circle" - case .shell: return "terminal" - case .ssh: return "network" - } - } private var originTint: Color { switch origin { - case .login: return Brand.green + case .login: return Brand.textPrimary case .shell: return Brand.gold case .ssh: return Brand.orange } } + + /// Approximate wall-clock start time from the runtime (now − runtime). + private static func startedText(runtimeSeconds: Double) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f.localizedString(fromTimeInterval: -runtimeSeconds) + } } diff --git a/macos/Tests/MachOArchTests.swift b/macos/Tests/MachOArchTests.swift new file mode 100644 index 0000000..401263c --- /dev/null +++ b/macos/Tests/MachOArchTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import Burrow + +final class MachOArchTests: XCTestCase { + func testThinArm64BigEndian() { + let h: [UInt8] = [0xFE, 0xED, 0xFA, 0xCF, 0x01, 0x00, 0x00, 0x0C] + XCTAssertEqual(MachOArch.archs(fromHeader: h), ["arm64"]) + XCTAssertEqual(MachOArch.label(MachOArch.archs(fromHeader: h)), "arm64") + } + + func testThinX86LittleEndian() { + // 0xCFFAEDFE magic → cputype fields are little-endian; x86_64 = 0x01000007. + let h: [UInt8] = [0xCF, 0xFA, 0xED, 0xFE, 0x07, 0x00, 0x00, 0x01] + XCTAssertEqual(MachOArch.archs(fromHeader: h), ["x86_64"]) + } + + func testFatUniversal() { + var h: [UInt8] = [0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x02] // FAT_MAGIC, 2 slices + h += [0x01, 0x00, 0x00, 0x0C] + [UInt8](repeating: 0, count: 16) // slice 0: arm64 (stride 20) + h += [0x01, 0x00, 0x00, 0x07] + [UInt8](repeating: 0, count: 16) // slice 1: x86_64 + XCTAssertEqual(MachOArch.archs(fromHeader: h), ["arm64", "x86_64"]) + XCTAssertEqual(MachOArch.label(MachOArch.archs(fromHeader: h)), "Universal (arm64, x86_64)") + } + + func testNotMachO() { + XCTAssertEqual(MachOArch.archs(fromHeader: [0x7F, 0x45, 0x4C, 0x46, 0, 0, 0, 0]), []) // ELF + XCTAssertEqual(MachOArch.label([]), "") + } +} From cc90a9fdb18bd5cdda15da103b342ffe89c9ad86 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:23:54 -0700 Subject: [PATCH 33/33] fix(ui): kill 3 main-thread hangs on the mole-parity surfaces SoftwareView: alias-aware search re-folded bundleId + uninstallName with ICU per app per keystroke over the 100+ app list, reintroducing the App Hang the loweredNames cache fixed. Fold name + bundleId + uninstallName into one lowercased haystack once per load (loweredSearch); the keystroke filter is now a single substring scan. Name sort still uses the name-only cache. ProcessInspectorView: binaryMissing was a computed fileExists() stat() re-run on every inspector body redraw. Resolve it once in load()'s off-main block and store @State. ConnectivityView: ConnectionHistory.record() (UserDefaults read + JSON decode/encode/write) ran on the main actor inside reload(); move it off-main via Task.detached, like the SSID read above it. --- macos/Sources/ConnectivityView.swift | 8 +++++++- macos/Sources/ProcessInspectorView.swift | 16 +++++++++++---- macos/Sources/SoftwareView.swift | 25 +++++++++++++++++------- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/macos/Sources/ConnectivityView.swift b/macos/Sources/ConnectivityView.swift index faba95b..60faf6e 100644 --- a/macos/Sources/ConnectivityView.swift +++ b/macos/Sources/ConnectivityView.swift @@ -424,7 +424,13 @@ struct ConnectivityView: View { let online = result.checks.contains { $0.id == "internet" && $0.status == .ok } let portal = result.checks.contains { $0.id == "portal" } let reason = ConnectionFailureClassifier.classify(online: online, portal: portal, loginReachable: portal) - history = ConnectionHistory.record(ssid: ssid, reason: reason.rawValue, at: Date()) + // record() reads + rewrites the UserDefaults JSON log — keep it off + // the main actor, like the SSID read above. + let reasonRaw = reason.rawValue + let at = Date() + history = await Task.detached(priority: .utility) { + ConnectionHistory.record(ssid: ssid, reason: reasonRaw, at: at) + }.value } } } diff --git a/macos/Sources/ProcessInspectorView.swift b/macos/Sources/ProcessInspectorView.swift index bbf97cd..75705e9 100644 --- a/macos/Sources/ProcessInspectorView.swift +++ b/macos/Sources/ProcessInspectorView.swift @@ -33,6 +33,10 @@ struct ProcessInspectorView: View { @State private var sign: CodeSignInfo.Info? @State private var arch = "" @State private var path: String? + /// On-disk binary gone (deleted/replaced after launch). It's a `stat()`, so + /// it's resolved once in `load()` off-main rather than recomputed on every + /// body redraw the way a computed `fileExists` property would be. + @State private var binaryMissing = false private var accent: Color { Tool.status.accent } @@ -43,7 +47,6 @@ struct ProcessInspectorView: View { private var origin: ProcessOrigin.Origin { ProcessOrigin.classify(pid: proc.pid, table: originTable) } private var parent: ProcessInfo? { proc.ppid.flatMap { ppid in processes.first { $0.pid == ppid } } } private var children: [ProcessInfo] { processes.filter { $0.ppid == proc.pid } } - private var binaryMissing: Bool { path.map { !FileManager.default.fileExists(atPath: $0) } ?? false } var body: some View { VStack(spacing: 0) { @@ -254,10 +257,15 @@ struct ProcessInspectorView: View { deep = ProcessDeepMetrics.read(pid: pid) let p = ProcessActions.executablePath(pid: pid) path = p - let (s, a): (CodeSignInfo.Info?, String) = await Task.detached(priority: .utility) { - (CodeSignInfo.read(pid: pid), p.map(MachOArch.label(path:)) ?? "") + // Fold the binary-exists stat() into the off-main block — the inspector + // body reads `binaryMissing` on every redraw, so a computed fileExists + // would be a syscall per render. + let (s, a, missing): (CodeSignInfo.Info?, String, Bool) = await Task.detached(priority: .utility) { + (CodeSignInfo.read(pid: pid), + p.map(MachOArch.label(path:)) ?? "", + p.map { !FileManager.default.fileExists(atPath: $0) } ?? false) }.value - sign = s; arch = a + sign = s; arch = a; binaryMissing = missing let r = await Task.detached(priority: .utility) { NetUsage.sample()[pid] }.value net = r; measuringNet = false } diff --git a/macos/Sources/SoftwareView.swift b/macos/Sources/SoftwareView.swift index 6ad2fb2..6ae3154 100644 --- a/macos/Sources/SoftwareView.swift +++ b/macos/Sources/SoftwareView.swift @@ -542,21 +542,32 @@ final class SoftwareModel: ObservableObject { /// (`u_isUAlphabetic`, `icu::CharString::append`) per app per keystroke /// over a 100+ app list hung the main thread (Sentry App Hang). Folding /// once up front turns each keystroke into a plain substring scan. + /// `loweredNames` stays name-only for the Name sort; `loweredSearch` is the + /// name + bundle id + uninstall name haystack for alias-aware filtering — + /// folding those two extra fields per keystroke instead brought the App + /// Hang straight back. private var loweredNames: [String: String] = [:] + private var loweredSearch: [String: String] = [:] private func rebuildLoweredNames() { - var map = [String: String](minimumCapacity: apps.count) - for a in apps { map[a.id] = a.name.lowercased() } - loweredNames = map + var names = [String: String](minimumCapacity: apps.count) + var search = [String: String](minimumCapacity: apps.count) + for a in apps { + let name = a.name.lowercased() + names[a.id] = name + search[a.id] = "\(name) \(a.bundleId.lowercased()) \(a.uninstallName.lowercased())" + } + loweredNames = names + loweredSearch = search } var filtered: [InstalledApp] { let q = query.trimmingCharacters(in: .whitespaces).lowercased() - // Alias-aware: also match bundle id + the engine's uninstall name (PRD §Uninstall). + // Alias-aware (name + bundle id + uninstall name, PRD §Uninstall), but + // matched against the haystack pre-folded once per load — so a keystroke + // is one substring scan per app, not three ICU foldings. let base = q.isEmpty ? apps : apps.filter { - (loweredNames[$0.id] ?? $0.name.lowercased()).contains(q) - || $0.bundleId.lowercased().contains(q) - || $0.uninstallName.lowercased().contains(q) + (loweredSearch[$0.id] ?? $0.name.lowercased()).contains(q) } let sorted: [InstalledApp] switch sort {