Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-runtime at main @b0cfc90 (companion-android + the companion-core Rust crate). Desktop proof is drawn from packet reports (packets/done/, lane REPORT.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:5454 that a device runs while its browser is open. Requests to <app>.localhost:5454 are 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 connectedDevice FGS — chosen because Android 15 time-limits the dataSync type, 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:

DeviceRole todayProof
LinuxReference 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).
macOSFull 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.”
WindowsFull 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).
AndroidThe 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.
iOSClient-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:

  1. Runtime enrollment (companion-core/src/lib.rs:120-171): the device redeems an authority-signed Invite (version, runtime identity, authority, token, expiry, signature), builds an enroll request in slot launcher with role Device, and verifies the returned EnrollmentAttestation.
  2. Group join (group.rs:244): a base64url JoinCode{invite, endpoint} is redeemed over iroh via GroupNode::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:5454 is bridged byte-for-byte over an open lane 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.dial grant on every open, 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 does refresh_routes (a real RouteInfo RPC that doubles as a liveness probe), then start_plane, then returns the <app>.localhost:5454 URL. 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): a connectedDevice foreground service started START_NOT_STICKY — the OS is told not to resurrect it, so “off means off” (:26,68). It binds a loopback readiness endpoint 127.0.0.1:47615 that answers READY <device> (:105-111). The connectedDevice FGS type is chosen over dataSync because Android 15 time-limits the latter (AndroidManifest.xml:37-40); Android 14+ requires this FGS to hold CHANGE_NETWORK_STATE (:6-9).
  • Honest kill vs. off. onDestroy without an explicit STOP writes a kill audit line (:161-170); a failed loopback bind tears down as kill (:118-141); a clean STOP writes off (: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 a status probe can answer without waking the device) plus the hash-chained audit log. deviceName sanitizes 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=false answers 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:

ScreenFunctionStates it renders
Apps (launcher) — primary surfacerenderLauncher (: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.
JoinshowJoin (:504)Paste join code + device name → “Join over iroh”. QR scan disabled (“coming next”).
Device rosterrender (:524)members / self / revoked · notice banner · Sync now · “Open apps ▸”.
Consent requestshowConsent (: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):

  1. Validate the target is an isolated-app:// URL — else audit cross outcome=refused-invalid-origin.
  2. Build the Intent: ACTION_VIEW (navigate) or setData(target) (restore, no navigate).
  3. setClassName("org.chromium.chrome", "com.google.android.apps.chrome.Main") — an explicit component to Xenon, never an app-chooser.
  4. 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 standalone WebappActivity) instead of coalescing into a tabbed-browser task — the P-160 fronting-over-an-existing-tab fix. There is no MULTIPLE_TASK, so an existing app task is re-fronted, never duplicated.
  5. 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 stringConst (source)Direction on the phoneWired?
xenon/net/consent/1CONSENT_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/1LAUNCHER_APPS_ALPN (launcher_apps.rs:23)Phone dials it outbound (launcher_apps_listdial_session(&addr, LAUNCHER_APPS_ALPN) group.rs:605). Phone-as-host responder is not wired (below).⚠️ dial-only
xenon/substrate/1SUBSTRATE_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_node never dispatches LAUNCHER_APPS_ALPN. Its stream loop is while 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/join register only vec![CONSENT_ALPN.to_vec()] (group.rs:233,272).
  • The responder exists but is never called: serve_list_apps_extra (launcher_apps.rs:271) checks incoming.alpn == LAUNCHER_APPS_ALPN and serves — but nothing in group.rs invokes it.
  • What serves today is only the debug TCP adapter bin/launcher-app-host.rs (used by the emulator rig) and the desktop xe host’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 reviewed CHANNEL_UNAVAILABLE copy (: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.list responder is unwired on the phone-as-host and the desktop-serve path (P-188) — the reviewed CHANNEL_UNAVAILABLE copy 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).