Appearance
Desktop Additions
Two things are missing from the desktop app: there's no way to reload the page if the task list goes stale, and dragging over text triggers browser-style selection.
In this chapter we'll add a View menu with a Reload action and a platform detection layer that disables text selection when running outside the browser. Nine files change, five are new[1]:
sh
doable/
└── client/
├── package.json # @tauri-apps/plugin-os added
├── src-tauri/
│ ├── Cargo.toml # tauri-plugin-os added
│ ├── capabilities/
│ │ └── default.json # os:default permission added
│ └── src/
│ └── lib.rs # menu construction + os plugin
└── src/
├── browser.gleam # reload_page + add_body_class
├── browser_ffi.js # reload_page + add_body_class
├── client.gleam # Msg type + menu wiring + body class
├── client.css # user-select CSS
├── app/
│ └── platform.gleam # platform detection
└── tauri/
├── menu.gleam # menu event subscription
├── menu_ffi.js # Tauri event listener
├── os.gleam # platform string external
└── os_ffi.js # tauri-plugin-os bridgeThe Menu
Menus in Tauri are built on the Rust side. lib.rs constructs a View submenu with a single Reload item and appends it to the platform's default menu bar:
rust
// client/src-tauri/src/lib.rs
use tauri::{
AppHandle, Emitter,
menu::{Menu, MenuEvent, MenuItem, Submenu},
};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
let reload_item =
MenuItem::with_id(app.handle(), "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
let view_submenu = Submenu::with_items(app.handle(), "View", true, &[&reload_item])?;
let menu = Menu::default(app.handle())?;
menu.append(&view_submenu)?;
app.set_menu(menu)?;
Ok(())
})
.on_menu_event(|app: &AppHandle, event: MenuEvent| {
app.emit("menu-event", event.id().as_ref()).ok();
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}A walk through what each piece does:
Menu::defaultgives us the platform's built-in menu bar — on macOS that includes the application menu with Quit and Hide. We append the View submenu on top rather than replacing it.MenuItem::with_idassigns the string"reload"as the item's identifier. That id is what gets emitted when the user clicks — the label "Reload" is display only.Some("CmdOrCtrl+R")registers the keyboard shortcut.CmdOrCtrlmaps to Cmd on macOS and Ctrl on Windows and Linux.on_menu_eventemits a"menu-event"Tauri event carrying the item's id. That crosses the Rust/JS boundary and makes the event available to the Gleam frontend.
Menu Events in Gleam
The Rust side emits a Tauri event; the Gleam side subscribes to it. Two new files handle the bridge:
js
// client/src/tauri/menu_ffi.js
import { listen } from "@tauri-apps/api/event";
export function listen_menu_events(dispatch) {
listen("menu-event", (event) => {
dispatch(event.payload);
});
}gleam
// client/src/tauri/menu.gleam
import lustre/effect.{type Effect}
@external(javascript, "./menu_ffi.js", "listen_menu_events")
fn listen_menu_events(dispatch: fn(String) -> Nil) -> Nil
pub fn subscribe(to_msg: fn(String) -> msg) -> Effect(msg) {
use dispatch <- effect.from
use id <- listen_menu_events
to_msg(id) |> dispatch
}listen_menu_events calls Tauri's listen API, which fires the callback every time a "menu-event" arrives.
subscribe wraps it as a Lustre effect: effect.from hands us the dispatch function, which we thread through as the listener callback. The result — when the user clicks a menu item, its id string lands in the app as a regular message.
Platform Detection
Platform detection relies on tauri-plugin-os. The Tauri CLI handles all the wiring — it adds the Rust crate to Cargo.toml, the JS package to package.json, the permission to capabilities/default.json, and registers the plugin in lib.rs:
sh
cd client
bun tauri add osos_ffi.js calls the plugin to get the current platform string:
js
// client/src/tauri/os_ffi.js
import { platform } from "@tauri-apps/plugin-os";
export function platform_string() {
try {
return platform();
} catch {
return "browser";
}
}The try/catch matters: @tauri-apps/plugin-os throws when called outside a Tauri context. Catching it and returning "browser" means the same code works in both environments without crashing.
gleam
// client/src/tauri/os.gleam
@external(javascript, "./os_ffi.js", "platform_string")
pub fn platform_string() -> Stringapp/platform.gleam maps the raw string to a typed value:
gleam
// client/src/app/platform.gleam
import tauri/os
pub type Platform {
Browser
MacOS
Windows
Linux
}
pub fn platform() -> Platform {
case os.platform_string() {
"macos" -> MacOS
"windows" -> Windows
"linux" -> Linux
_ -> Browser
}
}
pub fn is_desktop() -> Bool {
case platform() {
MacOS | Windows | Linux -> True
_ -> False
}
}Wiring the App
Previously client.gleam forwarded all messages directly to the router. With the menu as a second source of messages, a top-level Msg type is needed. While here, main also tags <body> with a platform class so the CSS rules below have something to target:
gleam
// client/src/client.gleam
import app/platform.{Browser, Linux, MacOS, Windows}
import browser
import lustre
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import modem
import router
import tauri/menu
pub fn main() {
case platform.platform() {
MacOS | Windows | Linux -> browser.add_body_class("desktop")
Browser -> browser.add_body_class("browser")
}
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
}
pub type Msg {
RouterSentMsg(router.Msg)
MenuSentEvent(String)
}
type Model {
Model(page: router.Page)
}
fn init(_) -> #(Model, Effect(router.Msg)) {
fn init(_) -> #(Model, Effect(Msg)) {
let #(page, router_effect) = router.init(modem.initial_uri())
#(
Model(page:),
effect.batch([modem.init(router.on_url_change), router_effect]),
)
let effects = [
modem.init(router.on_url_change) |> effect.map(RouterSentMsg),
router_effect |> effect.map(RouterSentMsg),
]
let effects = case platform.is_desktop() {
True -> [menu.subscribe(MenuSentEvent), ..effects]
False -> effects
}
#(Model(page:), effect.batch(effects))
}
fn update(model: Model, msg: router.Msg) -> #(Model, Effect(router.Msg)) {
let #(page, effect) = router.update(model.page, msg)
#(Model(page:), effect)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
RouterSentMsg(msg) -> {
let #(page, effect) = router.update(model.page, msg)
#(Model(page:), effect |> effect.map(RouterSentMsg))
}
MenuSentEvent("reload") -> {
#(model, effect.from(fn(_) { browser.reload_page() }))
}
MenuSentEvent(_) -> #(model, effect.none())
}
}
fn view(model: Model) -> Element(router.Msg) {
router.view(model.page)
fn view(model: Model) -> Element(Msg) {
router.view(model.page) |> element.map(RouterSentMsg)
}effect.map(RouterSentMsg) wraps every effect produced by the router so its messages arrive at the top level as RouterSentMsg(...). menu.subscribe(MenuSentEvent) only joins the effect batch when platform.is_desktop() is True — the browser never subscribes to menu events it can't receive.
When Reload fires, MenuSentEvent("reload") arrives and calls browser.reload_page(). Unknown menu events are silently ignored — a safe default as the menu grows.
Browser FFI
browser.gleam gains two thin externals: one to reload the page, and one to add a class to <body> so CSS can target the current platform.
gleam
// client/src/browser.gleam
@external(javascript, "./browser_ffi.js", "window_location_origin")
pub fn window_location_origin() -> String
@external(javascript, "./browser_ffi.js", "history_back")
pub fn history_back() -> Nil
@external(javascript, "./browser_ffi.js", "reload_page")
pub fn reload_page() -> Nil
@external(javascript, "./browser_ffi.js", "add_body_class")
pub fn add_body_class(class_name: String) -> Niljs
// client/src/browser_ffi.js
export function window_location_origin() {
return window.location.origin;
}
export function history_back() {
window.history.back();
}
export function reload_page() {
window.location.reload();
}
export function add_body_class(class_name) {
document.body.classList.add(class_name);
}Text Selection
Web pages select text on click-drag. One CSS rule scoped to body.desktop disables it across the entire app:
css
/* client/src/client.css */
@import "tailwindcss";
@plugin "daisyui";
@plugin "@iconify/tailwind4";
body.desktop * {
-webkit-user-select: none;
user-select: none;
}The matching <body> class is added in client.gleam at startup — see the snippet above. Scoping the rule to body.desktop keeps the browser experience unchanged: text stays selectable when running in a regular browser tab.
Running
sh
cd client
bun tauri devA View menu appears in the menu bar. Selecting View → Reload — or pressing Cmd+R on macOS, Ctrl+R on Windows and Linux — reloads the page. Clicking and dragging over text no longer triggers selection.
What's Next
The desktop experience is starting to feel native: a View menu, a Cmd+R shortcut, and text that no longer selects on drag. There's one more thing to sort out before production: bun tauri build works locally, but CORS blocks every API request the moment the app runs outside the dev server. Next, we'll route HTTP through Tauri's Rust backend to get around it.