frust_devtools/lib.rs
1//! `frust-devtools` — the in-app debug service a Frust app hosts for external
2//! tooling.
3//!
4//! It listens on an ephemeral **loopback** TCP port, speaks the NDJSON
5//! JSON-RPC protocol defined by [`frust_devtools_protocol`], and answers every
6//! request through one trait, [`DevtoolsBackend`], which a shell implements.
7//! The tooling side (`frust-drive`/`frust-tui`) never depends on this crate —
8//! the two sides meet at the protocol crate and nowhere else
9//! (`docs/ARCHITECTURE.md`'s Tooling isolation rule), which is also why this
10//! crate depends on no framework crate: it is a service, not a framework
11//! layer, and it learns about the app only through the backend it is handed.
12//!
13//! ```no_run
14//! use frust_devtools::{AppInfo, Service};
15//! # use frust_devtools::{BackendError, DevtoolsBackend};
16//! # use frust_devtools_protocol::{
17//! # InputScrollParams, InputTapParams, MetricsSnapshot, WidgetProps, WidgetTreeDump,
18//! # };
19//! # struct ShellBackend;
20//! # impl DevtoolsBackend for ShellBackend {
21//! # fn widget_tree(&self) -> WidgetTreeDump { WidgetTreeDump { roots: Vec::new() } }
22//! # fn widget_props(&self, _id: u64) -> Option<WidgetProps> { None }
23//! # fn metrics_snapshot(&self) -> MetricsSnapshot {
24//! # MetricsSnapshot { rss_bytes: None, uptime_ms: 0 }
25//! # }
26//! # fn inject_tap(&self, _p: InputTapParams) -> Result<(), BackendError> { Ok(()) }
27//! # fn inject_scroll(&self, _p: InputScrollParams) -> Result<(), BackendError> { Ok(()) }
28//! # fn inject_text(&self, _t: &str) -> Result<(), BackendError> { Ok(()) }
29//! # }
30//! let devtools = Service::start(ShellBackend, AppInfo::new("my-app", "0.1.0"))?;
31//! // ...each frame, from the frame hook — never blocks:
32//! // devtools.publish_frame_stats(stats);
33//! devtools.shutdown();
34//! # Ok::<(), std::io::Error>(())
35//! ```
36//!
37//! # Trust model
38//!
39//! The listener binds `127.0.0.1:0` and **only** `127.0.0.1` — never
40//! `0.0.0.0`, not configurably. Loopback alone is *not* the boundary, though:
41//! on a device every co-resident app can reach `127.0.0.1:<port>` too, and the
42//! protocol carries `input_*` methods that drive the real UI plus a widget-tree
43//! dump that is user data. So the service also mints a random per-process
44//! **token**, prints it on its discovery line, and requires it at `handshake`
45//! before dispatching any other method — the same shape the Dart VM service's
46//! auth code has. That works because only a privileged reader sees the line:
47//! another Android app cannot read this app's logcat (`READ_LOGS` is a
48//! privileged permission), and a desktop app's stderr reaches only the tooling
49//! process that launched it.
50//!
51//! Two layers still sit above it, and neither is optional: a shell gates
52//! starting the service on a debug/profile build via a cargo feature (a release
53//! build compiles the listener out entirely), and `ServiceConfig::require_token`
54//! — default **on** — is the only way to run without auth, meant for in-process
55//! tests, never a shipped build. See `crate::token` for the token's entropy
56//! source, stated with its limits.
57//!
58//! # Threading & blocking model
59//!
60//! Three threads are involved, and only one of them is the app's:
61//!
62//! | Thread | Owns | Blocking rule |
63//! |---|---|---|
64//! | the shell's frame/UI thread | calls [`ServiceHandle::publish_frame_stats`] | never blocks: a bounded, drop-oldest, sync send |
65//! | the service thread | the internal current-thread tokio runtime, the listener, every connection | blocks only on IO it owns |
66//! | the backend thread | the [`DevtoolsBackend`] value | runs one sync trait call at a time, in arrival order |
67//!
68//! A backend method that needs UI-thread state hops there itself; the service
69//! caps every call with [`ServiceConfig::backend_timeout`], so a hop that
70//! never returns costs that client one error response and nothing else.
71//! `handshake` bypasses the backend entirely (its answer is captured at
72//! startup), so a client can always identify even a wedged app. `crate::hop`'s
73//! module doc carries the full contract, including what happens to a call that
74//! completes after its client gave up.
75//!
76//! # Discovery
77//!
78//! On start the service logs one line built by
79//! [`frust_devtools_protocol::format_discovery_line`] at `info` level, carrying
80//! the port and (with auth on) the token; tooling recovers both from a
81//! log/logcat stream with
82//! [`frust_devtools_protocol::parse_discovery_line`].
83//! [`ServiceHandle::port`]/[`ServiceHandle::token`] are the in-process
84//! equivalents.
85
86mod backend;
87mod dispatch;
88mod frame_stats;
89mod hop;
90mod server;
91mod service;
92mod token;
93
94pub use backend::{AppInfo, BackendError, DevtoolsBackend};
95pub use service::{Service, ServiceConfig, ServiceHandle};