Skip to content

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 bridge

The 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::default gives 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_id assigns 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. CmdOrCtrl maps to Cmd on macOS and Ctrl on Windows and Linux.
  • on_menu_event emits 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.

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 os

os_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() -> String

app/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) -> Nil
js
// 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 dev

A 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.


  1. See commit 63e810b on GitHub ↩︎