···259259bunx expo export --platform web # smoke-test the bundle (catches NativeWind transform issues)
260260```
261261262262+### Native gRPC client — `crates/expo/` + `expo/modules/rockbox-rpc/`
263263+264264+The mobile app talks to rockboxd through a native module that wraps a real
265265+tonic gRPC client written in Rust. It is split in two halves:
266266+267267+**`crates/expo/`** — `rockbox-expo` Rust crate, `staticlib + cdylib`.
268268+- Generates client-only proto bindings in `build.rs` from the shared
269269+ `crates/rpc/proto` tree (linked in via the `proto -> ../rpc/proto` symlink
270270+ inside the crate so we don't duplicate `.proto` files).
271271+- Owns a single multi-thread Tokio runtime via `once_cell`.
272272+- Exposes a flat C ABI (`rb_set_server_url`, `rb_ping`, `rb_play`, `rb_pause`,
273273+ `rb_play_pause`, `rb_next`, `rb_prev`, `rb_seek`, `rb_status_json`,
274274+ `rb_current_track_json`, `rb_like_track`, `rb_unlike_track`,
275275+ `rb_free_string`). Complex responses are returned as heap-allocated JSON
276276+ C strings — caller MUST free via `rb_free_string`. Simple ops return `i32`
277277+ status codes (0 = ok, <0 = error).
278278+- Deliberately does NOT depend on `rockbox-rpc` to avoid pulling sqlx /
279279+ typesense / library transitive deps that fight cross-compilation.
280280+281281+**`expo/modules/rockbox-rpc/`** — Expo SDK 54 native module.
282282+- `expo-module.config.json` declares iOS + Android module classes; the module
283283+ is autolinked into the app via `expo/package.json` (`"rockbox-rpc": "file:./modules/rockbox-rpc"`).
284284+- iOS: `ios/RockboxRpcModule.swift` declares each `rb_*` symbol with
285285+ `@_silgen_name(...)` and exposes them through `Function` / `AsyncFunction`.
286286+ The static library is delivered as `ios/RockboxExpo.xcframework` (built by
287287+ `scripts/build-ios.sh`); the `.podspec` `vendored_frameworks` it.
288288+- Android: `android/src/main/java/expo/modules/rockboxrpc/RockboxRpcModule.kt`
289289+ uses `System.loadLibrary("rockbox_expo")` + JNI `external fun` declarations.
290290+ The `.so` per ABI is dropped into `android/src/main/jniLibs/<abi>/` by
291291+ `scripts/build-android.sh` (uses `cargo-ndk`).
292292+- TS facade: `expo/modules/rockbox-rpc/src/index.ts` declares the JS surface;
293293+ `expo/lib/rockbox-client.ts` is the in-app helper with an `isAvailable`
294294+ flag so callers can fall back to the mock `PlayerProvider` on web or when
295295+ the libs haven't been built yet.
296296+297297+#### Building the native libs
298298+299299+```sh
300300+# iOS — produces expo/modules/rockbox-rpc/ios/RockboxExpo.xcframework
301301+rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
302302+cd expo/modules/rockbox-rpc
303303+bun run build:ios
304304+305305+# Android — produces expo/modules/rockbox-rpc/android/src/main/jniLibs/<abi>/librockbox_expo.so
306306+cargo install cargo-ndk
307307+rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
308308+export ANDROID_NDK_HOME=... # NDK r25+
309309+bun run build:android
310310+```
311311+312312+After the native libs are in place, run `bunx expo prebuild` and then
313313+`bunx expo run:ios` / `run:android` to bundle them into the app.
314314+315315+#### Adding new RPCs
316316+317317+1. Add a thin wrapper in `crates/expo/src/lib.rs` (`rb_<name>` returning
318318+ `c_int` for unit ops or `*mut c_char` for JSON-bearing reads).
319319+2. Add the matching extern declaration in both
320320+ `expo/modules/rockbox-rpc/ios/RockboxRpcModule.swift` and
321321+ `expo/modules/rockbox-rpc/android/src/main/java/.../RockboxRpcModule.kt`,
322322+ plus an `AsyncFunction` binding.
323323+3. Add the typed method to `expo/modules/rockbox-rpc/src/index.ts` and the
324324+ forwarding helper in `expo/lib/rockbox-client.ts`.
325325+4. Rebuild the native libs (`build:ios` / `build:android`) — `metro` doesn't
326326+ pick up native changes automatically.
327327+328328+#### Streaming subscriptions
329329+330330+Server-streaming RPCs (`StreamStatus`, `StreamCurrentTrack`, `StreamPlaylist`)
331331+are exposed as JS events — not async iterators — to play nicely with React's
332332+render loop. The pipeline is:
333333+334334+```
335335+tonic stream
336336+ → tokio mpsc<String> (one queue per subscription, in crates/expo)
337337+ → rb_poll_event(id, timeout_ms) -> *mut c_char
338338+ → Swift dispatch_async / Kotlin Dispatchers.IO loop
339339+ → sendEvent("rockbox.<topic>", payload) (Expo Modules EventEmitter)
340340+ → RockboxRpc.addListener("rockbox.<topic>", cb)
341341+```
342342+343343+Each `subscribe*` returns an opaque numeric subscription id; the JS facade in
344344+`expo/lib/rockbox-client.ts` wraps that with an `() => void` unsubscribe
345345+helper that removes both the event listener and the native subscription:
346346+347347+```ts
348348+const unsubscribe = RockboxClient.subscribeStatus(
349349+ (s) => console.log("status", s.status),
350350+ (e) => console.warn("stream error", e.error),
351351+);
352352+// later: unsubscribe();
353353+```
354354+355355+Topics today: `rockbox.status`, `rockbox.currentTrack`, `rockbox.playlist`,
356356+`rockbox.library`, `rockbox.discovery` (LAN mDNS / Bonjour scan via the
357357+`rockbox-discovery` crate — emits one `DiscoveredService` per resolved peer),
358358+plus `rockbox.error` for stream failures (carries `subId`, `stream`, `error`).
359359+360360+The `subscribeDiscovery` helper defaults to the `_rockbox._tcp.local.`
361361+service; pass any other Bonjour service name (e.g. `_googlecast._tcp.local.`)
362362+to scan for Chromecast / etc. Constants are also surfaced on the JS side via
363363+`RockboxClient.rockboxServiceName()` and `RockboxClient.chromecastServiceName()`.
364364+365365+To add a new streamed RPC: add a `rb_subscribe_<name>` in `crates/expo/src/lib.rs`
366366+that follows the `spawn_stream(...)` pattern, declare the matching event topic
367367+in the iOS / Android `Events(...)` lists, register a `Function("subscribe<Name>")`
368368+in both modules, and add the typed `subscribe<Name>(cb, onError?)` helper to
369369+`expo/lib/rockbox-client.ts`.
370370+262371## Useful commands
263372264373```sh
···11+[package]
22+name = "rockbox-expo"
33+version = "0.1.0"
44+edition = "2021"
55+description = "C-ABI gRPC client wrapper for embedding in the rockbox-zig Expo (React Native) mobile app"
66+license = "LGPL-2.1"
77+88+[lib]
99+name = "rockbox_expo"
1010+crate-type = ["staticlib", "cdylib"]
1111+1212+[dependencies]
1313+prost = "0.13"
1414+tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
1515+tonic = { version = "0.12", default-features = false, features = ["transport", "codegen", "prost"] }
1616+once_cell = "1"
1717+serde = { version = "1", features = ["derive"] }
1818+serde_json = "1"
1919+futures-util = "0.3"
2020+rockbox-discovery = { path = "../discovery" }
2121+2222+[build-dependencies]
2323+tonic-build = { version = "0.12", default-features = false, features = ["prost", "transport"] }
+136
crates/expo/README.md
···11+# `rockbox-expo` — C-ABI gRPC client for the Expo mobile app
22+33+This crate produces a `staticlib` + `cdylib` that wraps the rockboxd gRPC
44+client (built with [`tonic`]) and exposes a flat C ABI suitable for embedding
55+in iOS (`.xcframework`) and Android (`jniLibs/<abi>/librockbox_expo.so`)
66+builds. The companion [Expo Modules wrapper](../../expo/modules/rockbox-rpc/)
77+loads the resulting library and forwards calls through React Native.
88+99+It is the **mobile counterpart** to the desktop client in [`gpui/`](../../gpui/);
1010+the surface mirrors `gpui/src/client.rs` 1:1 wherever it makes sense.
1111+1212+## Why a separate crate?
1313+1414+The `rockbox-rpc` crate (which the rockboxd server uses) pulls in heavy
1515+dependencies — `sqlx`, `typesense`, `library`, `reqwest` with native TLS,
1616+`rocksky`, etc. — that are painful to cross-compile to iOS / Android. This
1717+crate keeps deps minimal:
1818+1919+- `tonic` (transport + codegen + prost), client only
2020+- `tokio` runtime (multi-thread, 2 worker threads)
2121+- `prost`, `serde`, `serde_json`, `once_cell`, `futures-util`
2222+- `rockbox-discovery` for LAN mDNS / Bonjour scans
2323+2424+Proto bindings are generated from `proto/` (a symlink to
2525+`../rpc/proto`) via [`tonic-build`] in `build.rs`, with
2626+`build_server(false)` and a `type_attribute(".", "#[derive(serde::Serialize)]")`
2727+configuration so any response can be JSON-serialized in one line.
2828+2929+## Layout
3030+3131+```
3232+crates/expo/
3333+├── Cargo.toml staticlib + cdylib, slim deps
3434+├── build.rs tonic-build (client only) + serde derive on every proto
3535+├── proto -> ../rpc/proto shared with the rest of the workspace
3636+└── src/lib.rs runtime, FFI surface, subscriptions
3737+```
3838+3939+## ABI conventions
4040+4141+- All entry points are prefixed `rb_*` and exported with `#[no_mangle]`.
4242+- Unit operations return `i32` — `0` on success, negative on error.
4343+- Reads return `*mut c_char` — heap-owned JSON. Caller **must** free via
4444+ `rb_free_string`. Errors come back as `{ "error": "..." }` JSON objects;
4545+ the platform glue checks for that key and throws.
4646+- Strings flow in as `*const c_char` (NUL-terminated UTF-8); collections
4747+ flow in as JSON-array C strings to keep the FFI narrow.
4848+4949+## Surface map
5050+5151+| Group | Examples |
5252+|-------|----------|
5353+| Init | `rb_set_server_url`, `rb_ping` |
5454+| Playback | `rb_play / pause / play_pause / next / prev`, `rb_seek`, `rb_play_album / play_artist_tracks / play_track / play_directory` |
5555+| Queue | `rb_jump_to_queue_position`, `rb_insert_tracks`, `rb_insert_track_next / last`, `rb_remove_from_queue`, `rb_shuffle_playlist`, `rb_get_playlist_current_json` |
5656+| Library | `rb_get_tracks_json`, `rb_get_artists_json`, `rb_get_album_json`, `rb_search_json`, `rb_like_track / unlike_track`, `rb_get_liked_tracks_json` |
5757+| Sound / Settings | `rb_adjust_volume`, `rb_sound_current_json`, `rb_save_shuffle / save_repeat`, `rb_get_global_settings_json`, `rb_get_global_status_json` |
5858+| Browse | `rb_tree_get_entries_json` |
5959+| Saved playlists | `rb_get_saved_playlists_json`, `rb_create_saved_playlist`, `rb_update_saved_playlist`, `rb_delete_saved_playlist`, `rb_add_track_to_playlist`, `rb_remove_track_from_playlist`, `rb_get_saved_playlist_tracks_json`, `rb_play_saved_playlist` |
6060+| Smart playlists | `rb_get_smart_playlists_json`, `rb_get_smart_playlist_tracks_json`, `rb_play_smart_playlist` |
6161+| Bluetooth | `rb_bluetooth_available`, `rb_get_bluetooth_devices_json`, `rb_connect_bluetooth`, `rb_disconnect_bluetooth` |
6262+| Server-streaming | `rb_subscribe_status`, `rb_subscribe_current_track`, `rb_subscribe_playlist`, `rb_subscribe_library`, `rb_subscribe_discovery(serviceName)` |
6363+| Stream pump | `rb_poll_event(subId, timeoutMs)`, `rb_unsubscribe(subId)` |
6464+| Discovery constants | `rb_rockbox_service_name`, `rb_chromecast_service_name` |
6565+| Memory | `rb_free_string` |
6666+6767+## Streaming subscriptions
6868+6969+Server-streaming RPCs and the mDNS scan share one model:
7070+7171+```text
7272+tonic / mdns-sd stream
7373+ → tokio mpsc<String> (one queue per subscription)
7474+ → rb_poll_event(id, timeout_ms) -> *mut c_char
7575+ → Swift dispatch_async / Kotlin Dispatchers.IO loop
7676+ → sendEvent("rockbox.<topic>", payload)
7777+```
7878+7979+`rb_subscribe_*` returns an opaque `i32` subscription id. Each event JSON is
8080+the prost message for the topic (e.g. `StatusResponse`, `CurrentTrackResponse`,
8181+`PlaylistResponse`) or a `DiscoveredService` snapshot for the mDNS topic.
8282+8383+Topics: `rockbox.status`, `rockbox.currentTrack`, `rockbox.playlist`,
8484+`rockbox.library`, `rockbox.discovery`. Stream errors propagate as
8585+`{ "error": "..." }` payloads on the same channel; the platform glue
8686+re-emits them on `rockbox.error`.
8787+8888+## Building
8989+9090+Host-only sanity check:
9191+9292+```sh
9393+cargo check -p rockbox-expo
9494+```
9595+9696+Cross-compile for mobile (driven by the [Expo module's build scripts](../../expo/modules/rockbox-rpc/scripts/)):
9797+9898+```sh
9999+# iOS — produces ../../expo/modules/rockbox-rpc/ios/RockboxExpo.xcframework
100100+rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
101101+( cd ../../expo/modules/rockbox-rpc && bun run build:ios )
102102+103103+# Android — produces ../../expo/modules/rockbox-rpc/android/src/main/jniLibs/<abi>/librockbox_expo.so
104104+cargo install cargo-ndk
105105+rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
106106+export ANDROID_NDK_HOME=... # NDK r25+
107107+( cd ../../expo/modules/rockbox-rpc && bun run build:android )
108108+```
109109+110110+## Adding a new RPC
111111+112112+1. Add a `rb_<name>` wrapper in `src/lib.rs`. For unit ops, use the
113113+ `simple_call!` macro or write `run_unit(async move { ... })`. For reads,
114114+ `unwrap_or_err_string(res.map(|r| r.into_inner()))` does the JSON wrap.
115115+2. Add the matching extern + `Function` / `AsyncFunction` in both
116116+ `expo/modules/rockbox-rpc/ios/RockboxRpcModule.swift` and
117117+ `.../RockboxRpcModule.kt`.
118118+3. Add the typed signature to `expo/modules/rockbox-rpc/src/index.ts` and a
119119+ one-line forwarder on `RockboxClient` in `expo/lib/rockbox-client.ts`.
120120+4. Rebuild the native libs (`build:ios` / `build:android`); Metro doesn't
121121+ pick up native changes automatically.
122122+123123+For server-streaming RPCs, follow the `spawn_stream(...)` pattern, declare
124124+the matching event topic in `Events(...)` on both platforms, register a
125125+`Function("subscribe<Name>")` Function, and add a typed
126126+`subscribe<Name>(cb, onError?)` helper to `expo/lib/rockbox-client.ts`.
127127+128128+## Skipped vs. `gpui/src/client.rs`
129129+130130+The HTTP-REST device endpoints (`fetch_devices`, `connect_device`,
131131+`disconnect_device`) are not gRPC and aren't covered by this crate. The
132132+`run_*_sync` driver loops are also not exposed — the JS side can call the
133133+underlying unary RPCs directly and orchestrate its own caching.
134134+135135+[`tonic`]: https://docs.rs/tonic
136136+[`tonic-build`]: https://docs.rs/tonic-build
+36
crates/expo/build.rs
···11+fn main() -> Result<(), Box<dyn std::error::Error>> {
22+ // Generate client-only bindings from the shared proto files. The local
33+ // `proto` directory is a symlink to `../rpc/proto` so we read through the
44+ // same path the rest of the workspace uses (`crates/rpc/proto`) without
55+ // duplicating files. We deliberately avoid depending on `rockbox-rpc` to
66+ // keep the mobile crate slim (no sqlx / typesense / library transitive
77+ // deps that fight cross-compilation).
88+ let protos = [
99+ "proto/rockbox/v1alpha1/browse.proto",
1010+ "proto/rockbox/v1alpha1/library.proto",
1111+ "proto/rockbox/v1alpha1/metadata.proto",
1212+ "proto/rockbox/v1alpha1/playback.proto",
1313+ "proto/rockbox/v1alpha1/playlist.proto",
1414+ "proto/rockbox/v1alpha1/settings.proto",
1515+ "proto/rockbox/v1alpha1/sound.proto",
1616+ "proto/rockbox/v1alpha1/system.proto",
1717+ "proto/rockbox/v1alpha1/saved_playlist.proto",
1818+ "proto/rockbox/v1alpha1/smart_playlist.proto",
1919+ "proto/rockbox/v1alpha1/bluetooth.proto",
2020+ ];
2121+2222+ // Derive serde::Serialize on every generated proto type so we can ship
2323+ // entire responses to JS as JSON in one line. (Deserialize is not derived
2424+ // — we only serialize *out* of Rust today.)
2525+ tonic_build::configure()
2626+ .build_server(false)
2727+ .build_client(true)
2828+ .type_attribute(".", "#[derive(serde::Serialize)]")
2929+ .compile_protos(&protos, &["proto"])?;
3030+3131+ for p in &protos {
3232+ println!("cargo:rerun-if-changed={p}");
3333+ }
3434+3535+ Ok(())
3636+}
···11+#!/usr/bin/env node
22+// Replace `node_modules/rockbox-rpc` with a symlink to `modules/rockbox-rpc`.
33+//
44+// Bun resolves `"file:./modules/rockbox-rpc"` by *copying* the directory into
55+// `node_modules`, which means edits to the source module aren't visible until
66+// you re-run `bun install`. We need a live symlink so TS picks up new exports
77+// and Metro picks up native module changes immediately. This runs as the
88+// `postinstall` hook so every install converges on the symlinked layout.
99+1010+const fs = require("node:fs");
1111+const path = require("node:path");
1212+1313+const expoRoot = path.resolve(__dirname, "..");
1414+const target = path.join(expoRoot, "node_modules", "rockbox-rpc");
1515+const source = path.join(expoRoot, "modules", "rockbox-rpc");
1616+const relativeSource = path.relative(path.dirname(target), source);
1717+1818+if (!fs.existsSync(source)) {
1919+ console.warn(`[link-rockbox-rpc] source missing: ${source}`);
2020+ process.exit(0);
2121+}
2222+2323+// Skip if it's already the right symlink.
2424+try {
2525+ const stat = fs.lstatSync(target);
2626+ if (stat.isSymbolicLink()) {
2727+ const current = fs.readlinkSync(target);
2828+ if (path.resolve(path.dirname(target), current) === source) {
2929+ console.log("[link-rockbox-rpc] already linked → ok");
3030+ process.exit(0);
3131+ }
3232+ }
3333+ fs.rmSync(target, { recursive: true, force: true });
3434+} catch (err) {
3535+ if (err.code !== "ENOENT") throw err;
3636+}
3737+3838+fs.mkdirSync(path.dirname(target), { recursive: true });
3939+fs.symlinkSync(relativeSource, target, "dir");
4040+console.log(`[link-rockbox-rpc] linked ${target} → ${relativeSource}`);