Files
LLeMbas-Desktop/src-tauri/src/lib.rs
T
HomerandClaude Fable 5.1 881a222f5f The look from the server's tokens, pinned; the frame and bar follow the page's theme
ui/tokens.css is no longer a hand copy that drifts: scripts/fetch-tokens.sh
takes the server's tokens.css at a git ref and tokens.lock.json pins it, as
fetch-cli.sh does for the CLI. build.rs reads the two themes' --bg and --border
out of it, so no colour is written in Rust. The content view reports the page's
theme by a navigation the app refuses (llembas-theme:<name>), and the window,
the title bar and the first-run page are painted in it and remember it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 03:38:31 +02:00

1037 lines
36 KiB
Rust

//! LLeMbas Desktop: the server's web UI in a window of its own, and the LLeMbas CLI's agent
//! running on this computer, so agent chats can work in its projects without a terminal.
//!
//! - **One window, `main`, drawn by the app**: no system decoration, the app's own title bar (a
//! local page, `ui/titlebar.html`) on top and the content below it — before a server is chosen
//! the first-run page (`ui/index.html`), after it the server itself. Two webviews in one window,
//! so the server's pages never share a document with the app's controls. Closing hides the
//! window; the tray, the title bar's menu or starting the app again bring it back.
//! - **The server's pages get no IPC.** They are remote content, and the one capability lists no
//! remote URL. Links that leave the server open in the system browser.
//! - **The window follows the server's theme** (Moria or Shire, the account's choice, not the
//! desktop's): a script in the content view reports the page's `data-theme` by trying to
//! navigate to `llembas-theme:<name>`, which the navigation handler notes and refuses (see
//! `THEME_SCRIPT`). The frame, the title bar and the first-run page are painted in it, and it
//! is remembered for the next start. No IPC is granted for this: a navigation attempt is all
//! the page can make, and the only thing it can say is one of two theme names.
//! - **The agent is the CLI's**: `lembas login --json` links this computer, and
//! `lembas service run` is the device (see `agent.rs`).
mod agent;
mod cli;
mod remote;
mod settings;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use serde::Serialize;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::webview::WebviewBuilder;
use tauri::window::{Color, WindowBuilder};
use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, Webview, WebviewUrl,
Window, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_updater::UpdaterExt;
use agent::{Agent, State as AgentState};
use remote::Remote;
use settings::Settings;
/// The colours the window paints itself, read out of `ui/tokens.css` by `build.rs`.
mod tokens {
include!(concat!(env!("OUT_DIR"), "/tokens.rs"));
}
const MAIN: &str = "main";
const TITLEBAR: &str = "titlebar";
const CONTENT: &str = "content";
/// The title bar's height, in logical pixels: `--titlebar-h` in `ui/titlebar.css`.
const TITLE_H: f64 = 36.0;
/// The frame left around the webviews while the window is not maximized: the window's own
/// surface, where the pointer resizes it (a webview over the edge would take the events).
const FRAME: f64 = 4.0;
/// The scheme the content page's theme report is a navigation to: `llembas-theme:shire`.
const THEME_SCHEME: &str = "llembas-theme";
/// Run in the content view on every page: reports the page's theme (see the module's notes).
const THEME_SCRIPT: &str = include_str!("theme_report.js");
/// The two looks the server's tokens define, named as `tokens.css` selects them (`data-theme`).
/// A custom theme on the server reports its base, which is one of these.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Theme {
Moria,
Shire,
}
impl Theme {
fn parse(name: &str) -> Option<Theme> {
match name {
"moria" => Some(Theme::Moria),
"shire" => Some(Theme::Shire),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Theme::Moria => "moria",
Theme::Shire => "shire",
}
}
/// The frame's colour: the theme's `--border`, which the pages draw their edges in.
fn frame(self) -> Color {
let (r, g, b) = match self {
Theme::Moria => tokens::MORIA_BORDER,
Theme::Shire => tokens::SHIRE_BORDER,
};
Color(r, g, b, 0xFF)
}
/// A webview's own colour, `--bg`: what shows where a page has not painted yet, so a
/// server page loading in the dark theme is not a white flash first.
fn background(self) -> Color {
let (r, g, b) = match self {
Theme::Moria => tokens::MORIA_BG,
Theme::Shire => tokens::SHIRE_BG,
};
Color(r, g, b, 0xFF)
}
}
struct App {
config_dir: PathBuf,
settings: Mutex<Settings>,
agent: Agent,
tray: Mutex<Option<TrayItems>>,
linking: Mutex<bool>,
/// The newer release the last check found, if any.
update: Mutex<Option<String>>,
/// The agent's folders, as `lembas remote` last answered.
remote: Mutex<Option<Remote>>,
/// What the window is painted in: the server page's theme, as last reported.
theme: Mutex<Theme>,
}
struct TrayItems {
status: MenuItem<Wry>,
toggle: MenuItem<Wry>,
}
impl App {
fn settings(&self) -> Settings {
self.settings.lock().unwrap().clone()
}
fn update(&self, f: impl FnOnce(&mut Settings)) -> Result<Settings, String> {
let mut s = self.settings.lock().unwrap();
f(&mut s);
settings::save(&self.config_dir, &s)
.map_err(|e| format!("could not save the settings: {e}"))?;
Ok(s.clone())
}
fn theme(&self) -> Theme {
*self.theme.lock().unwrap()
}
}
/// What the first-run page shows.
#[derive(Clone, Serialize)]
struct Status {
version: &'static str,
server: Option<String>,
linked: bool,
agent: AgentState,
agent_wanted: bool,
/// A newer release of the app, when the last check found one.
update: Option<String>,
/// Which folders the agent may open sessions in (None: not known yet).
remote: Option<Remote>,
/// The theme the app's own pages are painted in: `moria` or `shire`, the server page's.
theme: &'static str,
}
fn status(app: &App) -> Status {
let s = app.settings();
Status {
version: env!("CARGO_PKG_VERSION"),
linked: s.server.as_deref().is_some_and(cli::linked_to),
server: s.server,
agent: app.agent.state(),
agent_wanted: app.agent.wanted(),
update: app.update.lock().unwrap().clone(),
remote: app.remote.lock().unwrap().clone(),
theme: app.theme().name(),
}
}
#[tauri::command]
fn get_status(app: tauri::State<'_, App>) -> Status {
status(&app)
}
/// Check that `address` is a LLeMbas server, remember it, and open it.
#[tauri::command]
async fn set_server(handle: AppHandle, address: String) -> Result<String, String> {
let base = settings::normalise(&address)?;
let probe = base.clone();
let name = tauri::async_runtime::spawn_blocking(move || discover(&probe))
.await
.map_err(|e| e.to_string())??;
let app = handle.state::<App>();
let before = app.settings().server;
app.update(|s| s.server = Some(base.clone()))?;
if before.as_deref() != Some(base.as_str()) {
app.agent.stop();
}
open_main(&handle, Some(&base))?;
Ok(name)
}
/// `/.well-known/lembas.json`: the same check `lembas login` makes first.
fn discover(base: &str) -> Result<String, String> {
let url = format!("{base}/.well-known/lembas.json");
let res = ureq::get(&url)
.timeout(std::time::Duration::from_secs(10))
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => format!("{base} answers, but is not a LLeMbas server."),
ureq::Error::Status(code, _) => format!("{base} answered {code}."),
ureq::Error::Transport(t) => format!("Could not reach {base}: {t}"),
})?;
let body: serde_json::Value = res
.into_json()
.map_err(|_| format!("{base} is not a LLeMbas server."))?;
if body.get("service").and_then(|s| s.as_str()) != Some("lembas") {
return Err(format!("{base} is not a LLeMbas server."));
}
Ok(body
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("LLeMbas")
.to_string())
}
#[tauri::command]
fn set_agent(handle: AppHandle, on: bool) -> Result<(), String> {
set_agent_wanted(&handle, on)
}
#[tauri::command]
fn link_computer(handle: AppHandle) -> Result<(), String> {
start_link(&handle)
}
fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
let app = handle.state::<App>();
let s = app.update(|s| s.agent = on)?;
match (on, s.server) {
(true, Some(server)) => {
app.agent.start(server);
let h = handle.clone();
std::thread::spawn(move || {
// Connected but allowed into no folder is the state a new computer starts in:
// say so where the folders are chosen, rather than showing a green light.
if let Some(r) = refresh_remote(&h) {
if !r.usable() {
let _ = h.run_on_main_thread({
let h = h.clone();
move || {
let _ = open_main(&h, None);
}
});
}
}
});
}
_ => app.agent.stop(),
}
Ok(())
}
/// Ask the CLI which folders the agent may use; remember and announce it.
fn refresh_remote(handle: &AppHandle) -> Option<Remote> {
let r = remote::run(&["list"]).ok();
*handle.state::<App>().remote.lock().unwrap() = r.clone();
let _ = handle.emit("status", status_of(handle));
r
}
/// After a change: remember the new state, and start the app's own agent again when the CLI
/// says the running service cannot take the change live.
fn remote_changed(handle: &AppHandle, r: Remote) {
let app = handle.state::<App>();
*app.remote.lock().unwrap() = Some(r.clone());
if r.restart_needed && app.agent.wanted() {
if let Some(server) = app.settings().server {
app.agent.start(server);
}
}
let _ = handle.emit("status", status_of(handle));
}
/// Run a `lembas remote` change off the main thread; the page hears the outcome as `status`,
/// or as `remote-error`.
fn change_remote(handle: &AppHandle, args: Vec<String>) {
let h = handle.clone();
std::thread::spawn(move || {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
match remote::run(&refs) {
Ok(r) => remote_changed(&h, r),
Err(e) => {
let _ = h.emit("remote-error", e);
}
}
});
}
#[tauri::command]
fn remote_refresh(handle: AppHandle) {
std::thread::spawn(move || {
refresh_remote(&handle);
});
}
/// Choose a folder with the system's dialog and let the agent work in it (this turns remote
/// control on, if it was off).
#[tauri::command]
fn remote_add(handle: AppHandle) {
let h = handle.clone();
handle
.dialog()
.file()
.set_title("A folder the agent may work in")
.pick_folder(move |picked| {
if let Some(path) = picked.and_then(|p| p.into_path().ok()) {
change_remote(&h, vec!["add".into(), path.to_string_lossy().into_owned()]);
}
});
}
#[tauri::command]
fn remote_remove(handle: AppHandle, dir: String) {
change_remote(&handle, vec!["remove".into(), dir]);
}
#[tauri::command]
fn remote_mode(handle: AppHandle, mode: String) -> Result<(), String> {
if !["manual", "edit", "auto", "plan"].contains(&mode.as_str()) {
return Err(format!("no such mode: {mode}"));
}
change_remote(&handle, vec!["mode".into(), mode]);
Ok(())
}
#[tauri::command]
fn remote_terminal(handle: AppHandle, on: bool) {
change_remote(
&handle,
vec!["terminal".into(), if on { "on" } else { "off" }.into()],
);
}
/// The server's pages are told they are in LLeMbas Desktop, and on which computer: it shows
/// the device that is this computer as "Local". A label only — it grants nothing.
fn user_agent() -> String {
let platform = if cfg!(target_os = "linux") {
"X11; Linux x86_64"
} else if cfg!(target_os = "macos") {
"Macintosh; Intel Mac OS X 10_15_7"
} else {
"Windows NT 10.0; Win64; x64"
};
let host = cli::hostname()
.filter(|h| {
h.len() <= 253
&& h.chars()
.all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c))
})
.map(|h| format!(" (host={h})"))
.unwrap_or_default();
format!(
"Mozilla/5.0 ({platform}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 LLeMbasDesktop/{}{host}",
env!("CARGO_PKG_VERSION")
)
}
/// The app's own first-run page, as the webview addresses it on this platform.
fn local_page() -> Url {
let base = if cfg!(windows) {
"http://tauri.localhost/index.html"
} else {
"tauri://localhost/index.html"
};
Url::parse(base).expect("a fixed URL")
}
fn content(handle: &AppHandle) -> Option<Webview> {
handle.get_webview(CONTENT)
}
fn main_window(handle: &AppHandle) -> Option<Window> {
handle.get_window(MAIN)
}
/// Show the main window on `server`, or on the first-run page; made when it does not exist.
fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> {
let target = match server {
Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?),
None => None,
};
if let (Some(w), Some(c)) = (main_window(handle), content(handle)) {
c.navigate(target.unwrap_or_else(local_page))
.map_err(|e| e.to_string())?;
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
return Ok(());
}
build_main(handle, target).map_err(|e| e.to_string())
}
fn build_main(handle: &AppHandle, target: Option<Url>) -> tauri::Result<()> {
let theme = handle.state::<App>().theme();
let window = WindowBuilder::new(handle, MAIN)
.title("LLeMbas")
.inner_size(1280.0, 840.0)
.min_inner_size(420.0, 560.0)
.decorations(false)
// The frame's colour: the theme's border, as the pages draw theirs.
.background_color(theme.frame())
.build()?;
let size = window
.inner_size()?
.to_logical::<f64>(window.scale_factor()?);
window.add_child(
WebviewBuilder::new(TITLEBAR, WebviewUrl::App("titlebar.html".into()))
.background_color(theme.background()),
LogicalPosition::new(FRAME, FRAME),
LogicalSize::new(size.width - 2.0 * FRAME, TITLE_H),
)?;
let url = match target {
Some(u) => WebviewUrl::External(u),
None => WebviewUrl::App("index.html".into()),
};
#[cfg(target_os = "linux")]
if let Some(bar) = handle.get_webview(TITLEBAR) {
pin_titlebar(&bar);
}
let nav = handle.clone();
window.add_child(
WebviewBuilder::new(CONTENT, url)
.user_agent(&user_agent())
.background_color(theme.background())
.initialization_script(THEME_SCRIPT)
.on_navigation(move |url| allowed(&nav, url)),
LogicalPosition::new(FRAME, FRAME + TITLE_H),
LogicalSize::new(
size.width - 2.0 * FRAME,
(size.height - TITLE_H - 2.0 * FRAME).max(0.0),
),
)?;
layout(&window);
Ok(())
}
/// On Linux, Tauri packs every webview of a window into the window's GTK box, each with an equal
/// share of the height, and positions and sizes set on a webview are not applied there. So the
/// title bar is told directly: a fixed height, no share of what is left (the content takes it).
#[cfg(target_os = "linux")]
fn pin_titlebar(bar: &Webview) {
let _ = bar.with_webview(|pv| {
use gtk::prelude::*;
let view = pv.inner();
view.set_size_request(-1, TITLE_H as i32);
if let Some(parent) = view.parent().and_then(|p| p.downcast::<gtk::Box>().ok()) {
parent.set_child_packing(&view, false, false, 0, gtk::PackType::Start);
}
});
}
/// Lay the two webviews out in the window: the title bar on top, the content under it, and the
/// frame around both unless the window is maximized (then there is nothing to resize).
fn layout(window: &Window) {
let framed =
!(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false));
#[cfg(target_os = "linux")]
{
use gtk::prelude::*;
// The box's border is the frame: the window's own surface, which GTK resizes by.
if let Ok(vbox) = window.default_vbox() {
vbox.set_border_width(if framed { FRAME as u32 } else { 0 });
}
}
#[cfg(not(target_os = "linux"))]
place(window, framed);
}
#[cfg(not(target_os = "linux"))]
fn place(window: &Window, framed: bool) {
use tauri::{PhysicalPosition, PhysicalSize};
let handle = window.app_handle();
let (Some(bar), Some(body)) = (handle.get_webview(TITLEBAR), handle.get_webview(CONTENT))
else {
return;
};
let (Ok(size), Ok(scale)) = (window.inner_size(), window.scale_factor()) else {
return;
};
let frame = if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false)
{
0
} else {
(FRAME * scale).round() as u32
};
let title = (TITLE_H * scale).round() as u32;
let width = size.width.saturating_sub(2 * frame);
let _ = bar.set_position(PhysicalPosition::new(frame, frame));
let _ = bar.set_size(PhysicalSize::new(width, title));
let _ = body.set_position(PhysicalPosition::new(frame, frame + title));
let _ = body.set_size(PhysicalSize::new(
width,
size.height.saturating_sub(title + 2 * frame),
));
}
/// The window may show the app's own pages and the server; anything else opens in the browser.
/// A `llembas-theme:` "navigation" is the content page reporting its theme: noted, never loaded.
fn allowed(handle: &AppHandle, url: &Url) -> bool {
if url.scheme() == THEME_SCHEME {
if let Some(theme) = Theme::parse(url.path()) {
set_theme(handle, theme);
}
return false;
}
let local = matches!(url.scheme(), "tauri" | "asset")
|| url.host_str() == Some("tauri.localhost")
|| url.scheme() == "about"
|| url.scheme() == "data";
if local {
return true;
}
let server = handle.state::<App>().settings().server;
let same = server
.as_deref()
.and_then(|s| Url::parse(s).ok())
.is_some_and(|s| s.origin() == url.origin());
if !same && matches!(url.scheme(), "http" | "https" | "mailto") {
let _ = handle.opener().open_url(url.as_str(), None::<&str>);
}
same
}
/// The server page shows `theme`: paint the frame and the webviews in it, remember it for the
/// next start, and tell the app's own pages (they hear it as `status`).
fn set_theme(handle: &AppHandle, theme: Theme) {
let app = handle.state::<App>();
{
let mut current = app.theme.lock().unwrap();
if *current == theme {
return;
}
*current = theme;
}
let _ = app.update(|s| s.theme = Some(theme.name().into()));
if let Some(w) = main_window(handle) {
let _ = w.set_background_color(Some(theme.frame()));
}
for label in [TITLEBAR, CONTENT] {
if let Some(v) = handle.get_webview(label) {
let _ = v.set_background_color(Some(theme.background()));
}
}
let _ = handle.emit("status", status_of(handle));
}
/// Link this computer: `lembas login <server> --json`, with the approval shown in the window
/// (where the user is already signed in to the server), then the agent started.
fn start_link(handle: &AppHandle) -> Result<(), String> {
let app = handle.state::<App>();
let Some(server) = app.settings().server else {
return Err("Choose a server first.".into());
};
{
let mut busy = app.linking.lock().unwrap();
if *busy {
return Err("Linking is already in progress.".into());
}
*busy = true;
}
let mut child = match Command::new(cli::binary())
.arg("login")
.arg(&server)
.arg("--json")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
*app.linking.lock().unwrap() = false;
return Err(format!("Could not start {}: {e}", cli::binary().display()));
}
};
let handle = handle.clone();
std::thread::spawn(move || {
let mut failure: Option<String> = None;
let mut done = false;
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().map_while(Result::ok) {
let Ok(ev) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
match ev.get("event").and_then(|e| e.as_str()) {
Some("code") => {
let link = ev
.get("verification_uri_complete")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Ok(url) = Url::parse(link) {
if let Some(c) = content(&handle) {
let _ = c.navigate(url);
}
if let Some(w) = main_window(&handle) {
let _ = w.show();
let _ = w.set_focus();
}
}
}
Some("done") => done = true,
Some("error") => {
failure = ev.get("message").and_then(|m| m.as_str()).map(String::from)
}
_ => {}
}
}
}
let status = child.wait();
if !done && failure.is_none() {
let mut err = String::new();
if let Some(mut e) = child.stderr.take() {
let _ = std::io::Read::read_to_string(&mut e, &mut err);
}
failure = Some(match status {
Ok(s) if !err.trim().is_empty() => format!("{} ({s})", err.trim()),
Ok(s) => format!("lembas login ended without linking ({s})"),
Err(e) => e.to_string(),
});
}
let app = handle.state::<App>();
*app.linking.lock().unwrap() = false;
match failure {
None => {
let _ = open_main(&handle, Some(&server));
let _ = set_agent_wanted(&handle, true);
}
Some(why) => {
handle
.dialog()
.message(why)
.title("LLeMbas: linking failed")
.kind(MessageDialogKind::Error)
.show(|_| {});
}
}
let _ = handle.emit("status", status_of(&handle));
});
Ok(())
}
fn status_of(handle: &AppHandle) -> Status {
status(&handle.state::<App>())
}
fn show(handle: &AppHandle) {
let server = handle.state::<App>().settings().server;
if let Some(w) = main_window(handle) {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
} else {
let _ = open_main(handle, server.as_deref());
}
}
fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
let open = MenuItem::with_id(handle, "open", "Open LLeMbas", true, None::<&str>)?;
let status = MenuItem::with_id(
handle,
"status",
AgentState::Off.label(),
false,
None::<&str>,
)?;
let toggle = MenuItem::with_id(handle, "toggle", "Start the agent", true, None::<&str>)?;
let link = MenuItem::with_id(handle, "link", "Link this computer…", true, None::<&str>)?;
let server = MenuItem::with_id(handle, "server", "Settings…", true, None::<&str>)?;
let quit = MenuItem::with_id(handle, "quit", "Quit LLeMbas", true, None::<&str>)?;
let menu = Menu::with_items(
handle,
&[
&open,
&PredefinedMenuItem::separator(handle)?,
&status,
&toggle,
&link,
&PredefinedMenuItem::separator(handle)?,
&server,
&quit,
],
)?;
let mut tray = TrayIconBuilder::with_id("tray")
.tooltip("LLeMbas")
.menu(&menu);
if let Some(icon) = handle.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(handle)?;
Ok(TrayItems { status, toggle })
}
/// How often a running app looks for a new release (and once shortly after it starts).
const UPDATE_EVERY: std::time::Duration = std::time::Duration::from_secs(6 * 3600);
/// Ask the update manifest whether a newer release exists; remember and announce the answer.
/// The manifest and each package are signed with the updater key, which the app checks before
/// installing anything.
async fn check_update(handle: &AppHandle) -> Result<Option<String>, String> {
let found = handle
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.map(|u| u.version);
*handle.state::<App>().update.lock().unwrap() = found.clone();
let _ = handle.emit("status", status_of(handle));
Ok(found)
}
fn watch_updates(handle: AppHandle) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(20));
loop {
let _ = tauri::async_runtime::block_on(check_update(&handle));
std::thread::sleep(UPDATE_EVERY);
}
});
}
/// Download and install the newer release, then start again on it. A .deb is installed with
/// the system's password prompt (pkexec); an AppImage replaces itself.
async fn install_update(handle: AppHandle) -> Result<(), String> {
let update = handle
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.ok_or("LLeMbas is up to date.")?;
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|e| format!("The update could not be installed: {e}"))?;
handle.state::<App>().agent.stop();
handle.restart()
}
fn offer_update(h: &AppHandle) {
let h = h.clone();
tauri::async_runtime::spawn(async move {
let version = match check_update(&h).await {
Ok(Some(v)) => v,
Ok(None) => {
h.dialog()
.message(format!(
"LLeMbas {} is the newest release.",
env!("CARGO_PKG_VERSION")
))
.title("LLeMbas is up to date")
.show(|_| {});
return;
}
Err(e) => {
h.dialog()
.message(format!("Could not look for updates: {e}"))
.title("LLeMbas")
.kind(MessageDialogKind::Warning)
.show(|_| {});
return;
}
};
let again = h.clone();
h.dialog()
.message(format!(
"LLeMbas {version} is available (this is {}). Install it now? LLeMbas starts again afterwards; the agent stops for a moment.",
env!("CARGO_PKG_VERSION")
))
.title("Update LLeMbas")
.buttons(tauri_plugin_dialog::MessageDialogButtons::OkCancelCustom(
"Install".into(),
"Later".into(),
))
.show(move |yes| {
if yes {
tauri::async_runtime::spawn(async move {
if let Err(e) = install_update(again.clone()).await {
again
.dialog()
.message(e)
.title("LLeMbas")
.kind(MessageDialogKind::Error)
.show(|_| {});
}
});
}
});
});
}
#[tauri::command]
fn update_app(handle: AppHandle) {
offer_update(&handle);
}
/// A menu item chosen, from the tray or from the title bar's menu: the same ids in both.
fn on_menu(h: &AppHandle, id: &str) {
match id {
"open" => show(h),
"toggle" => {
let on = !h.state::<App>().agent.wanted();
if let Err(e) = set_agent_wanted(h, on) {
h.dialog()
.message(e)
.kind(MessageDialogKind::Error)
.show(|_| {});
}
}
"link" => {
if let Err(e) = start_link(h) {
h.dialog()
.message(e)
.kind(MessageDialogKind::Warning)
.show(|_| {});
}
}
"update" => offer_update(h),
"reload" => {
if let Some(c) = content(h) {
if let Ok(url) = c.url() {
let _ = c.navigate(url);
}
}
}
"server" => {
let _ = open_main(h, None);
}
"quit" => {
h.state::<App>().agent.stop();
h.exit(0);
}
_ => {}
}
}
/// The title bar's menu: the tray's items, where the window is (a desktop without a tray —
/// GNOME without the AppIndicator extension — has no other way to reach them).
/// The title bar's menu, opened under the button that asked for it: `x`, `y` are the button's
/// bottom-left corner in the title bar page. (A plain popup opens at the pointer, which a Wayland
/// compositor does not tell the app — it landed in the middle of the window.)
#[tauri::command]
fn titlebar_menu(window: Window, x: f64, y: f64) -> Result<(), String> {
let h = window.app_handle();
let app = h.state::<App>();
let m = |id: &str, text: &str, on: bool| MenuItem::with_id(h, id, text, on, None::<&str>);
let build = || -> tauri::Result<Menu<Wry>> {
let status = m("status", &app.agent.state().label(), false)?;
let toggle = m(
"toggle",
if app.agent.wanted() {
"Stop the agent"
} else {
"Start the agent"
},
true,
)?;
let link = m("link", "Link this computer…", true)?;
let update = match app.update.lock().unwrap().clone() {
Some(v) => m("update", &format!("Install LLeMbas {v}…"), true)?,
None => m("update", "Check for updates…", true)?,
};
let reload = m("reload", "Reload", true)?;
let server = m("server", "Settings…", true)?;
let quit = m("quit", "Quit LLeMbas", true)?;
Menu::with_items(
h,
&[
&status,
&toggle,
&link,
&PredefinedMenuItem::separator(h)?,
&reload,
&update,
&server,
&PredefinedMenuItem::separator(h)?,
&quit,
],
)
};
let menu = build().map_err(|e| e.to_string())?;
// The title bar sits inside the frame while the window is not maximized.
let framed =
!(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false));
let inset = if framed { FRAME } else { 0.0 };
window
.popup_menu_at(&menu, LogicalPosition::new(x + inset, y + inset))
.map_err(|e| e.to_string())
}
fn on_agent_state(handle: &AppHandle, state: &AgentState) {
let app = handle.state::<App>();
if let Some(t) = app.tray.lock().unwrap().as_ref() {
let _ = t.status.set_text(state.label());
let _ = t.toggle.set_text(if app.agent.wanted() {
"Stop the agent"
} else {
"Start the agent"
});
}
let _ = handle.emit("status", status_of(handle));
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|h, _argv, _cwd| show(h)))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
get_status,
set_server,
set_agent,
link_computer,
titlebar_menu,
update_app,
remote_refresh,
remote_add,
remote_remove,
remote_mode,
remote_terminal
])
.on_menu_event(|h, ev| on_menu(h, ev.id().as_ref()))
.setup(|app| {
let handle = app.handle().clone();
let config_dir = app.path().app_config_dir()?;
if let Ok(dir) = app.path().resource_dir() {
cli::set_bundled(&dir);
}
let log = app.path().app_log_dir()?.join("agent.log");
let notify_handle = handle.clone();
let agent = Agent::new(log, move |s| {
// The notifier runs on the agent's thread; the tray is touched on the main one.
let h = notify_handle.clone();
let s = s.clone();
let _ = notify_handle.run_on_main_thread(move || on_agent_state(&h, &s));
});
let loaded = settings::load(&config_dir);
// Until a server page has said, the server's own default: Moria.
let theme = loaded
.theme
.as_deref()
.and_then(Theme::parse)
.unwrap_or(Theme::Moria);
app.manage(App {
config_dir,
settings: Mutex::new(loaded.clone()),
agent,
tray: Mutex::new(None),
linking: Mutex::new(false),
update: Mutex::new(None),
remote: Mutex::new(None),
theme: Mutex::new(theme),
});
let items = build_tray(&handle)?;
*handle.state::<App>().tray.lock().unwrap() = Some(items);
open_main(&handle, loaded.server.as_deref())?;
watch_updates(handle.clone());
{
let h = handle.clone();
std::thread::spawn(move || {
refresh_remote(&h);
});
}
if let (true, Some(server)) = (loaded.agent, loaded.server) {
handle.state::<App>().agent.start(server);
}
Ok(())
})
.on_window_event(|window, event| match event {
// Closing the window keeps the app (and the agent) in the tray.
WindowEvent::CloseRequested { api, .. } if window.label() == MAIN => {
let _ = window.hide();
api.prevent_close();
}
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. }
if window.label() == MAIN =>
{
layout(window);
let _ = window.emit_to(
TITLEBAR,
"maximized",
window.is_maximized().unwrap_or(false),
);
}
_ => {}
})
.build(tauri::generate_context!())
.expect("LLeMbas Desktop could not start")
.run(|handle, event| {
if let RunEvent::Exit = event {
handle.state::<App>().agent.stop();
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_theme_is_one_of_two_names() {
assert_eq!(Theme::parse("moria"), Some(Theme::Moria));
assert_eq!(Theme::parse("shire"), Some(Theme::Shire));
assert_eq!(Theme::parse("Shire"), None);
assert_eq!(Theme::parse(""), None);
for t in [Theme::Moria, Theme::Shire] {
assert_eq!(Theme::parse(t.name()), Some(t));
}
}
#[test]
fn the_themes_come_out_of_tokens_css_as_two_different_looks() {
// build.rs read these out of ui/tokens.css: a light and a dark theme cannot share
// either colour, and the frame is not the page.
assert_ne!(Theme::Moria.frame(), Theme::Shire.frame());
assert_ne!(Theme::Moria.background(), Theme::Shire.background());
assert_ne!(Theme::Moria.frame(), Theme::Moria.background());
assert_ne!(Theme::Shire.frame(), Theme::Shire.background());
}
#[test]
fn the_theme_report_is_a_navigation_the_handler_can_read() {
let url = Url::parse("llembas-theme:shire").unwrap();
assert_eq!(url.scheme(), THEME_SCHEME);
assert_eq!(Theme::parse(url.path()), Some(Theme::Shire));
assert!(THEME_SCRIPT.contains(THEME_SCHEME));
}
}