How a third party writes, packages, and ships an app for the glasses — the runtime that hosts it, the package it lives in, the ABI it speaks, and the tooling that builds it. A concrete proposal grounded in the prototype we already run.
Today an "app" is a Rust struct compiled into the OS. That is fine for first-party demos, but the glasses platform only matters if third parties can build apps without our source tree, ship them as a self-contained artifact, and have a user install one the way they install an APK — pick it, grant permissions, run it. That requires four things we do not have yet:
A distributable, versioned, signed artifact — code + manifest + assets — that installs without rebuilding the OS.
A sandbox that loads untrusted third-party code safely, with hard memory / CPU limits and no ambient capabilities.
A stable API surface + a macro that turns a developer's handler into a loadable module, hiding the wire ABI.
A CLI to scaffold, build, validate, simulate, and package — with our two-process simulator as the dev loop.
An app implements one Rust trait. launch() returns the initial view (a restricted DSL tree, Node); tick() and the event hooks return partial updates (Op) or a new view. The glasses render the DSL and resolve view-state locally; only app-state events bubble back to this code on the phone.
pub trait App: Send {
fn manifest(&self) -> Manifest; // id, name, icon
fn launch(&mut self) -> Node; // initial view (DSL)
fn tick(&mut self) -> Vec<Op> { vec![] } // periodic partial updates
fn menu_actions(&self) -> Vec<(&str, &str)> { vec![] } // context-menu items
fn on_action(&mut self, id: &str) -> Vec<Op> { vec![] } // a menu item was picked
fn on_voice(&mut self, phase: &str) -> Vec<Op> { vec![] }// long-press dictation → ASR
fn on_event(&mut self, code: u32, focus: usize) -> Option<Node> { None } // confirm/back
fn supports_fullscreen(&self) -> bool { false } // opt into full-screen
fn set_fullscreen(&mut self, on: bool) -> Node { .. } // the full-screen view
}
The catalog is a hardcoded make(id) factory; "install" just flips a flag; "launch" calls make() and drops the instance into one of three desktop slots. Everything runs in the phone process.
The trait is the right shape — a small, declarative surface that already covers views, updates, menus, voice, navigation, and full-screen. We keep it verbatim.
It needs our source to compile, runs with full host trust, has no package or versioning, and the catalog is fixed at build time. None of that survives third parties.
An app compiles to a WebAssembly module. The phone runtime loads each installed app into its own wasmtime instance — a separate linear memory, a per-callback fuel budget, and zero capabilities except the imports we hand it. This is exactly how the glasses brain already runs; we are reusing a proven sandbox, not inventing one.
installed packages phone = app authority glasses (render only) ┌───────────────┐ load .wasm ┌──────────────────────────────────────────┐ ViewBundle / Op ┌──────────────┐ │ com.acme.chat │ ───────────────▶ │ wasmtime Store (per app) │ ───── BLE ──────▶ │ shell renders │ │ app.wasm │ │ mem cap · fuel · capability imports only │ │ DSL, local │ │ manifest.toml│ capabilities │ ─────────────────────────────────────────│ AppEvent bubble │ view-state SM │ │ assets/ │ ◀───granted at──▶ │ host: storage · timers · ASR · net (gated)│ ◀──────────────── │ (3 MB cap) │ └───────────────┘ install └──────────────────────────────────────────┘ └──────────────┘
Apps run on the phone, never on the glasses. The glasses are memory- and CPU-bound (3 MB SRAM); they only render the DSL and resolve local view-state. The phone has the headroom to host many sandboxed modules. This split already exists and is the right one.
wasmtime gives memory isolation, a fuel ceiling per call (a runaway app can't hang the phone), and no syscalls unless we import them.
Anything that targets wasm32 — Rust, C/C++, TinyGo, AssemblyScript, Zig. The ABI is language-agnostic.
The DSL (shared) and the on-device LVGL renderer are unchanged. An app just produces Node/Op — it cannot draw arbitrary pixels.
We separate two questions. The package payload is always WebAssembly — that is the security and portability boundary. The SDK is the developer-facing layer on top, and it is Rust-first because the codebase, the DSL types, and the best wasm toolchain are all Rust today. Other-language SDKs can be layered onto the same ABI later.
| Option | Sandbox | DX | Verdict |
|---|---|---|---|
| WASM module, Rust-first SDK | wasmtime — strong, already in use | Write the same trait + one macro; cargo build to wasm | recommended reuses everything we built |
| WASM module, any language | identical | Lower (hand-write the ABI, or wait for per-language SDKs) | enabled, later same ABI, SDKs follow demand |
| Embedded scripting (JS / Lua) | need a separate interpreter sandbox | High for web devs, weak typing | future layer compile to wasm or ship an interpreter-as-wasm host |
| Native dynamic library (.so / .dylib) | none — full host trust | Familiar | rejected no isolation; unsafe for third parties |
The crucial property: an app can only do what its imports allow. A module with no host_net import physically cannot reach the network — capability is structural, not policy. AssemblyScript / JS support is genuinely valuable for adoption and is on the roadmap, but it rides the same ABI, so it is additive rather than a fork.
.glsappA package is a signed archive. The manifest is declarative and inspectable — the OS reads it to list the app, prompt for permissions, and build the context menu, all before the module ever executes. Code only runs after install + grant.
com.acme.chat-1.2.0.glsapp ├── manifest.toml # identity, capabilities, menu, full-screen — read WITHOUT running code ├── app.wasm # the compiled module (the only executable part) ├── assets/ # icon, vector art, subset fonts (addressed by the DSL) └── SIGNATURE # developer key → integrity + provenance
[app] id = "com.acme.chat" # reverse-DNS, globally unique name = "Chat" version = "1.2.0" # semver; the store + OS gate upgrades on it icon = "assets/icon.svg" min_os = "1.0" # host rejects modules newer than it understands [capabilities] # the ONLY powers the module gets; prompted at install storage = true # persisted key-value store, scoped to this app voice_asr = true # may request dictation (powers VoiceInput) network = ["api.acme.com"] # host-mediated egress, allow-list only timers = true # periodic tick() wakeups [[menu]] # context-menu items — STATIC, see §07 id = "mark_read" label = "Mark all read" [[menu]] id = "mute" label = "Mute 1 hour" [fullscreen] # opt into full-screen — see §08 supported = true
The contract mirrors today's trait, one wasm function per method, with the DSL passed as serialized bytes through a shared buffer — the exact inbox_ptr / length pattern the glasses brain already uses. The host drives the module; the module calls back only through granted imports.
app_launch() | → ViewBundle |
app_tick() | → Ops |
app_on_action(id) | → Ops |
app_on_voice(phase) | → Ops |
app_on_event(code, focus) | → ViewBundle? |
app_set_fullscreen(on) | → ViewBundle |
app_inbox_ptr() / _cap() | shared buffer |
host_log(ptr,len) | always |
host_now() → u64 | always |
host_storage_get/set | storage |
host_asr_request() | voice_asr |
host_http(req) | network |
host_timer(ms) | timers |
Bytes cross via the shared buffer (write args to app_inbox_ptr(), call the export, read the returned pointer/length) — no pointers are dereferenced across the boundary, so the module stays fully sandboxed. A binary encoding of Node/Op replaces JSON here for size, which also tightens the BLE budget downstream (see §10).
The whole point of the Rust SDK: a developer writes the trait they already write today, and a macro emits the wasm exports, the buffer plumbing, and the (de)serialization. The ABI is invisible.
use gls_sdk::*; // the SDK crate: App, Node, Op, host::*
struct Chat { open: Option<usize>, msgs: Vec<String> }
impl App for Chat {
fn launch(&mut self) -> Node {
Screen("CHAT", List(contacts())) // DSL builders
}
fn on_event(&mut self, code: u32, focus: usize) -> Option<Node> {
if code == CONFIRM { self.open = Some(focus); Some(self.conversation()) } else { None }
}
fn on_action(&mut self, id: &str) -> Vec<Op> {
if id == "mark_read" { host::storage::set("unread", "0"); } // a granted capability
vec![]
}
fn supports_fullscreen(&self) -> bool { true }
fn set_fullscreen(&mut self, on: bool) -> Node { self.view(on) }
}
register_app!(Chat); // ← emits app_launch / app_on_event / … wasm exports
The SDK ships the shared DSL types, ergonomic builders (Screen, List, RunningText, VoiceInput, …), and thin wrappers over the capability imports (host::storage, host::asr). A C / AssemblyScript SDK would expose the same shapes over the same ABI.
Context-menu items move from a runtime call to a static manifest declaration. The shell already renders the menu without involving the app (it is a host-built list over the link); reading the items from the manifest makes that cheaper, lets the store preview them, and means a malformed app can't inject surprises into the system menu. The system owns Close and the Full Screen toggle; the app contributes the rest.
declaration (manifest) shell renders user picks → app handles
[[menu]] ┌─────────────────┐
id = "mark_read" │ CLOSE APP │ (system)
label = "Mark all read" ───▶ │ FULL SCREEN │ (system, if [fullscreen]) ──▶ app_on_action("mark_read")
│ Mark all read │ (from manifest) → returns Op[] (partial update)
[[menu]] │ Mute 1 hour │
id = "mute" … └─────────────────┘
Close always; Full Screen / Exit Full Screen when the manifest opts in. The shell adds these — apps can't remove them.
Each manifest [[menu]] entry is an id + label (later: icon, enablement, destructive flag). Selecting one calls app_on_action(id); the app runs its own logic and returns updates.
This is now implemented end-to-end and is the template for richer view models. The manifest's [fullscreen] supported = true makes the shell offer the toggle; when picked, the host calls app_set_fullscreen(true) and the app returns a view designed for the whole 1280×720 — more list rows, a larger canvas, a different layout. The app owns that decision; the shell only changes geometry (full-bleed, neighbours hidden, gaze suspended).
menu: FULL SCREEN ──▶ host calls app_set_fullscreen(true)
│
Conversate ───────┴──▶ windowed: RunningText, 13 lines in a tile
full: RunningText, 20 lines across the display (same stream, more shown)
Vector Image ──────────▶ windowed: image scaled to a tile column
full: same DSL, scaled to the full width (the renderer adapts)
Full-screen is really "the app has more than one view." The same mechanism extends to named views / routes the manifest can declare (e.g. list, detail, compose), so tooling can preview each and the shell can deep-link. An app is a small state machine returning Node trees; the host drives which one is shown.
Apps that don't opt in stay windowed-only and never see app_set_fullscreen — verified: the Timer app shows no full-screen item, Conversate and Vector Image do.
glsdk CLIThe biggest DX win is already built: the two-process simulator (phone runtime + glasses viewer + live telemetry) becomes glsdk sim. A developer sees their app on a real LVGL render, drives gestures, and watches per-tap latency, BLE bytes, memory, and fuel — without hardware.
| command | does |
|---|---|
glsdk new myapp | scaffold a Rust crate: the App stub, a manifest.toml, a sample view. |
glsdk build | cargo build --target wasm32 + bundle wasm + manifest + assets into .glsapp. |
glsdk validate | static checks: manifest schema, required exports present, DSL nodes within the allowed vocabulary, capability sanity, bundle-size budget. |
glsdk sim | load the module into a simulator slot and open the glasses viewer + telemetry — the live dev loop. |
glsdk package / sign | produce the signed, distributable artifact. |
Because the glasses brain and the app share the same sandbox model, validate can measure exactly what ships: render fuel per view, bytes per bundle (BLE on-air time), and peak module memory.
Store or sideload → verify signature + manifest schema + min_os → show the capability prompt (the granted imports) → register in the catalog. The phone's App Manager UI already models install / launch / suspend / resume / close — it just sources the catalog from installed packages instead of a hardcoded list.
Launch instantiates the module into a slot's Store; suspend/resume gate its tick(); close drops the Store (memory reclaimed). Persisted state survives via the storage capability, not the instance.
Per-app linear-memory cap, a fuel ceiling per callback (a bad app stalls itself, not the OS), and a bundle-size budget — large views cost real BLE latency, which the telemetry makes visible (a 9 KB image ≈ 150 ms on-air vs ≈ 25 ms for a small patch).
The DSL vocabulary and ABI are versioned. The manifest's min_os and a host capability list let the OS refuse a module that uses nodes or imports it doesn't support — forward and backward safe.
| Piece | Today | Platform | Effort |
|---|---|---|---|
DSL (shared) | Rust types over the link | unchanged — also the SDK's types | none |
| On-device renderer (LVGL) | renders Node | unchanged | none |
App trait | in-process | same trait, now in the SDK crate | none |
| Catalog | hardcoded make(id) | registry of installed packages | small |
| App runtime | direct Rust calls | wasmtime loader + ABI glue (reuse the glasses host) | medium |
| Capabilities | full host trust | imports gated by manifest grants | medium |
| Package + CLI | — | .glsapp format + glsdk | new |
| Simulator | internal tool | glsdk sim — the dev loop | repackage |
(1) Define the ABI + binary DSL encoding and move one existing app behind it as a wasm module loaded by the phone. (2) Add the manifest, capability imports, and the .glsapp loader/registry. (3) Ship glsdk (new / build / validate / sim / package). (4) Layer non-Rust SDKs and a store on top. Each phase is independently demoable in the simulator.