Devices (desktop & Android)
This layer is the device fabric: the desktops, laptops, and phones that together make up one person’s Xe Computer, how they join a group, how they serve each other’s apps over iroh, and — on Android — the companion app that carries membership, consent, an audit ledger, and the launcher crossing in one APK. It rides on Transport & identity and Peer serving & the fleet; this chapter is about the devices those planes run on and the phone-shaped surface unique to this beat.
As-built truth. Runtime citations are repo-relative in
agent54/xenon-runtimeatmain @b0cfc90(companion-android + thecompanion-coreRust crate). Desktop proof is drawn from packet reports (packets/done/, laneREPORT.mds). Ports follow d/100 (on-device browser plane on 5454). Where the design is honestly unfinished, this chapter says so and links the review.
Primer: the words this chapter assumes
What is the companion? The Android app
network.xenon.companion. It is not the browser — it is the phone’s membership card, consent screen, audit log, and launcher. It shows you the apps a desktop in your group is serving and, when you tap one, hands off to Xenon (our browser fork) to open it. Mobile is both-track (d/062): v1 is phone-as-remote-client to your own instance; v2 is a real Android browser runtime — a separate, later workstream.What is a group / membership? A device group is a signed, append-only document naming who is a member and who has been revoked. Every device holds a copy; membership is checked before any connection is trusted. There is no server — the document is the authority, verifiable offline.
What is the on-device plane? A single loopback web server on
127.0.0.1:5454that a device runs while its browser is open. Requests to<app>.localhost:5454are bridged, byte-for-byte, over an authenticated iroh session to whichever device is actually hosting the app. No background service; close the browser and every listener drops (d/037).What is a foreground service (FGS)? On Android, a long-running task that must show a notification and declare a type. The companion’s session runs as a
connectedDeviceFGS — chosen because Android 15 time-limits thedataSynctype, and a device session must not be silently killed by a clock.
Zoom 1 — the big picture: one person, many devices, one group
An Xe Computer is not a single machine. It is a fleet: a set of devices enrolled into one group, each able to serve its locally running apps to the others over an authenticated iroh mesh. Desktops and laptops are full peers — they both publish and consume. The phone is a companion — today it consumes (sees and launches a desktop’s apps) and carries the sovereignty machinery (membership, consent, audit), but does not yet host a browser runtime of its own.
graph LR
subgraph Group["Your device group (signed membership · iroh mesh)"]
LX["Linux<br/>reference peer<br/>publish + consume"]
MAC["macOS studio-36<br/>full peer<br/>publish + consume"]
WIN["Windows VM 200<br/>full peer<br/>publish + consume"]
AND["Android companion<br/>consume + launcher<br/>membership · consent · audit"]
IOS["iOS<br/>client-surface only<br/>(Apple policy)"]
end
MAC -- "iroh Direct, byte-identical (P-208)" --> LX
LX -- "publish → consume (P-201m)" --> MAC
WIN -- "iroh Direct, byte-identical (P-206)" --> LX
LX -- "L1-L3 consume in Controlled Frame (P-201w)" --> WIN
AND -- "substrate route_info/open (xenon/substrate/1)" --> LX
AND -. "apps.list dial - responder UNWIRED (P-188)" .-> WIN
classDef gap stroke-dasharray:4 3;
Every solid edge above is a proven serve-and-consume path; the dashed edge is the one structural gap on the launcher route (§3.5). Per-device roles as shipped:
| Device | Role today | Proof |
|---|---|---|
| Linux | Reference platform; every cross-host proof’s consumer. xe peer publish|consume + the on-device plane build & run native; serve+consume byte-identical over iroh. | P-208 REPORT (lane-186 consumed studio’s app byte-identical, iroh Direct). |
| macOS | Full peer (studio-36, arm64) — consume and publish. Prism installed + launched as a real IWA; peer app rendered in a real <controlledframe>; honest offline→recovery. | P-201m REPORT — PARTIAL (strong), LOAD proven E2E on real macOS. P-208 — Mac publishes over iroh Direct, “cleanest receipts of the night.” |
| Windows | Full peer (VM 200) — publish + consume native. A deliberate #[cfg(unix)] gate once blocked it; P-206 made peer publish|consume cross-platform. Full L1→L3 consume ladder incl. real HTMLControlledFrameElement render, in-frame offline+recovery. | P-206 — MERGED PR#62, byte-identical both directions, owner-only DACL custody. P-201w — VERIFIED-WITH-CAVEATS (catalog showed 0 apps; captures headless). |
| Android | The companion APK — consume + launcher + the membership/consent/audit substrate. Two subsystems in one APK (§3). | companion-android/ + crates/companion-core/; M3 (P-180) merged @193f1a7. |
| iOS | Client-surface only, by Apple policy (not our architecture). companion-ios crate exists; out of this beat. | kickoff §2. |
Zoom 2 — subsystems of the fabric
2.1 Device pairing & membership
A device joins a group through two enrollments, both over iroh, both verifiable offline:
- Runtime enrollment (
companion-core/src/lib.rs:120-171): the device redeems an authority-signedInvite(version, runtime identity, authority, token, expiry, signature), builds an enroll request in slotlauncherwith roleDevice, and verifies the returnedEnrollmentAttestation. - Group join (
group.rs:244): a base64urlJoinCode{invite, endpoint}is redeemed over iroh viaGroupNode::join_with_protocols; the verified, signed head of the group document is persisted; the node stays running.
Membership itself is a GroupDocument: a monotonic, hash-chained,
single-authority-signed list of Member{device_key, name, role} where role is
Administrator | Member | Observer, plus an append-only revocations list.
The roster is derived offline from the durable head (roster_from
group.rs:657) — no server call to know who is in your group. Convergence is
by gossip on every accepted connection plus an explicit announce on app-open
(sync_now group.rs:409); there is no background service (d/037).
Revocation is honest in both directions. Removing a live device cuts it
immediately. A device that has been revoked while offline learns the truth the
next time it dials: a member-gate refusal ("not a current group member") is
interpreted as this device’s own revocation, which writes a local
self-revoked.json and stops the device signing anything further
(record_self_revocation group.rs:438). The reverse paths — lost phone,
new laptop, rejoin after self-revocation — are thin, and reviewed as a gap
(review G-3).
2.2 The on-device plane (host-routed single listener)
Once a device is a member, its browser reaches peers’ apps through one loopback server, not a service per app (d/100):
- A single host-routed loopback HTTP plane at
127.0.0.1:5454(DEFAULT_LOCAL_PLANE_ADDR,apps.rs:41). A browser request to<app>.localhost:5454is bridged byte-for-byte over anopenlane on the same authenticated substrate session to the hosting device’s host-agent (apps.rs:1-19). - Grant enforcement stays hosting-side. The host re-checks membership plus
the
net.dialgrant on everyopen, so a revoked phone’s next fetch dies with the host’s own spelled reason surfaced as an HTTP error page (apps.rs:7-12). Authorization is not cached on the consuming device. - Plain HTTP loopback: no TLS, no zone CA, no system proxy, no external DNS
(
apps.rs:14-16) — deliberately minimal (d/101). Lifecycle (d/037): the proxy lives while the app process lives; closing the browser drops every listener (apps.rs:18-19). open(apps.rs:238) first doesrefresh_routes(a realRouteInfoRPC that doubles as a liveness probe), thenstart_plane, then returns the<app>.localhost:5454URL. On a network change,retire_stale_bridges_after_recovery(apps.rs:401) drops pre-recovery browser bridges (RFC 9112 §9.3.1 new-accept discipline).
2.3 Cross-device serving over iroh
The tunnel itself — publisher mints a ticket, consumer dials, verifies, binds a local port — is the subject of its own chapter and is not re-derived here; see Peer serving & the fleet. What matters for the device story is the shape it gives the fabric: any peer’s apps appear as ordinary local origins on any other peer, which is what lets the phone’s launcher list a desktop’s apps and open them as if they were local.
Zoom 3 — the Android companion, module by module
The APK is two overlapping subsystems in one binary, which are easy to conflate. Naming them separately is the whole trick to reading the code.
3.1 Subsystem A — the session/wake layer (Kotlin)
This is the Android incarnation of the Xe Computer Boot Service: the layer that answers “is this device up, and honestly so?”
SessionService(SessionService.kt): aconnectedDeviceforeground service startedSTART_NOT_STICKY— the OS is told not to resurrect it, so “off means off” (:26,68). It binds a loopback readiness endpoint127.0.0.1:47615that answersREADY <device>(:105-111). TheconnectedDeviceFGS type is chosen overdataSyncbecause Android 15 time-limits the latter (AndroidManifest.xml:37-40); Android 14+ requires this FGS to holdCHANGE_NETWORK_STATE(:6-9).- Honest kill vs. off.
onDestroywithout an explicit STOP writes akillaudit line (:161-170); a failed loopback bind tears down askill(:118-141); a clean STOP writesoff(:144-159). The audit ledger tells you how a session ended. WakeActivity(WakeActivity.kt): a no-UI trampoline (Theme.NoDisplay, exported) that gives a cold start a foreground context. Three actions —WAKE(start the FGS),CROSS(the launcher crossing, below),STOP. An unrecognized action is a deliberate no-op (:43-46): an exported activity must never fall through into a wake.SessionStore(SessionStore.kt): cached session state (so astatusprobe can answer without waking the device) plus the hash-chained audit log.deviceNamesanitizes every source to[a-z0-9-]≤32 (:80-92) because the device name reaches the notification, the loopback handshake line, and the audit log — an unsanitized newline could otherwise inject an audit-shaped line.StatusReceiver(StatusReceiver.kt): the doctor/status surface. Two modes, neither of which starts the FGS:probe=falseanswers from cached state;probe=true(“doctor”) does a live loopback probe and reports reconciled truth. Every failure string ends in an action (:59-63) — a Power Design rule made literal.
Session FGS semantics (honesty-first). START_NOT_STICKY means no OS
resurrection. On app launch, maybeRestoreSession (MainActivity.kt:117)
restarts a session only if it was active and the loopback probe returns null
and the cached flag was not cleared by a clean STOP — so a deliberately-ended
session is never silently revived; a genuine restore is audited as resume.
3.2 Subsystem B — the group / companion core (Rust via UniFFI)
The membership, consent, and app-access logic lives in companion-core and is
called from Kotlin over UniFFI. Its screens (MainActivity.kt) map directly to
states:
| Screen | Function | States it renders |
|---|---|---|
| Apps (launcher) — primary surface | renderLauncher (:189) | CHANNEL_UNAVAILABLE · offline · browser-missing · empty · app-missing · populated app cards. Actions: Reconnect · Start device · Refresh · Open Xenon · Connect a peer. Uses apps.list. |
| Join | showJoin (:504) | Paste join code + device name → “Join over iroh”. QR scan disabled (“coming next”). |
| Device roster | render (:524) | members / self / revoked · notice banner · Sync now · “Open apps ▸”. |
| Consent request | showConsent (:663) | FROM DEVICE / ACTION / APP / CAPABILITIES / WHY · Decline / Approve (Approve disabled if self-revoked). |
| Apps (on-device plane) | showApps (:558) | host substrate address + host device key · Connect to host · app cards · Back to roster. |
3.3 First run & the launcher crossing (the CROSS flow)
First run is the Join screen: paste the join code and a device name, tap
“Join over iroh.” After that the primary surface is the launcher — “Installed
apps on <computer>” — populated (when the responder is wired, §3.5)
from the desktop’s apps.list. Tapping an app card is the crossing: the
phone hands the app off to Xenon, our IWA-enabled Chromium fork, so it opens as
its own standalone app task rather than a browser tab.
sequenceDiagram participant U as You participant L as Companion launcher (MainActivity) participant C as companion-core (Rust) participant W as WakeActivity (trampoline) participant X as Xenon (Chromium fork) U->>L: tap app card L->>C: apps.list over xenon/companion/apps/1 C-->>L: LauncherAppsView (isolated_app_url) L->>L: validate isolated-app:// + Xenon installed L->>C: audit "launch outcome=requested" L->>W: Intent CROSS (origin, navigate) W->>W: validIsolatedAppUrl? else audit "cross outcome=refused-invalid-origin" W->>X: Intent(component=chrome/Main)<br/>NEW_TASK | NEW_DOCUMENT | RESET_TASK_IF_NEEDED W->>C: audit "cross outcome=sent" X-->>U: standalone WebappActivity fronts the app origin
The crossing, precisely (WakeActivity.doCross :70):
- Validate the target is an
isolated-app://URL — else auditcross outcome=refused-invalid-origin. - Build the Intent:
ACTION_VIEW(navigate) orsetData(target)(restore, no navigate). setClassName("org.chromium.chrome", "com.google.android.apps.chrome.Main")— an explicit component to Xenon, never an app-chooser.- Flags
NEW_TASK | NEW_DOCUMENT | RESET_TASK_IF_NEEDED(:101-103): document-centric routing so the system re-fronts the installed app’s own task (a standaloneWebappActivity) instead of coalescing into a tabbed-browser task — the P-160 fronting-over-an-existing-tab fix. There is noMULTIPLE_TASK, so an existing app task is re-fronted, never duplicated. - Audit
cross origin=… mode=navigate|restore outcome=sent|browser-missing.
3.4 The audit chain (as-shipped)
Every session and launcher event is appended to a tamper-evident
hash-chained log (SessionStore.audit :95-106):
line = "<seq>|<ts>|<action>|<detail>|<hash>\n"
seq = lines.size + 1
ts = ISO-8601 millis+tz (SimpleDateFormat "yyyy-MM-dd'T'HH:mm:ss.SSSZ")
prevHash = previous line's field after the last '|' ("genesis" if empty)
payload = "<seq>|<ts>|<action>|<detail>"
hash = SHA-256(prevHash + "|" + payload).hex().take(16) // 64-bit truncated
safeAudit collapses \r \n | → - and caps a field at 2048 bytes
(:109-112). Actions observed in-tree: wake, kill, off, cross, list, launch, resume. This is tamper-evident (each line commits to the last), not
append-only or externally anchored — an honest limitation it shares with the
agent-layer ledger; the two should converge on one discipline
(agent-layer review). The full field table is a
Part V candidate:
Companion audit hash-chain v1.
3.5 The ALPN registry — every ALPN accounted for
An ALPN is the protocol name negotiated when two devices open an iroh stream. The companion touches exactly three:
| ALPN string | Const (source) | Direction on the phone | Wired? |
|---|---|---|---|
xenon/net/consent/1 | CONSENT_ALPN (companion-transport/src/lib.rs:27) | Inbound and registered — the only protocol in bind_with_protocols / join_with_protocols (group.rs:233,272); dispatched by adopt_node (:328). | ✅ fully wired |
xenon/companion/apps/1 | LAUNCHER_APPS_ALPN (launcher_apps.rs:23) | Phone dials it outbound (launcher_apps_list → dial_session(&addr, LAUNCHER_APPS_ALPN) group.rs:605). Phone-as-host responder is not wired (below). | ⚠️ dial-only |
xenon/substrate/1 | SUBSTRATE_ALPN (apps.rs:599) | Phone dials it for route_info + open on the on-device plane. | ✅ dial wired |
The known unwired responder (P-188). This is the single most important structural gap on the launcher path, and it is a dispatch omission, not a missing protocol:
adopt_nodenever dispatchesLAUNCHER_APPS_ALPN. Its stream loop iswhile let Some(incoming) = extra.recv().await { if incoming.alpn != CONSENT_ALPN { continue; } … }(group.rs:327-328) — an inbound launcher-apps stream is silently skipped.- The protocol is not even accepted inbound:
bind_node/joinregister onlyvec. - The responder exists but is never called:
serve_list_apps_extra(launcher_apps.rs:271) checksincoming.alpn == LAUNCHER_APPS_ALPNand serves — but nothing ingroup.rsinvokes it. - What serves today is only the debug TCP adapter
bin/launcher-app-host.rs(used by the emulator rig) and the desktopxehost’s own responder. - User-visible face: in production,
MainActivity.fetchLauncherApps(:271) has no wired channel and no debug endpoint, so it throws → the launcher shows the reviewedCHANNEL_UNAVAILABLEcopy (:297-300, wired by P-217). Parked pending d/104 (PROPOSED, pending Luke); tracked as P-188, and argued in the review.
Honest edges
Named here, argued in the review:
- The launcher’s
apps.listresponder is unwired on the phone-as-host and the desktop-serve path (P-188) — the reviewedCHANNEL_UNAVAILABLEcopy is what a real phone shows until d/104 ratifies the launch descriptor. - Device recovery is one-directional: enrollment and revocation are solid; lost-phone / new-laptop / rejoin ceremonies are thin (G-3).
- The phone cannot hold app state offline — the plane is host-routed and session-lifetime only; “movable memory” is not yet a cross-device layer (G-5).
- Wake-a-sleeping-device is honestly refused on most paths: the peer transport is iroh, which cannot be OS-socket-activated, so only same-network zero-resident desktops are wakeable today (G-4, and the agent-layer mission context).
Links
- iroh: iroh.computer · relay concepts
- Controlled Frame / IWA: Controlled Frame explainer · Isolated Web Apps
- Android: foreground service types · Doze & App Standby · UnifiedPush (sovereign push)
- Related chapters: Transport & identity · Peer serving & the fleet · The agent layer · Review