LLeMbas Desktop: the server in a window, the CLI's agent on this computer
ci / check (push) Failing after 24s

Tauri 2, no Node. A first-run page chooses the server; then the window is
the server's web UI, whose pages get no IPC. The bundled LLeMbas CLI
(fetched and signature-checked by scripts/fetch-cli.sh) links the computer
with 'lembas login --json' and runs as 'lembas service' while the app runs.
A tray menu starts and stops it. Linux packages from GitHub Actions on a tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-12 08:25:54 +00:00
commit a18950367f
28 files changed
+6801

No files matched your search

+5241
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "llembas-desktop"
version = "0.1.0"
description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer"
authors = ["Jaroslav Beneš"]
license = "MIT"
repository = "https://github.com/LLeMbas/LLeMbas-Desktop"
homepage = "https://llembas.eu"
edition = "2021"
[lib]
# The library holds the app; main.rs only starts it (the shape Tauri expects for mobile too).
name = "llembas_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "The app's own pages (the first-run page). The server's web UI is a remote page and gets no IPC at all: this capability lists no remote URLs.",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+285
View File
@@ -0,0 +1,285 @@
//! The agent: `lembas service run` as a child of the app, kept running while it is wanted.
//!
//! That one process is what makes this computer a device of the server — agent chats started in
//! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged;
//! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in
//! progress ends cleanly) when the agent is switched off or the app quits.
use std::fs::{File, OpenOptions};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli;
/// The CLI's "updated on disk, start me again" exit status.
const RESTART_FOR_UPDATE: i32 = 75;
const BACKOFF: Duration = Duration::from_secs(10);
const STOP_GRACE: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "state", content = "detail", rename_all = "snake_case")]
pub enum State {
/// Not wanted.
Off,
/// This computer is not signed in to the server yet.
NotLinked,
Starting,
Running,
/// Another `lembas service` on this computer holds the link (its pid).
Elsewhere(u32),
/// It ended with an error; trying again shortly.
Failed(String),
}
impl State {
pub fn label(&self) -> String {
match self {
State::Off => "Agent: off".into(),
State::NotLinked => "Agent: this computer is not linked".into(),
State::Starting => "Agent: starting…".into(),
State::Running => "Agent: running".into(),
State::Elsewhere(pid) => format!("Agent: running as a service (pid {pid})"),
State::Failed(why) => format!("Agent: stopped — {why}"),
}
}
}
type Notify = Arc<dyn Fn(&State) + Send + Sync>;
struct Inner {
wanted: bool,
/// Bumped by every start and stop, so an old supervisor notices it is no longer the one.
generation: u64,
child: Option<Child>,
state: State,
}
#[derive(Clone)]
pub struct Agent {
inner: Arc<Mutex<Inner>>,
notify: Notify,
log: PathBuf,
}
impl Agent {
pub fn new(log: PathBuf, notify: impl Fn(&State) + Send + Sync + 'static) -> Self {
Agent {
inner: Arc::new(Mutex::new(Inner {
wanted: false,
generation: 0,
child: None,
state: State::Off,
})),
notify: Arc::new(notify),
log,
}
}
pub fn state(&self) -> State {
self.inner.lock().unwrap().state.clone()
}
pub fn wanted(&self) -> bool {
self.inner.lock().unwrap().wanted
}
fn set(&self, generation: u64, state: State) -> bool {
let mut g = self.inner.lock().unwrap();
if g.generation != generation {
return false;
}
if g.state != state {
g.state = state.clone();
drop(g);
(self.notify)(&state);
}
true
}
/// Run the agent for `server`, and keep it running until `stop`.
pub fn start(&self, server: String) {
self.stop();
let generation = {
let mut g = self.inner.lock().unwrap();
g.wanted = true;
g.generation += 1;
g.generation
};
let me = self.clone();
thread::spawn(move || me.supervise(generation, server));
}
fn supervise(&self, generation: u64, server: String) {
loop {
if !cli::linked_to(&server) {
if !self.set(generation, State::NotLinked) {
return;
}
self.pause(generation, BACKOFF);
continue;
}
if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) {
if !self.set(generation, State::Elsewhere(pid)) {
return;
}
self.pause(generation, BACKOFF);
continue;
}
if !self.set(generation, State::Starting) {
return;
}
let child = match self.spawn() {
Ok(c) => c,
Err(e) => {
if !self.set(
generation,
State::Failed(format!("could not start {}: {e}", cli::binary().display())),
) {
return;
}
self.pause(generation, BACKOFF);
continue;
}
};
{
let mut g = self.inner.lock().unwrap();
if g.generation != generation {
drop(g);
terminate(child);
return;
}
g.child = Some(child);
}
self.set(generation, State::Running);
let code = self.wait_child(generation);
let mut g = self.inner.lock().unwrap();
if g.generation != generation {
return;
}
g.child = None;
drop(g);
match code {
Some(RESTART_FOR_UPDATE) => continue,
Some(0) => {
// It ended by itself without an error: nothing to do but start it again.
self.pause(generation, Duration::from_secs(1));
}
other => {
let why = other.map_or("ended by a signal".to_string(), |c| {
format!("exit status {c}")
});
if !self.set(generation, State::Failed(why)) {
return;
}
self.pause(generation, BACKOFF);
}
}
}
}
fn spawn(&self) -> std::io::Result<Child> {
let (out, err) = self.log_handles();
Command::new(cli::binary())
.args(["service", "run"])
.stdin(Stdio::null())
.stdout(out)
.stderr(err)
.spawn()
}
fn log_handles(&self) -> (Stdio, Stdio) {
let open = || -> Option<File> {
if let Some(d) = self.log.parent() {
std::fs::create_dir_all(d).ok()?;
}
OpenOptions::new()
.create(true)
.append(true)
.open(&self.log)
.ok()
};
match open() {
Some(f) => match f.try_clone() {
Ok(f2) => (Stdio::from(f), Stdio::from(f2)),
Err(_) => (Stdio::from(f), Stdio::null()),
},
None => (Stdio::null(), Stdio::null()),
}
}
fn child_pid(&self) -> Option<u32> {
self.inner.lock().unwrap().child.as_ref().map(|c| c.id())
}
/// Poll the child until it exits (its exit code), or until this supervisor is replaced.
fn wait_child(&self, generation: u64) -> Option<i32> {
loop {
{
let mut g = self.inner.lock().unwrap();
if g.generation != generation {
return None;
}
match g.child.as_mut().map(|c| c.try_wait()) {
Some(Ok(Some(status))) => return status.code(),
Some(Ok(None)) => {}
Some(Err(_)) | None => return None,
}
}
thread::sleep(Duration::from_millis(500));
}
}
/// Sleep, waking early when this supervisor is replaced.
fn pause(&self, generation: u64, d: Duration) {
let until = Instant::now() + d;
while Instant::now() < until {
if self.inner.lock().unwrap().generation != generation {
return;
}
thread::sleep(Duration::from_millis(250));
}
}
/// Stop the agent, waiting for it to end its work (at most `STOP_GRACE`).
pub fn stop(&self) {
let child = {
let mut g = self.inner.lock().unwrap();
g.wanted = false;
g.generation += 1;
g.child.take()
};
if let Some(c) = child {
terminate(c);
}
let mut g = self.inner.lock().unwrap();
if g.state != State::Off {
g.state = State::Off;
drop(g);
(self.notify)(&State::Off);
}
}
}
/// SIGTERM, then SIGKILL after the grace period. The service ends its own work in seconds.
fn terminate(mut child: Child) {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as libc::pid_t, libc::SIGTERM);
}
#[cfg(not(unix))]
let _ = child.kill();
let until = Instant::now() + STOP_GRACE;
while Instant::now() < until {
if let Ok(Some(_)) = child.try_wait() {
return;
}
thread::sleep(Duration::from_millis(100));
}
let _ = child.kill();
let _ = child.wait();
}
+146
View File
@@ -0,0 +1,146 @@
//! The LLeMbas CLI this app carries, and what it keeps on disk. The app never writes the CLI's
//! files: it runs `lembas login --json` and `lembas service run`, and reads two files to know
//! where things stand — the logins (`lembas.json`) and the service's lock (`service.lock`).
//!
//! The directories are the CLI's own (XDG, `lembas` inside each), so the app and a `lembas`
//! installed by hand on the same computer are one device to the server, not two.
use std::path::{Path, PathBuf};
/// The bundled binary: Tauri puts an `externalBin` beside the app's own executable.
/// `LLEMBAS_DESKTOP_CLI` points elsewhere, for development.
pub fn binary() -> PathBuf {
if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") {
return PathBuf::from(p);
}
let name = if cfg!(windows) {
"lembas.exe"
} else {
"lembas"
};
std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(|d| d.join(name)))
.unwrap_or_else(|| PathBuf::from(name))
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_default()
}
/// `$XDG_X_HOME/lembas`, or `~/<fallback>/lembas`: the CLI's `paths` (a relative XDG value
/// is ignored, as the spec says). `LEMBAS_HOME` roots all three, as it does for the CLI.
fn dir(var: &str, fallback: &str) -> PathBuf {
if let Some(root) = std::env::var_os("LEMBAS_HOME") {
let last = fallback.rsplit('/').next().unwrap_or(fallback);
return PathBuf::from(root).join(last).join("lembas");
}
match std::env::var_os(var).map(PathBuf::from) {
Some(p) if p.is_absolute() => p.join("lembas"),
_ => home().join(fallback).join("lembas"),
}
}
pub fn config_dir() -> PathBuf {
dir("XDG_CONFIG_HOME", ".config")
}
pub fn state_dir() -> PathBuf {
dir("XDG_STATE_HOME", ".local/state")
}
/// Whether this computer is signed in to `server` (a base from `settings::normalise`).
pub fn linked_to(server: &str) -> bool {
linked_in(&config_dir(), server)
}
fn linked_in(config: &Path, server: &str) -> bool {
let Ok(text) = std::fs::read_to_string(config.join("lembas.json")) else {
return false;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
return false;
};
let want = server.trim_end_matches('/');
v.get("instances")
.and_then(|i| i.as_object())
.map(|all| {
all.values().any(|i| {
i.get("base_url")
.and_then(|b| b.as_str())
.is_some_and(|b| b.trim_end_matches('/') == want)
})
})
.unwrap_or(false)
}
/// The pid of a `lembas service run` already going on this computer (installed as a service,
/// or started by hand), if any: then the app does not start another — one link per device.
pub fn running_service() -> Option<u32> {
running_in(&state_dir())
}
fn running_in(state: &Path) -> Option<u32> {
let pid: u32 = std::fs::read_to_string(state.join("service.lock"))
.ok()?
.trim()
.parse()
.ok()?;
(pid != 0 && alive(pid)).then_some(pid)
}
#[cfg(unix)]
pub fn alive(pid: u32) -> bool {
// Signal 0 checks without sending; EPERM means it exists but is someone else's.
let r = unsafe { libc::kill(pid as libc::pid_t, 0) };
r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
pub fn alive(_pid: u32) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("llembas-cli-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn linked_reads_the_logins_the_cli_wrote() {
let d = scratch("linked");
assert!(!linked_in(&d, "https://ai.example"));
fs::write(
d.join("lembas.json"),
r#"{"instances":{"example":{"base_url":"https://ai.example","connection":"example"}}}"#,
)
.unwrap();
assert!(linked_in(&d, "https://ai.example"));
assert!(linked_in(&d, "https://ai.example/"));
assert!(!linked_in(&d, "https://other.example"));
fs::remove_dir_all(&d).unwrap();
}
#[cfg(unix)]
#[test]
fn a_lock_counts_only_while_its_process_lives() {
let d = scratch("lock");
assert_eq!(running_in(&d), None);
fs::write(d.join("service.lock"), std::process::id().to_string()).unwrap();
assert_eq!(running_in(&d), Some(std::process::id()));
// A pid far above any pid_max: no such process.
fs::write(d.join("service.lock"), "2147483000").unwrap();
assert_eq!(running_in(&d), None);
fs::remove_dir_all(&d).unwrap();
}
}
+447
View File
@@ -0,0 +1,447 @@
//! 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`.** Before a server is chosen it shows the app's own first-run page
//! (`ui/`); after, the server itself. Closing it hides it; the tray brings 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 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 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::{
AppHandle, Emitter, Manager, RunEvent, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_opener::OpenerExt;
use agent::{Agent, State as AgentState};
use settings::Settings;
const MAIN: &str = "main";
struct App {
config_dir: PathBuf,
settings: Mutex<Settings>,
agent: Agent,
tray: Mutex<Option<TrayItems>>,
linking: Mutex<bool>,
}
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())
}
}
/// What the first-run page shows.
#[derive(Serialize)]
struct Status {
version: &'static str,
server: Option<String>,
linked: bool,
agent: AgentState,
agent_wanted: bool,
}
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(),
}
}
#[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),
_ => app.agent.stop(),
}
Ok(())
}
/// 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")
}
/// 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) = handle.get_webview_window(MAIN) {
w.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(());
}
let url = match target {
Some(u) => WebviewUrl::External(u),
None => WebviewUrl::App("index.html".into()),
};
let nav = handle.clone();
WebviewWindowBuilder::new(handle, MAIN, url)
.title("LLeMbas")
.inner_size(1280.0, 840.0)
.min_inner_size(420.0, 560.0)
.on_navigation(move |url| allowed(&nav, url))
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
/// The window may show the app's own pages and the server; anything else opens in the browser.
fn allowed(handle: &AppHandle, url: &Url) -> bool {
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
}
/// 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(w) = handle.get_webview_window(MAIN) {
let _ = w.navigate(url);
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) = handle.get_webview_window(MAIN) {
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", "Change server…", 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)
.on_menu_event(|h, ev| match ev.id().as_ref() {
"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(|_| {});
}
}
"server" => {
let _ = open_main(h, None);
}
"quit" => {
h.state::<App>().agent.stop();
h.exit(0);
}
_ => {}
});
if let Some(icon) = handle.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(handle)?;
Ok(TrayItems { status, toggle })
}
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())
.invoke_handler(tauri::generate_handler![
get_status,
set_server,
set_agent,
link_computer
])
.setup(|app| {
let handle = app.handle().clone();
let config_dir = app.path().app_config_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);
app.manage(App {
config_dir,
settings: Mutex::new(loaded.clone()),
agent,
tray: Mutex::new(None),
linking: Mutex::new(false),
});
let items = build_tray(&handle)?;
*handle.state::<App>().tray.lock().unwrap() = Some(items);
open_main(&handle, loaded.server.as_deref())?;
if let (true, Some(server)) = (loaded.agent, loaded.server) {
handle.state::<App>().agent.start(server);
}
Ok(())
})
.on_window_event(|window, event| {
// Closing the window keeps the app (and the agent) in the tray.
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() == MAIN {
let _ = window.hide();
api.prevent_close();
}
}
})
.build(tauri::generate_context!())
.expect("LLeMbas Desktop could not start")
.run(|handle, event| {
if let RunEvent::Exit = event {
handle.state::<App>().agent.stop();
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// No console window behind the app on Windows, in a release build.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
llembas_desktop_lib::run()
}
+92
View File
@@ -0,0 +1,92 @@
//! What the app remembers: which server it opens, and whether the agent should run. One small
//! JSON file in the app's config directory; nothing secret is in it (the device's key is the
//! LLeMbas CLI's, in its own config).
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
/// The server's base address, `https://host[:port]`, with no trailing slash.
pub server: Option<String>,
/// Run the agent while the app runs (once this computer is linked to the server).
pub agent: bool,
}
pub fn file(config_dir: &Path) -> PathBuf {
config_dir.join("settings.json")
}
pub fn load(config_dir: &Path) -> Settings {
fs::read_to_string(file(config_dir))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(config_dir: &Path, s: &Settings) -> std::io::Result<()> {
fs::create_dir_all(config_dir)?;
let tmp = config_dir.join("settings.json.tmp");
fs::write(&tmp, serde_json::to_string_pretty(s)? + "\n")?;
fs::rename(tmp, file(config_dir))
}
/// An address as typed (`ai.example`, `https://ai.example/`, `http://10.1.2.3:8080/chat`)
/// as a base: https unless it says http, scheme and host and port only. The same rule as
/// `lembas login`, so both name the instance alike.
pub fn normalise(address: &str) -> Result<String, String> {
let a = address.trim();
if a.is_empty() {
return Err("Enter the address of your LLeMbas server.".into());
}
let with_scheme = if a.contains("://") {
a.to_string()
} else {
format!("https://{a}")
};
let url = tauri::Url::parse(&with_scheme).map_err(|e| format!("Not an address: {e}"))?;
if url.scheme() != "https" && url.scheme() != "http" {
return Err("The address must start with https:// or http://.".into());
}
let host = url.host_str().ok_or("The address has no host.")?;
Ok(match url.port() {
Some(p) => format!("{}://{}:{}", url.scheme(), host, p),
None => format!("{}://{}", url.scheme(), host),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_address_becomes_a_base() {
assert_eq!(normalise("ai.example").unwrap(), "https://ai.example");
assert_eq!(
normalise(" https://ai.example/ ").unwrap(),
"https://ai.example"
);
assert_eq!(
normalise("http://10.1.2.3:8080/chat?x=1").unwrap(),
"http://10.1.2.3:8080"
);
assert!(normalise("").is_err());
assert!(normalise("ftp://ai.example").is_err());
}
#[test]
fn settings_round_trip_and_survive_a_missing_file() {
let dir = std::env::temp_dir().join(format!("llembas-desktop-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
assert_eq!(load(&dir), Settings::default());
let s = Settings {
server: Some("https://ai.example".into()),
agent: true,
};
save(&dir, &s).unwrap();
assert_eq!(load(&dir), s);
fs::remove_dir_all(&dir).unwrap();
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LLeMbas",
"mainBinaryName": "llembas-desktop",
"identifier": "eu.llembas.desktop",
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src ipc: http://ipc.localhost"
}
},
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"category": "Productivity",
"shortDescription": "Your LLeMbas server in a window, with the agent on this computer",
"longDescription": "LLeMbas Desktop opens your LLeMbas server's web UI in a window of its own and runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal.",
"license": "MIT",
"homepage": "https://llembas.eu",
"publisher": "LLeMbas",
"copyright": "Copyright (c) 2026 Jaroslav Beneš",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.ico"
],
"externalBin": ["binaries/lembas"]
}
}