Appearance
Deploy to Device
The app runs in dev on simulators and emulators — but localhost:8000 is hardcoded. That breaks on physical devices, where the server isn't reachable at localhost, and it breaks in production builds, where the server lives behind a real domain. This chapter fixes the URL logic, then takes the app through production builds on simulators, emulators, and real devices[1].
Seven files change, three are new:
sh
doable/
└── client/
├── src-tauri/
│ ├── capabilities/
│ │ ├── default.json # production URL allowlisted
│ │ └── mobile.json # mobile-only http allowlist
│ ├── src/
│ │ ├── commands.rs # is_dev + tauri_dev_host commands
│ │ └── lib.rs # invoke_handler registered
│ └── tauri.conf.json # iOS team ID + LAN host arg
└── src/
├── api.gleam # async base URL by platform + mode
└── tauri/
├── commands.gleam # tauri_is_dev + tauri_dev_host
└── commands_ffi.js # invoke bridgeReading Tauri's State from Rust
Tauri has two pieces of state we want to consume from Gleam: whether the binary was built in debug or release mode (tauri::is_dev()), and the LAN host the dev server is bound to when targeting a physical device (option_env!("TAURI_DEV_HOST") — Tauri's CLI sets this at compile time). Both are exposed as Tauri commands that the JS side can invoke:
rust
// client/src-tauri/src/commands.rs
#[tauri::command]
pub fn is_dev() -> bool {
tauri::is_dev()
}
#[tauri::command]
pub fn tauri_dev_host() -> Option<String> {
option_env!("TAURI_DEV_HOST").map(str::to_string)
}lib.rs registers them with invoke_handler and pulls in the new module:
rust
// client/src-tauri/src/lib.rs
mod commands;
...
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.setup_platform()
.invoke_handler(tauri::generate_handler![
commands::is_dev,
commands::tauri_dev_host
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}tauri::generate_handler! is a macro that turns the listed commands into a single handler value Tauri can register. Adding more commands later means appending them to the list.
The Gleam Bridge
commands_ffi.js invokes the two commands through Tauri's invoke API. Both have isTauri() guards so the same Gleam code can also run in a plain browser — outside a Tauri context, is_dev defaults to False and tauri_dev_host to None:
js
// client/src/tauri/commands_ffi.js
import { invoke, isTauri } from "@tauri-apps/api/core";
import { Some, None } from "../../gleam_stdlib/gleam/option.mjs";
export async function tauri_is_dev() {
return isTauri() ? invoke("is_dev") : false;
}
export async function tauri_dev_host() {
const host = isTauri() ? await invoke("tauri_dev_host") : null;
return host ? new Some(host) : new None();
}commands.gleam exposes both as typed Gleam externals returning Promises — Tauri's invoke is asynchronous:
gleam
// client/src/tauri/commands.gleam
import gleam/javascript/promise.{type Promise}
import gleam/option.{type Option}
@external(javascript, "./commands_ffi.js", "tauri_is_dev")
pub fn tauri_is_dev() -> Promise(Bool)
@external(javascript, "./commands_ffi.js", "tauri_dev_host")
pub fn tauri_dev_host() -> Promise(Option(String))Resolving the API Base URL
api_base_url() now branches on platform, build mode, and whether TAURI_DEV_HOST was set at compile time. Because the Tauri commands are async, the function returns a Promise(String) rather than a bare String — the rest of api.gleam adapts to wait for it:
gleam
// client/src/api.gleam
import gleam/option.{None, Some}
import tauri/commands
fn api_base_url() -> String {
case platform.platform() {
Browser -> browser.window_location_origin()
_ -> "http://localhost:8000"
}
}
fn api_base_url() -> Promise(String) {
use tauri_is_dev <- promise.await(commands.tauri_is_dev())
use tauri_dev_host <- promise.map(commands.tauri_dev_host())
case platform.platform(), tauri_is_dev, tauri_dev_host {
Browser, _, _ -> browser.window_location_origin()
_, True, Some(host) -> "http://" <> host <> ":8000"
_, True, None -> "http://localhost:8000"
_, False, _ -> "https://your-domain.com"
}
} - Browser —
window.location.originworks in any browser context, dev or production, because the Caddy proxy is there to forward/api. - Tauri + dev + host set — Tauri sets
TAURI_DEV_HOSTto the machine's LAN IP when targeting a physical device. The server needs to be reachable on that address, not justlocalhost. - Tauri + dev + no host — simulator and emulator both share the host network, so
localhost:8000reaches the server directly. - Tauri + release — replace
https://your-domain.comwith the address of your deployed server.
with_json_request awaits the resolved URL before building the request:
gleam
// client/src/api.gleam
fn with_json_request(
path: String,
callback: fn(Request(String)) -> Promise(Result(b, ApiError)),
) -> Promise(Result(b, ApiError)) {
let url = api_base_url() <> path
use base_url <- promise.await(api_base_url())
let url = base_url <> path
request.to(url)
|> result.replace_error(InvalidUrl(url))
|> result.map(request.set_header(_, "accept", "application/json"))
|> promise.resolve
|> promise.try_await(callback)
}HTTP Capabilities
The production URL needs to be added to the desktop HTTP allowlist — requests to unlisted URLs are blocked:
json
// client/src-tauri/capabilities/default.json
{
"identifier": "http:default",
"allow": [
{ "url": "http://localhost:8000/**" },
{ "url": "https://your-domain.com/**" }
]
}On mobile, physical devices reach the dev server through whichever LAN IP TAURI_DEV_HOST was set to. Since the address varies between networks, a wildcard host is the simplest match. Adding the rule to mobile.json keeps it scoped to iOS and Android — desktop builds don't get a wildcard:
json
// client/src-tauri/capabilities/mobile.json
{
"identifier": "mobile-capability",
"platforms": ["android", "iOS"],
"windows": ["main"],
"permissions": [
"haptics:allow-impact-feedback",
{
"identifier": "http:default",
"allow": [
{
"url": "http://*:8000/**"
}
]
}
]
}Binding the Dev Server to the LAN
Targeting a physical device needs the dev server bound to the LAN, not just 127.0.0.1. lustre_dev_tools start accepts --host=0.0.0.0 to do that. We only want it set when Tauri provides a TAURI_DEV_HOST, so the shell expansion ${TAURI_DEV_HOST:+--host=0.0.0.0} substitutes the flag when the variable is set and produces nothing otherwise:
json
// client/src-tauri/tauri.conf.json
{
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1234",
"beforeDevCommand": "gleam run -m lustre/dev start",
"beforeDevCommand": "gleam run -m lustre/dev start ${TAURI_DEV_HOST:+--host=0.0.0.0}",
"beforeBuildCommand": "gleam run -m lustre/dev build"
}
}For simulator and emulator runs, TAURI_DEV_HOST is empty and lustre_dev_tools keeps its default loopback binding.
iOS Setup
Distributing to a simulator or device requires an Apple Developer team ID. Add it to tauri.conf.json under bundle.iOS:
json
// client/src-tauri/tauri.conf.json
{
"bundle": {
"iOS": {
"developmentTeam": "YOUR_APPLE_TEAM_ID"
},
...
}
}Find your team ID in Apple Developer under Membership Details. With this set, bun tauri ios build handles signing automatically — no manual certificate management needed.
Before distributing, swap the bundle identifier in tauri.conf.json for your own reverse-domain value. Both iOS and Android pick it up from that one field — it was set to com.lukwol.doable back in chapter 11 as a placeholder.
Running on iOS Simulator
For a dev build on the simulator, bun tauri ios dev from chapter 12 still works. For a production build:
sh
cd client
bun tauri ios build --target aarch64-simThis compiles a release build for Apple Silicon simulators. Install it on the booted simulator:
sh
xcrun simctl install booted src-tauri/gen/apple/build/arm64-sim/Doable.appThe app then appears on the simulator's home screen — tap it to open, or remove it the same way as on a real device.
Simulator needs .app, device needs .ipa
--target aarch64-sim produces a .app bundle for the simulator. Physical devices require a signed .ipa — using the simulator build on a device won't work.
Running on an iOS Device
Connect a device and run in dev mode:
sh
cd client
bun tauri ios devTauri detects the physical device, sets TAURI_DEV_HOST to the machine's LAN IP, and starts lustre_dev_tools bound to that address. The dev + Some(host) branch in api_base_url() kicks in — API requests go to http://<LAN IP>:8000 instead of localhost.
For a production build:
sh
bun tauri ios buildList connected devices to find the UDID:
sh
xcrun devicectl list devicesThen install:
sh
xcrun devicectl device install app --device <DEVICE_UDID> \
src-tauri/gen/apple/build/arm64/Doable.ipaRunning on Android Emulator
Create and manage AVDs through Android Studio's Device Manager — no CLI needed. Once an emulator is running:
sh
adb devicesThis confirms the emulator is connected. For a dev build, bun tauri android dev from chapter 12 still applies. To produce a standalone APK you can install directly on the emulator:
sh
cd client
bun tauri android build --apk --debug--debug uses the local debug keystore to sign the APK, which lets it install without any extra setup. Dropping the flag produces a proper release build instead, but that APK has to be signed with a release keystore before it'll install anywhere — Signing for Distribution below walks through that.
sh
adb install -r \
src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apkThe app appears in the emulator's launcher — tap it to open, or remove it the same way as on a real device.
.apk for the emulator, .aab for the Play Store
Without --apk, bun tauri android build also produces an AAB (Android App Bundle) — the Play Store's distribution format, but not installable via adb.
Release builds block cleartext HTTP
Android release builds disallow plain HTTP by default. The release branch in api_base_url() must point at an HTTPS URL — http://your-domain.com will be blocked. For local testing, use a tunneling tool (Tailscale, Cloudflare Tunnel, ngrok, or similar) to expose the local server over HTTPS.
Running on an Android Device
Connect a device over USB and enable USB debugging in the developer options. List connected devices to confirm it shows up alongside any running emulators:
sh
adb devicesRun in dev mode:
sh
cd client
bun tauri android devLike iOS, Tauri sets TAURI_DEV_HOST to the machine's LAN IP when a physical device is connected, so the dev + Some(host) branch in api_base_url() kicks in — API requests go to http://<LAN IP>:8000 instead of localhost.
For a standalone install:
sh
bun tauri android build --apk --debugThen install on the connected device:
sh
adb install -r \
src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apkSigning for Distribution
Anything you ship outside your own machine needs to be properly signed. Both platforms handle the bulk of this through their IDEs:
- iOS — Xcode's Signing & Capabilities tab manages certificates and provisioning profiles, and Product → Archive handles App Store submission. Start with Apple's Code Signing overview.
- Android — Android Studio's Build → Generate Signed Bundle / APK walks you through creating a keystore and signing an APK (for sideloading) or an AAB (for the Play Store, where Play App Signing takes over the final signing).
That's a Wrap
And that's the full stack: a Gleam server backed by Postgres, a Lustre frontend driven by lustre_dev_tools, and a Tauri shell that runs the same code as a web app, a macOS/Windows/Linux desktop app, and an iOS/Android mobile app — all sharing types and validation written once in Gleam.
Thanks for sticking with me all the way through. If any of it helped, I'd love to hear about it — and the full source is at github.com/lukwol/doable if you want to fork it, star it, or use it as a starting point for your own project. Happy building.