Source: handbook/ in the repository. Every code block compiles: CI runs ray check on each one.
Mobile app for iOS and Android
In this chapter you build Notes, a notes app that runs on the iPhone and on Android with a
React + TypeScript interface and its data stored on the phone itself with std/kv. It is a single
raylang program: the same code opens a desktop window while you develop and is packaged as a
native app for each phone.
The complete project is in
examples/apps/notes-mobile, with its tests. Every raylang
block on this page is copied from that project, and CI checks that it still is.
What you need
| For | You need |
|---|---|
| Any target | raylang and Node.js with npm, for the frontend |
| Compiling to native | a Rust toolchain (ray toolchain install installs a private one) |
| iOS | a Mac with Xcode, and the iOS targets: ray toolchain add-target ios |
| Android | the Android SDK and NDK (Android Studio installs them), Gradle 9 with JDK 17 or later, and the Android targets: ray toolchain add-target android |
ray toolchain add-target installs the Rust standard library for those targets into the toolchain
ray uses, whether your own or the private one ray toolchain install sets up (which is not on the
PATH, so a manual rustup target add does not reach it). Installing the private one from scratch is
one step: ray toolchain install --targets ios,android.
None of that is needed to start: up to section 7 the app is developed and tested on the desktop, with just raylang and Node.js.
1. How a raylang mobile app is built
A raylang app on the phone has two parts that live in the same process:
- The shell is a minimal native app that
ray bundlegenerates: an Xcode project on iOS and a Gradle project on Android. It shows a full-screen webview and starts your program. - Your raylang program, compiled to machine code as a library. It opens the interface with
ui.open, answers the page through a message bridge and stores its data wherever it wants.
The page does not talk HTTP to the program: it calls window.ray.request(...), the message
reaches the program as an event, and the answer resolves the page's Promise. There is no server
and no open port, so no other app on the phone can talk to yours.
the app: one process
┌────────────────────┐ window.ray.request(…) ┌────────────────────┐
│ the shell's webview│ ────────────────────────▶ │ raylang program │
│ React + TypeScript │ ◀──────────────────────── │ (machine code) │
└────────────────────┘ ui.reply_json(…) └────────────────────┘
page served from data: std/kv,
ray://app/ (embedded) files, network
The split of work follows from that: the page draws and collects what the user does; the program stores the data and talks to the network and the system. The page never touches the disk.
The interface is served from inside the binary through the ray://app/ scheme. During
development, the Vite server loads it instead, with hot reload.
2. Create the project
ray new has a template with a Vite frontend. Any npm create vite template works; here, React
with TypeScript:
ray new notes-mobile --frontend react-ts
cd notes-mobile
npm create vite@latest frontend -- --template react-ts
npm --prefix frontend install
The resulting ray.toml declares the frontend, and we add the app's identity:
[package]
name = "notes-mobile"
version = "0.1.0"
[app]
name = "Notes" # the name under the icon
id = "dev.raylang.notes" # the iOS bundle id and the Android application id
[dependencies]
[frontend]
dev = "npm --prefix frontend run dev -- --strictPort --port 5173 --clearScreen false"
url = "http://localhost:5173"
build = "npm --prefix frontend run build"
dist = "frontend/dist"
With [frontend], ray dev starts Vite for you, and ray build --native and ray bundle build
the frontend and put frontend/dist inside the binary.
3. The model and the store
Each note is a struct. @derive(ToJson) generates its to_json(), which serves both to store it
and to send it to the page:
/// A note as it is stored and as the page receives it.
@derive(ToJson)
pub struct Note {
id: string,
title: string,
body: string,
updated_ms: int,
}
The data goes into std/kv, a key-value store in a single file. Each note is one key (its id, a
uuid v7, which sorts by creation time) and its JSON is the value. save() writes the file
atomically: first a temporary file, then a rename. If the system kills the app halfway through
a write, the previous file stays intact. On a phone that is not a detail: iOS and Android close
background apps without warning.
/// Creates a note (empty `id`) or replaces an existing one, and persists the store.
pub fn save(
store: kv.Store,
id: string,
title: string,
body: string,
now_ms: int
) -> Result<Note, string> {
let clean = title.trim();
if (clean.len() == 0) {
return Result.Err("a note needs a title");
}
let key = if (id == "") { uuid.uuid_v7_at(now_ms) } else { id };
if (id != "" && store.get(key).is_none()) {
return Result.Err("no note with id " + id);
}
let note = Note { id: key, title: clean, body: body, updated_ms: now_ms };
store.set_string(key, note.to_json());
store.save()?;
Result.Ok(note)
}
Errors are values: a note without a title returns Err, and ? propagates a disk failure from
store.save() to the caller.
Where the data lives
On the phone there is no useful working directory: the program starts with the current directory
at /. The right place depends on the system, and $HOME resolves it on both:
| System | $HOME is |
The notes end up in | To look at them |
|---|---|---|---|
| iOS | the app container; its root is read-only, Documents/ is writable |
<container>/Documents/Notes |
xcrun simctl get_app_container booted dev.raylang.notes data prints the path on the simulator |
| Android | the app's private folder, files/ |
files/Documents/Notes |
adb shell run-as dev.raylang.notes ls files/Documents/Notes |
| Desktop | the user's home folder | ~/Documents/Notes |
the file manager |
Data in the private folder is deleted when the app is uninstalled, and no other app can read it.
fn data_dir() -> string {
match (env("NOTES_DIR")) {
Option.Some(d) => d,
Option.None => match (env("HOME")) {
Option.Some(home) => home + "/Documents/Notes",
Option.None => "notes-data",
},
}
}
NOTES_DIR lets tests or CI change the folder without touching the code.
4. The protocol with the page
Every request goes through one function: it receives the JSON the page sent and returns the JSON
of the answer. Since it does not depend on any window, ray test tests it like any other function.
/// Answers one request from the page. `now_ms` comes from the caller so tests are deterministic.
pub fn handle(store: kv.Store, body: string, now_ms: int) -> string {
let req = match (json.parse(body)) {
Result.Ok(j) => j,
Result.Err(e) => return fail("bad request: " + e),
};
let op = json.get_string(req, "op").unwrap_or("");
if (op == "list") {
return ok(notes.list(store));
}
if (op == "save") {
let id = json.get_string(req, "id").unwrap_or("");
let title = json.get_string(req, "title").unwrap_or("");
let text = json.get_string(req, "body").unwrap_or("");
return match (notes.save(store, id, title, text, now_ms)) {
Result.Ok(_) => ok(notes.list(store)),
Result.Err(e) => fail(e),
};
}
if (op == "delete") {
let id = json.get_string(req, "id").unwrap_or("");
return match (notes.remove(store, id)) {
Result.Ok(_) => ok(notes.list(store)),
Result.Err(e) => fail(e),
};
}
fail("unknown op '" + op + "'")
}
Every operation answers with the full list. For a notes app it is the simplest option: the interface repaints from a single source of truth and there is no state to reconcile.
5. The program
main opens the store, mounts the embedded frontend, opens the window and handles events. The loop
receives three kinds of event:
while (true) {
let e = match (ui.next_event()) {
Result.Err(err) => {
eprint("ui: " + err);
return 1;
},
Result.Ok(e) => e,
};
// On the desktop, closing the window ends the app. On a phone it never arrives: the
// system suspends or kills the process, which is why every change is already saved.
if (e.kind == "closed" && e.window == window) {
return 0;
}
if (e.kind == "lifecycle") {
print("notes: app moved to the " + e.tag);
}
if (e.kind == "message") {
match (ui.as_request(e)) {
Option.Some(request) => {
let (id, body) = request;
let answer = api.handle(store, body, time.now());
let _ = ui.reply_json(e.window, id, answer);
},
Option.None => { },
}
}
}
"message"is a request from the page.ui.as_requestdecodes it andui.reply_jsonanswers: the page's Promise resolves with an object, not with text."lifecycle"tells you when the app goes to the background (tag == "background") or comes back ("foreground"). Notes has nothing to do, because every change is already saved."closed"only arrives on the desktop. On the phone, the system suspends or kills the process without closing any window.
On the phone, ui.open does not create a new window: it hands the URL to the shell's webview. That
is why the same main works on the desktop and on mobile.
6. The interface in React
The frontend is a normal React app. Everything it knows about raylang is in a small module that
wraps window.ray.request with types:
async function call(request: Record<string, string>): Promise<Note[]> {
if (!window.ray) {
throw new Error('No raylang program behind this page: open it with `ray dev`.')
}
const answer = (await window.ray.request(request)) as Answer
if (!answer.ok) {
throw new Error(answer.error)
}
return answer.notes
}
export const listNotes = () => call({ op: 'list' })
export const saveNote = (id: string, title: string, body: string) =>
call({ op: 'save', id, title, body })
export const deleteNote = (id: string) => call({ op: 'delete', id })
The webview injects window.ray into every page it loads, both the Vite server during development
and the build embedded in the app. That is why the same code works in both cases. The main
component calls listNotes(), saveNote() and deleteNote() and repaints with the list they
return. It is in frontend/src/App.tsx.
Two CSS details matter on a phone:
viewport-fit=coverin the<meta name="viewport">ofindex.html, together withenv(safe-area-inset-top)andenv(safe-area-inset-bottom)in the CSS, keeps the content away from the iPhone's dynamic island and home indicator.- Text fields use
font-size: 16pxor more (Notes uses 17): below that, iOS zooms into the field when it gets focus and the page is left shifted.
7. Try it on the desktop
Before touching a phone, the whole app runs on the Mac, Linux or Windows:
ray test # the model and the protocol, without a window
ray dev # Vite + the program in a native window
ray dev starts the Vite server, waits until it answers and opens the window on it. If you edit a
React component, the page updates without reloading. If you edit a .ray file, only the program
restarts. The Notes tests create a store in a temporary folder and set the clock, so they are
deterministic:
@test
fn notes_survive_reopening_the_store() {
let dir = fs.make_temp_dir("notes-reopen").unwrap();
let s = kv.open(dir + "/notes.kv").unwrap();
let _ = notes.save(s, "", "Persisted", "", 1000).unwrap();
let again = kv.open(dir + "/notes.kv").unwrap();
assert_eq(notes.list(again)[0].title, "Persisted");
}
8. iOS
ray bundle --ios
ray bundle --ios builds the frontend, compiles the program as a static library for the iPhone
and the simulator, and generates an Xcode project in Notes-ios/. The simulator needs no
signing:
cd Notes-ios
xcodebuild -project Notes.xcodeproj -target Notes -sdk iphonesimulator \
-configuration Debug build CODE_SIGNING_ALLOWED=NO
xcrun simctl boot "iPhone 15 Pro"
xcrun simctl install booted build/Debug-iphonesimulator/Notes.app
xcrun simctl launch --console-pty booted dev.raylang.notes
With --console-pty, what the program prints shows up in your terminal: on start you will see
notes: app moved to the foreground. --ios-target sim builds only the simulator library, which
is quicker while you are not testing on a phone. The app icon comes from [app] icon in
ray.toml, a PNG.
For a real iPhone, declare your development team once in ray.toml and open the project in
Xcode:
[ios]
development_team = "ABCDE12345"
Every ray bundle --ios writes it into the project configuration. Picking the team only in Xcode
is not enough, because regenerating the bundle rewrites the project. If you only change the
program or the frontend there is no need to regenerate: the command that ray bundle prints
rebuilds the library and leaves the Xcode project as it was.
9. Android
ray bundle --android
It generates a Gradle project in Notes-android/ with the program compiled as a .so. With an
emulator or a phone connected:
cd Notes-android
gradle assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n dev.raylang.notes/org.raylang.shell.MainActivity
adb logcat -s ray # what the program prints
--android-abi arm64|x86_64|all picks the architecture: the emulator on an Apple Silicon Mac is
arm64. To publish, create a release.jks with keytool and a keystore.properties at the root
of the generated project; gradle assembleRelease produces the signed APK. Both files survive
regenerating the bundle and the passwords never go through ray.toml. The README the bundle
generates has the step-by-step.
On Android the data stays in the app's private folder. You can see it with
adb shell run-as dev.raylang.notes ls files/Documents/Notes.
10. Iterate on the phone without reinstalling
Rebuilding and installing the app for every change takes minutes. ray dev --device brings it down
to seconds: you install a development app once and, from then on, every time you save a .ray
file the new program is sent to the phone and restarts there.
| Step | How often |
|---|---|
ray bundle --ios --dev (or --android --dev) and install the resulting app |
once per phone |
ray dev --device and scan the QR code |
once per project |
| Save a file | every change |
Install the development app
ray bundle --ios --dev # or --android --dev
The app is called Notes-dev and lives next to the real one. It does not carry your program: it
carries the raylang VM and a pairing screen. Install it like the normal app (Xcode or adb install), and from then on you never touch it again, however much the code changes.
Pair the phone with the QR code
ray dev --device
The terminal prints the three steps and a QR code. Open the phone's camera, point it at the
code and tap the link that appears: Notes-dev opens already paired and receives the program. You
do not open the app first or type anything; the QR encodes your Mac's address on the local network,
the port and a token.
If you open Notes-dev by hand without scanning, it shows a screen with the same three steps and
asks you to use the camera. Below, folded, there is a field to paste the link the terminal
prints under the QR, for when the camera cannot read it (a low-contrast terminal, an emulator
without a camera).
The pairing is remembered on both sides: the app keeps the link, and the project keeps the port and
the token in .ray-dev (a hidden file that ray new already leaves out of git). Next time, run
ray dev --device and open Notes-dev; the terminal says "Already paired". The QR is still
printed, to pair a second phone.
Save and see the change
The code runs on the phone, on its VM: files, network and permissions are the device's, and what the program prints reaches your terminal. Every file you save is sent and the program restarts in about a second. A change that does not compile is not sent: the diagnostic shows up in the terminal, in amber, and the phone keeps the previous version.
On the desktop, ray dev-client <url> <folder> plays the phone's part, useful for testing the flow
in CI; the terminal prints the exact command at the foot of the banner.
If something does not connect
- The phone opens nothing when scanning. Phone and Mac must be on the same Wi-Fi network, and
the link is for this project's development app:
Notes-dev, not another one. - "The computer stopped answering". The app kept a link from an earlier session and
ray dev --deviceis no longer running, or runs on another port (if the one in.ray-devwas busy, the terminal warns and asks you to pair again). Scan the new QR. - Version warning. If the development app comes from a different raylang version than your Mac's, the terminal says so; the phone's toolchain compiles the program. Reinstall the app after updating raylang.
The interface with hot reload
To iterate on the interface with hot reload too, the phone can load the Vite server on your Mac:
- start Vite listening on the network:
npm --prefix frontend run dev -- --host 0.0.0.0; - build the app with
--devtools(ray bundle --ios --devtools); - launch it with
RAY_DEV_FRONTEND_URL=http://<your-mac-ip>:5173in the environment. In Xcode it is a scheme variable; on Android,adb reverse tcp:5173 tcp:5173andhttp://127.0.0.1:5173.
A release build always ignores that variable.
11. What changes compared to the desktop
The same main runs in all three places, but the system underneath does not behave the same:
| Desktop | iOS | Android | |
|---|---|---|---|
| The interface | a native window, with menus | the shell's webview, full screen | the shell's webview, full screen |
closed event |
when the window closes | never arrives | never arrives |
lifecycle event |
no | background and foreground |
background and foreground |
| Current directory | where it was launched | / |
/ |
std/process |
yes | does not exist | does not exist |
| The page loads from | ray://app/… |
ray://app/… |
https://app.ray.invalid/… |
| What the program prints | the terminal | simctl launch --console-pty, or the Xcode console |
adb logcat -s ray |
| Inspecting the page | ray dev, or ray run --devtools |
Safari, Develop menu, with --devtools |
chrome://inspect, with --devtools |
What that forces you to do:
- Save every change as soon as it happens. The system suspends or kills the app without warning, so there is no "on exit" moment to save in. That is what Notes does.
- Do not depend on the current directory. Use
$HOME(section 3) for data, and the embedded frontend orstd/embedfor resources. - Whatever must keep running in the background lives in the program. JavaScript timers freeze
when the app is not visible. An audio player plays from raylang, and
[ios] background_audio = trueand[android] background_audio = truekeep it playing. - Do not write absolute URLs by hand. On Android the page loads through
https://app.ray.invalid/…, an alias ofray://app/that never reaches the network. Relative URLs andfetch("/api/x")work the same in all three places.
12. Options and configuration
The ray bundle options for the phone:
| Option | What it does |
|---|---|
--ios |
generates the Xcode project in <Name>-ios/ |
--ios-target device|sim|both |
builds only the phone library or the simulator one; both by default |
--android |
generates the Gradle project in <Name>-android/ |
--android-abi arm64|x86_64|all |
the architecture of the .so; arm64 by default |
--dev |
the development app, for ray dev --device |
--devtools |
allows inspecting the webview from Safari or Chrome |
--without list |
leaves subsystems out of the binary, for example --without audio,sqlite |
And what is declared in ray.toml:
| Key | Effect |
|---|---|
[app] name |
the name under the icon |
[app] id |
the iOS bundle id and, unless another is given, the Android application id |
[app] icon |
a PNG: the app icon on both systems |
[app.plist] |
keys that go as they are into the iOS Info.plist, for example the text of a permission |
[ios] development_team |
the development team, to install on a real iPhone |
[ios] background_audio |
audio keeps playing with the app in the background |
[android] application_id |
an application id different from [app] id |
[android] background_audio |
the same on Android, with a foreground service |
[frontend] |
the frontend's commands and folder (section 2) |
13. Common problems
| Symptom | Cause and fix |
|---|---|
ray dev exits with code 73 |
the Vite port is taken by another process, usually an earlier session; the message says which |
Xcode asks for a team after every ray bundle --ios |
declare [ios] development_team in ray.toml: regenerating the bundle rewrites the project |
The phone does not connect to ray dev --device |
the link uses the local network: the phone and the computer must be on the same network |
| The development app shows "not found" | it was a bug up to 1.27.26; update raylang and regenerate the app with ray bundle --ios --dev or --android --dev |
| On Android the app sits under the status bar, or the keyboard covers the fields | the shell reserves the bars and the keyboard since 1.27.27; update raylang and regenerate the bundle |
| iOS zooms in when a text field is tapped | the field's font is smaller than 16px |
| The content sits under the dynamic island | viewport-fit=cover or the env(safe-area-inset-*) values are missing (section 6) |
A change in [app] does not show up on the phone |
the identity and the icon are written when the project is generated: run ray bundle again |
Next step
The same Notes, now also on the desktop: macOS, Linux and Windows with native menus, dialogs and SQLite, in the cross-platform app.
<!-- sync: sha256:f78382ac02b9 -->