Architecture
June 2, 2026 · View on GitHub
This document explains how quokka is put together and, more importantly, why.
If you only read one section, read The Device seam — it is
the rule the rest of the codebase is shaped around.
The big picture
quokka is a Cargo workspace with two crates:
quokka-core(libquokka_core,crates/quokka-core/) — the presentation-free heart: theDeviceseam (trait + iOS/Android/fake backends + neutral types), the application facade (app), and the pure projection/format logic (fmt,logic,card). Nothing here knows about terminals,clap, or Tauri. This is the crate the (future) GUI depends on.quokka-cli(libquokka_cli, binsquokka/qk,crates/quokka-cli/) — everything terminal-shaped:clapparsing/dispatch, the per-command modules, theratatuiTUIs, and the terminal helpers inui. It depends onquokka-coreand re-exports its modules at the historicalquokka_cli::{app, card, device, fmt, logic}paths.
A CLI run looks like this:
quokka.rs / qk.rs → lib.rs::run() → device::connect() → app::* → commands::*::run()
(thin shim) (parse + dispatch) (one connection) (facade) (render the DTO)
crates/quokka-cli/src/bin/{quokka,qk}.rsare four-line shims. They exist only so the tool can be invoked under two names. They both callrun().crates/quokka-cli/src/lib.rsownsrun(). It parses arguments withclap, connects to the device once, then either prints the facade DTO as JSON (--json) or dispatches to the matching command module to render it. All integration tests go through the library, never by spawning the binary.crates/quokka-core/src/device/mod.rsis the boundary with the device (see below).crates/quokka-core/src/app/is the facade: one async function per operation, each returning a serializable DTO andDeviceError. The CLI,--json, and the GUI all consume it.crates/quokka-cli/src/commands/holds one module per command. Commands call the facade (or the trait) and render — they never see the device crate types directly leak any backend.crates/quokka-cli/src/ui.rscentralises terminal helpers (spinners, progress bars, TTY detection, the device picker); the pure value formatters live inquokka_core::fmtand are re-exported throughui.
Because run() lives in the library and commands take a trait object, the
whole tool can be driven in a test without a binary, a terminal, or a device.
The facade and --json
quokka_core::app is a surface-agnostic API: app::status, app::info,
app::apps, app::analyze, app::media, app::delete_files, app::card,
app::reboot, app::shutdown, app::stream_logs. Each drives the Device
trait plus the pure logic and returns a serializable DTO (or a Receiver of
DTOs, for streaming logs), surfacing failures as a serializable DeviceError
({ kind, message }).
run() dispatches generically over this: for the one-shot query commands
(status, info, apps, analyze, media, devices), --json prints
serde_json of the DTO and the plain path renders it. logs --json streams
NDJSON, one line per LogEntry. card and capture stay out of --json
(image / TUI). The GUI wraps each app::* function in a one-line command — the
DTOs, parsing, and card render all come ready from the core.
The Device seam
Every operation quokka performs on a device — iPhone or Android — goes
through the Device trait in crates/quokka-core/src/device/mod.rs. This is
the single most important design decision in the project.
#[async_trait]
pub trait Device: Send + Sync {
async fn status(&self) -> Result<DeviceStatus>;
async fn apps(&self) -> Result<Vec<App>>;
async fn with_dynamic_sizes(&self, apps: Vec<App>, on_batch: BatchCallback) -> Result<Vec<App>>;
async fn app(&self, bundle_id: &str) -> Result<Option<App>>;
async fn uninstall_app(&self, bundle_id: &str) -> Result<()>;
async fn afc_walk(&self, roots: &[&str], on_progress: WalkCallback) -> Result<Vec<MediaFile>>;
async fn afc_delete(&self, path: &str) -> Result<()>;
}
There are two implementations:
RealDevice, in a privatemod realsubmodule, talks to theidevicecrate overusbmuxd.AndroidDevice, in amod androidsubmodule, talks to a localadbserver viaforensic-adb.FakeDeviceis an in-memory implementation used by tests. It ispubso the integration tests (crates/quokka-cli/tests/,quokka-core/tests/) can construct it; production code never does.
Why the seam exists
The idevice crate is pre-1.0 and ships breaking changes at nearly every
point release until 0.2.0. The seam isolates that churn:
- No
idevicetype may appear in the public surface ofdevice/mod.rs. The trait deals only in quokka's own types (DeviceStatus,App,MediaFile, …). Whenidevicebreaks, the damage is contained tomod real— the trait and everything above it stay still. - The dependency is pinned with
=0.1.xinquokka-core/Cargo.toml(alongside the=0.8.xforensic-adbpin) for the same reason. - Because commands depend on the trait, not the crate, every command is
testable against
FakeDevicewith zero hardware.
Adding a capability
To add a new device operation (say, reading crash logs):
- Add a method to the
Devicetrait. - Implement it in
mod realagainstidevice. - Implement it in
FakeDevicewith seeded in-memory data. - Consume the trait method from a command.
- Write unit/integration tests using
FakeDevice.
If step 4 needs an idevice type, the seam has leaked — fix the trait instead.
Commands
Each file in crates/quokka-cli/src/commands/ is one command and exposes an
async fn run(device: &dyn Device, …):
status.rs— fetches aDeviceStatusand prints the dashboard once.apps.rs— lists user apps by size. On a TTY it opens aratatuipicker whose sizes update live as a background "Phase 2" enrichment pass streams in dynamic disk usage. Also handles--uninstall.analyze.rs— walks the AFC media roots (/DCIM,/Downloads,/Recordings,/Books), then either prints the heaviest files (read-only) or, with--delete, opens aratatuideletion picker.dashboard.rs— a pure renderer: it turns aDeviceStatusinto the two-column dashboard string. No I/O, so every layout decision is unit-testable. Shared bystatusand the launcher.menu.rs— the interactive launcher shown whenquokkais run with no subcommand on a TTY.card.rs—qk cardrenders a 1080×1080 PNG snapshot of the device for social sharing. The pure layers live in the core (crates/quokka-core/src/card/):data.rsprojectsDeviceStatus + now → CardData(all time-derived values are pre-formatted strings, so the renderer is deterministic);badges.rsevaluates 15 eligibility checks and ranks the top 3;render.rsis a purefn render_svg(&CardData) -> String;png.rsrasterises viaresvgwith JetBrains Mono embedded viainclude_bytes!and registered inusvg::Options::fontdb;share.rsformats the Twitter intent URL. The CLI'scommands/card.rsis the only layer that touches the filesystem and spawnsopenfor Preview; the facade'sapp::cardreturns the rendered bytes directly for the GUI.
apps and analyze are the only commands with an interactive TUI; card
writes a PNG and exits; everything else prints a plain block of output.
UX principles
These are enforced by the shared helpers in ui.rs and by anstream /
owo-colors at the stream level — don't reinvent them:
- Colour and animation gate on TTY +
NO_COLORautomatically.anstreamstrips ANSI when piped;indicatif::ProgressDrawTarget::stderr()hides spinners on a non-TTY. - Operations longer than ~1s show a spinner or progress bar.
- Errors say what happened and what to do. No raw stack traces.
- Destructive actions require explicit confirmation. Without a TTY,
destructive commands abort rather than assume "yes" — see
analyze --delete. --dry-runbehaviour is the default where it makes sense:analyzenever deletes unless--deleteis set.
Test layers
| Layer | Location | Needs a device? | Runs in CI? |
|---|---|---|---|
| Unit | #[cfg(test)] next to the code (both crates) | No | Yes |
| Facade | crates/quokka-core/tests/facade.rs | No (uses fake) | Yes |
| Integration | crates/quokka-cli/tests/integration.rs | No (uses fake) | Yes |
| End-to-end | crates/quokka-cli/tests/e2e_*.rs (e2e / e2e-android) | Yes | No |
The first two layers are the regression net: they pin the current behaviour so a future change that breaks it fails CI before it can merge. That is why new logic must ship with tests in the same change — see CONTRIBUTING.md.
The e2e layer can't run in CI (no hardware), so CI compile-checks it instead:
a broken e2e test file still fails the build.
iOS / idevice notes
quokka deliberately uses only lockdown-classic services — lockdown,
diagnostics_relay, afc, installation_proxy — over usbmuxd. It does not
open the core_device_proxy / RemoteXPC tunnel, which would be required for
the DVT / DTServiceHub services on iOS 17+. The MVP does not need them, and
avoiding the tunnel keeps the privilege requirements at zero.
A few iOS 17.4+ quirks are handled in mod real and worth knowing before you
touch battery code:
diagnostics_relay.mobilegestalt(...)returnsMobileGestaltDeprecatedfor every key on modern iOS — don't use it. Battery level comes from the lockdowncom.apple.mobile.batterydomain instead.diagnostics_relay.gasguage()still works, but its response is wrapped one level deep under a"GasGauge"key — unwrap the inner dict first.- On iOS 17+,
FullChargeCapacityis reported as a percentage of design capacity (matching Settings → Battery Health). The heuristic incompute_health_percent: if the value is ≤ 100, treat it as a percentage; otherwise compute the ratio againstDesignCapacity. - Battery temperature is no longer cheaply available on iOS 17+ — it would
require an
ioregistrydump ofAppleSmartBattery(tens of thousands of lines for one number). It is intentionally left as—.
Scope boundaries
The following are intentionally not built, because iOS makes them impossible from a desktop companion without a jailbreak — don't add them, and push back on requests that assume them:
- Per-app cache cleanup (each app's cache lives inside its own sandbox).
- Crash log retrieval, full device backups, Wi-Fi pairing.
If a feature request looks like one of these, point the reporter at this section and at the README.