LLeMbas's own window, the leaf icon, one name for the app
ci / check (push) Failing after 3s

- The window has no system decoration: a local title bar webview (agent
  state, a native menu with the tray's items, window buttons) above the
  content webview, which stays remote with no IPC. Laid out on resize,
  with a frame to resize by unless maximized.
- Icons from the transparent pixel leaf, not the server's tiled icon.
- productName llembas-desktop: package, .desktop file and the window's
  app id now match (KWin showed a generic icon); the visible name stays
  LLeMbas through a desktop template. Replaces the 0.1.0 package l-le-mbas.
- The bundled CLI is a resource in the app's own directory, not
  /usr/bin/lembas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-14 15:54:55 +00:00
1 parent 5045ba025a
commit daf2cf31ce
22 files changed
+489 -107

No files matched your search

+1
View File
@@ -2,4 +2,5 @@
/src-tauri/target/ /src-tauri/target/
/src-tauri/gen/ /src-tauri/gen/
# The bundled LLeMbas CLI: fetched and verified by scripts/fetch-cli.sh, never committed # The bundled LLeMbas CLI: fetched and verified by scripts/fetch-cli.sh, never committed
/src-tauri/binaries/lembas
/src-tauri/binaries/lembas-* /src-tauri/binaries/lembas-*
+16
View File
@@ -6,6 +6,22 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased] ## [Unreleased]
### Added
- The window is LLeMbas's own: a title bar in the LLeMbas look with the server's address, the
agent's state, a menu (the tray's items, plus Reload) and the window buttons. Closing still keeps
the app in the tray. The server's pages stay in a view of their own, with no access to the app.
### Fixed
- The app icon is the leaf alone, on a transparent background, instead of a dark square tile.
- The window shows the app's icon instead of a generic one (the window, the menu entry and the
package now share one name, `llembas-desktop`).
- The package is called `llembas-desktop` (it was `l-le-mbas`) and replaces the old one.
- The bundled LLeMbas CLI is installed in the app's own directory instead of as `/usr/bin/lembas`,
where it could hide, or be hidden by, a `lembas` installed by hand.
- `SHA256SUMS` on the GitHub release no longer lists itself.
## [0.1.0] — 2026-10-14 ## [0.1.0] — 2026-10-14
The first alpha, for Linux. It carries LLeMbas CLI 1.1.0. The first alpha, for Linux. It carries LLeMbas CLI 1.1.0.
+9 -7
View File
@@ -7,8 +7,9 @@ Your [LLeMbas](https://llembas.eu) server in a window of its own, with the agent
- **The agent, built in.** LLeMbas Desktop carries the [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI) - **The agent, built in.** LLeMbas Desktop carries the [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI)
and runs it in the background as this computer's device, so agent chats started on the server and runs it in the background as this computer's device, so agent chats started on the server
can work in your projects here, without a terminal. can work in your projects here, without a terminal.
- **In the tray**: close the window and the agent keeps running; the tray menu starts and stops it, - **Its own window**: a LLeMbas title bar with the agent's state and a menu (start and stop the
links the computer, and changes the server. agent, link the computer, reload, change server, quit). Close the window and the agent keeps
running in the tray, which has the same menu.
> **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later. > **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later.
@@ -19,12 +20,12 @@ Download the `.deb` or the AppImage from the
`SHA256SUMS` in the same release. `SHA256SUMS` in the same release.
```sh ```sh
sudo apt install ./LLeMbas_*_amd64.deb # Debian, Ubuntu sudo apt install ./llembas-desktop_*_amd64.deb # Debian, Ubuntu (replaces 0.1.0's l-le-mbas)
chmod +x LLeMbas_*.AppImage && ./LLeMbas_*.AppImage chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage
``` ```
On GNOME the tray needs the *AppIndicator* extension; without it, closing the window hides the On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has
app, and starting it again brings the window back. everything, and starting the app again brings a hidden window back.
## Use ## Use
@@ -33,7 +34,8 @@ app, and starting it again brings the window back.
3. The agent starts. In the server's new-chat screen, choose this computer for an agent chat. 3. The agent starts. In the server's new-chat screen, choose this computer for an agent chat.
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
(`~/.config/lembas`). If you also installed the CLI and run `lembas service` yourself, the app sees (`~/.config/lembas`). The app's copy of the CLI lives in its own directory
(`/usr/lib/llembas-desktop/`), not on your `PATH`. If you also installed the CLI and run `lembas service` yourself, the app sees
it and does not start a second one: they are the same device. it and does not start a second one: they are the same device.
## Build ## Build
+6 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Put the LLeMbas CLI that LLeMbas Desktop carries where Tauri's bundler wants it: # Put the LLeMbas CLI that LLeMbas Desktop carries where the bundle takes it from:
# src-tauri/binaries/lembas-<target triple> # src-tauri/binaries/lembas (a resource: installed in the app's own directory, never on PATH)
# #
# scripts/fetch-cli.sh [target-triple] (default: this machine's, from rustc) # scripts/fetch-cli.sh [target-triple] (default: this machine's, from rustc)
# #
@@ -39,6 +39,7 @@ ssh-keygen -Y verify -f "$work/allowed" -I lembas-release -n lembas-release \
|| { echo "fetch-cli: $ASSET.gz does not match SHA256SUMS" >&2; exit 1; } || { echo "fetch-cli: $ASSET.gz does not match SHA256SUMS" >&2; exit 1; }
mkdir -p src-tauri/binaries mkdir -p src-tauri/binaries
gunzip -c "$work/$ASSET.gz" > "src-tauri/binaries/lembas-$TRIPLE" gunzip -c "$work/$ASSET.gz" > src-tauri/binaries/lembas
chmod 755 "src-tauri/binaries/lembas-$TRIPLE" chmod 755 src-tauri/binaries/lembas
echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas-$TRIPLE" rm -f src-tauri/binaries/lembas-* # where an earlier version of this script put it
echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas"
+1 -1
View File
@@ -1987,7 +1987,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]] [[package]]
name = "llembas-desktop" name = "llembas-desktop"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"libc", "libc",
"serde", "serde",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "llembas-desktop" name = "llembas-desktop"
version = "0.1.0" version = "0.2.0"
description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer" description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer"
authors = ["Jaroslav Beneš"] authors = ["Jaroslav Beneš"]
license = "MIT" license = "MIT"
@@ -17,7 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2", features = ["tray-icon", "unstable"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
+10 -2
View File
@@ -1,7 +1,15 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "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.", "description": "The app's own pages: the title bar and the first-run page. The server's web UI is a remote page in the same window and gets no IPC at all: this capability lists no remote URL. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default"] "permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-internal-toggle-maximize",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close"
]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Categories={{categories}}
{{#if comment}}
Comment={{comment}}
{{/if}}
Exec={{exec}}
StartupWMClass={{exec}}
Icon={{icon}}
Name=LLeMbas
GenericName=LLM workspace
Terminal=false
Type=Application
+25 -9
View File
@@ -6,22 +6,38 @@
//! installed by hand on the same computer are one device to the server, not two. //! installed by hand on the same computer are one device to the server, not two.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// The bundled binary: Tauri puts an `externalBin` beside the app's own executable. static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
/// `LLEMBAS_DESKTOP_CLI` points elsewhere, for development.
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
/// the .deb, inside the AppImage otherwise) — the app's own copy, never on PATH, so it neither
/// shadows nor is shadowed by a `lembas` installed by hand. Set once at start.
pub fn set_bundled(resource_dir: &Path) {
let _ = BUNDLED.set(resource_dir.join(exe_name()));
}
fn exe_name() -> &'static str {
if cfg!(windows) {
"lembas.exe"
} else {
"lembas"
}
}
/// The CLI to run: `LLEMBAS_DESKTOP_CLI` (development), else the bundled one, else one beside
/// the app's executable (`cargo tauri dev`).
pub fn binary() -> PathBuf { pub fn binary() -> PathBuf {
if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") { if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") {
return PathBuf::from(p); return PathBuf::from(p);
} }
let name = if cfg!(windows) { if let Some(p) = BUNDLED.get().filter(|p| p.exists()) {
"lembas.exe" return p.clone();
} else { }
"lembas"
};
std::env::current_exe() std::env::current_exe()
.ok() .ok()
.and_then(|e| e.parent().map(|d| d.join(name))) .and_then(|e| e.parent().map(|d| d.join(exe_name())))
.unwrap_or_else(|| PathBuf::from(name)) .unwrap_or_else(|| PathBuf::from(exe_name()))
} }
fn home() -> PathBuf { fn home() -> PathBuf {
+163 -26
View File
@@ -1,8 +1,11 @@
//! LLeMbas Desktop: the server's web UI in a window of its own, and the LLeMbas CLI's agent //! 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. //! 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 //! - **One window, `main`, drawn by the app**: no system decoration, the app's own title bar (a
//! (`ui/`); after, the server itself. Closing it hides it; the tray brings it back. //! 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 //! - **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. //! 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 //! - **The agent is the CLI's**: `lembas login --json` links this computer, and
@@ -20,8 +23,11 @@ use std::sync::Mutex;
use serde::Serialize; use serde::Serialize;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
use tauri::webview::WebviewBuilder;
use tauri::window::{Color, WindowBuilder};
use tauri::{ use tauri::{
AppHandle, Emitter, Manager, RunEvent, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize,
RunEvent, Url, Webview, WebviewUrl, Window, WindowEvent, Wry,
}; };
use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
@@ -30,6 +36,13 @@ use agent::{Agent, State as AgentState};
use settings::Settings; use settings::Settings;
const MAIN: &str = "main"; 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;
struct App { struct App {
config_dir: PathBuf, config_dir: PathBuf,
@@ -156,35 +169,92 @@ fn local_page() -> Url {
Url::parse(base).expect("a fixed URL") 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. /// 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> { fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> {
let target = match server { let target = match server {
Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?), Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?),
None => None, None => None,
}; };
if let Some(w) = handle.get_webview_window(MAIN) { if let (Some(w), Some(c)) = (main_window(handle), content(handle)) {
w.navigate(target.unwrap_or_else(local_page)) c.navigate(target.unwrap_or_else(local_page))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let _ = w.show(); let _ = w.show();
let _ = w.unminimize(); let _ = w.unminimize();
let _ = w.set_focus(); let _ = w.set_focus();
return Ok(()); return Ok(());
} }
build_main(handle, target).map_err(|e| e.to_string())
}
fn build_main(handle: &AppHandle, target: Option<Url>) -> tauri::Result<()> {
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 Moria border, as `--border` in the pages.
.background_color(Color(0x26, 0x30, 0x3A, 0xFF))
.build()?;
let size = window
.inner_size()?
.to_logical::<f64>(window.scale_factor()?);
window.add_child(
WebviewBuilder::new(TITLEBAR, WebviewUrl::App("titlebar.html".into())),
LogicalPosition::new(FRAME, FRAME),
LogicalSize::new(size.width - 2.0 * FRAME, TITLE_H),
)?;
let url = match target { let url = match target {
Some(u) => WebviewUrl::External(u), Some(u) => WebviewUrl::External(u),
None => WebviewUrl::App("index.html".into()), None => WebviewUrl::App("index.html".into()),
}; };
let nav = handle.clone(); let nav = handle.clone();
WebviewWindowBuilder::new(handle, MAIN, url) window.add_child(
.title("LLeMbas") WebviewBuilder::new(CONTENT, url).on_navigation(move |url| allowed(&nav, url)),
.inner_size(1280.0, 840.0) LogicalPosition::new(FRAME, FRAME + TITLE_H),
.min_inner_size(420.0, 560.0) LogicalSize::new(
.on_navigation(move |url| allowed(&nav, url)) size.width - 2.0 * FRAME,
.build() (size.height - TITLE_H - 2.0 * FRAME).max(0.0),
.map_err(|e| e.to_string())?; ),
)?;
Ok(()) Ok(())
} }
/// 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 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. /// 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 { fn allowed(handle: &AppHandle, url: &Url) -> bool {
let local = matches!(url.scheme(), "tauri" | "asset") let local = matches!(url.scheme(), "tauri" | "asset")
@@ -250,8 +320,10 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
if let Ok(url) = Url::parse(link) { if let Ok(url) = Url::parse(link) {
if let Some(w) = handle.get_webview_window(MAIN) { if let Some(c) = content(&handle) {
let _ = w.navigate(url); let _ = c.navigate(url);
}
if let Some(w) = main_window(&handle) {
let _ = w.show(); let _ = w.show();
let _ = w.set_focus(); let _ = w.set_focus();
} }
@@ -304,7 +376,7 @@ fn status_of(handle: &AppHandle) -> Status {
fn show(handle: &AppHandle) { fn show(handle: &AppHandle) {
let server = handle.state::<App>().settings().server; let server = handle.state::<App>().settings().server;
if let Some(w) = handle.get_webview_window(MAIN) { if let Some(w) = main_window(handle) {
let _ = w.show(); let _ = w.show();
let _ = w.unminimize(); let _ = w.unminimize();
let _ = w.set_focus(); let _ = w.set_focus();
@@ -341,8 +413,17 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
)?; )?;
let mut tray = TrayIconBuilder::with_id("tray") let mut tray = TrayIconBuilder::with_id("tray")
.tooltip("LLeMbas") .tooltip("LLeMbas")
.menu(&menu) .menu(&menu);
.on_menu_event(|h, ev| match ev.id().as_ref() { if let Some(icon) = handle.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(handle)?;
Ok(TrayItems { status, toggle })
}
/// 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), "open" => show(h),
"toggle" => { "toggle" => {
let on = !h.state::<App>().agent.wanted(); let on = !h.state::<App>().agent.wanted();
@@ -361,6 +442,13 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
.show(|_| {}); .show(|_| {});
} }
} }
"reload" => {
if let Some(c) = content(h) {
if let Ok(url) = c.url() {
let _ = c.navigate(url);
}
}
}
"server" => { "server" => {
let _ = open_main(h, None); let _ = open_main(h, None);
} }
@@ -369,12 +457,47 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
h.exit(0); h.exit(0);
} }
_ => {} _ => {}
});
if let Some(icon) = handle.default_window_icon() {
tray = tray.icon(icon.clone());
} }
tray.build(handle)?; }
Ok(TrayItems { status, toggle })
/// 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).
#[tauri::command]
fn titlebar_menu(window: Window) -> 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 reload = m("reload", "Reload", true)?;
let server = m("server", "Change server…", true)?;
let quit = m("quit", "Quit LLeMbas", true)?;
Menu::with_items(
h,
&[
&status,
&toggle,
&link,
&PredefinedMenuItem::separator(h)?,
&reload,
&server,
&PredefinedMenuItem::separator(h)?,
&quit,
],
)
};
let menu = build().map_err(|e| e.to_string())?;
window.popup_menu(&menu).map_err(|e| e.to_string())
} }
fn on_agent_state(handle: &AppHandle, state: &AgentState) { fn on_agent_state(handle: &AppHandle, state: &AgentState) {
@@ -399,11 +522,16 @@ pub fn run() {
get_status, get_status,
set_server, set_server,
set_agent, set_agent,
link_computer link_computer,
titlebar_menu
]) ])
.on_menu_event(|h, ev| on_menu(h, ev.id().as_ref()))
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
let config_dir = app.path().app_config_dir()?; 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 log = app.path().app_log_dir()?.join("agent.log");
let notify_handle = handle.clone(); let notify_handle = handle.clone();
let agent = Agent::new(log, move |s| { let agent = Agent::new(log, move |s| {
@@ -428,14 +556,23 @@ pub fn run() {
} }
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| match event {
// Closing the window keeps the app (and the agent) in the tray. // Closing the window keeps the app (and the agent) in the tray.
if let WindowEvent::CloseRequested { api, .. } = event { WindowEvent::CloseRequested { api, .. } if window.label() == MAIN => {
if window.label() == MAIN {
let _ = window.hide(); let _ = window.hide();
api.prevent_close(); 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!()) .build(tauri::generate_context!())
.expect("LLeMbas Desktop could not start") .expect("LLeMbas Desktop could not start")
+19 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "LLeMbas", "productName": "llembas-desktop",
"mainBinaryName": "llembas-desktop", "mainBinaryName": "llembas-desktop",
"identifier": "eu.llembas.desktop", "identifier": "eu.llembas.desktop",
"build": { "build": {
@@ -15,7 +15,10 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["deb", "appimage"], "targets": [
"deb",
"appimage"
],
"category": "Productivity", "category": "Productivity",
"shortDescription": "Your LLeMbas server in a window, with the agent on this computer", "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.", "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.",
@@ -30,6 +33,19 @@
"icons/icon.png", "icons/icon.png",
"icons/icon.ico" "icons/icon.ico"
], ],
"externalBin": ["binaries/lembas"] "resources": {
"binaries/lembas": "lembas"
},
"linux": {
"deb": {
"desktopTemplate": "llembas-desktop.desktop",
"conflicts": [
"l-le-mbas"
],
"replaces": [
"l-le-mbas"
]
}
}
} }
} }
+1 -52
View File
@@ -1,55 +1,4 @@
/* The first-run page: the LLeMbas look (Moria dark, Shire light), no build step. */ /* The first-run page. Tokens in tokens.css. */
:root {
--font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-xl: 1.5rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--radius: 2px;
--press: 2px;
--control-h: 2.5rem;
--card-max: 34rem;
color-scheme: dark;
--bg: #0B0D10;
--surface: #11151A;
--surface-hover: #1D232B;
--border: #26303A;
--border-strong: #5C6976;
--ink: #E2E8EC;
--ink-muted: #A6B2BC;
--accent: #8FD8E8;
--accent-hover: #B4E6F1;
--accent-ink: #061418;
--leaf: #7EE081;
--warning: #F0A840;
--shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55);
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #F1EAD6;
--surface: #F8F3E5;
--surface-hover: #ECE4CF;
--border: #CBBF9D;
--border-strong: #2A2E22;
--ink: #1F2418;
--ink-muted: #474D3B;
--accent: #285F3C;
--accent-hover: #1F4D30;
--accent-ink: #F8F3E5;
--leaf: #3D6B1C;
--warning: #7E5200;
--shadow-card: 4px 4px 0 var(--border-strong);
}
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; } html, body { margin: 0; min-height: 100%; }
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>LLeMbas</title> <title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
<script src="app.js" defer></script> <script src="app.js" defer></script>
</head> </head>
+83
View File
@@ -0,0 +1,83 @@
/* The title bar: one row, fixed height, every slot a fixed or shared track so nothing in it
moves when the host name or the agent's state changes length. */
* { box-sizing: border-box; }
html, body {
margin: 0;
height: var(--titlebar-h);
overflow: hidden;
background: var(--bg);
color: var(--ink);
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.bar {
display: grid;
/* leaf · name · host (takes the rest, and is the drag area) · agent · menu · sep · 3 controls */
grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto var(--titlebar-h) 1px repeat(3, 2.875rem);
align-items: center;
height: var(--titlebar-h);
border-bottom: 1px solid var(--border);
font: 600 var(--text-sm) / 1 var(--font-ui);
}
.bar__leaf { justify-self: center; width: 18px; height: 18px; image-rendering: pixelated; }
.bar__name b { color: var(--leaf); font-weight: 700; }
.bar__host {
padding: 0 var(--sp-3);
color: var(--ink-muted);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-self: stretch;
display: flex;
align-items: center;
}
button {
height: var(--titlebar-h);
margin: 0;
padding: 0;
font: inherit;
color: var(--ink-muted);
background: transparent;
border: 0;
border-radius: 0;
cursor: pointer;
}
button:hover { color: var(--ink); background: var(--surface-hover); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.bar__agent {
display: grid;
grid-template-columns: 0.5rem auto;
gap: var(--sp-2);
align-items: center;
padding: 0 var(--sp-3);
font-weight: 400;
}
.dot { width: 0.5rem; height: 0.5rem; background: var(--ink-muted); }
.bar__agent[data-state="running"] .dot,
.bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
.bar__agent[data-state="failed"] .dot,
.bar__agent[data-state="not_linked"] .dot { background: var(--warning); }
.bar__sep { width: 1px; height: 1.25rem; background: var(--border); }
.bar__btn { display: grid; place-items: center; }
.bar__btn svg { width: 16px; height: 16px; }
.bar__btn--close:hover { color: #fff; background: var(--danger); }
#maximize .i-restore { display: none; }
#maximize[data-maximized] .i-max { display: none; }
#maximize[data-maximized] .i-restore { display: block; }
/* Narrow windows: the host goes first, then the agent's words (its dot stays). */
@media (max-width: 34rem) {
.bar__host { visibility: hidden; }
#agent-text { display: none; }
}
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="dark light">
<title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="titlebar.css">
<script src="titlebar.js" defer></script>
</head>
<body>
<header class="bar" data-tauri-drag-region>
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<span class="bar__host" id="host" data-tauri-drag-region></span>
<button class="bar__agent" id="agent" type="button" title="Agent">
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
</button>
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu">
<svg viewBox="0 0 16 16" aria-hidden="true"><g fill="currentColor"><rect x="3" y="4" width="10" height="1.5"/><rect x="3" y="7.25" width="10" height="1.5"/><rect x="3" y="10.5" width="10" height="1.5"/></g></svg>
</button>
<span class="bar__sep" aria-hidden="true"></span>
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise">
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="10" width="8" height="1.5" fill="currentColor"/></svg>
</button>
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise">
<svg class="i-max" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.75 4.75h6.5v6.5h-6.5z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="i-restore" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.75 6.25h6v6h-6z M6.25 6.25v-2.5h6v6h-2.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</header>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
// The title bar. Moving and double-click maximising come from `data-tauri-drag-region`; the
// buttons call the window directly; the menu and the agent pill open the app's native menu (the
// tray's items), which can reach past this 36-pixel-high page.
"use strict"
const { invoke } = window.__TAURI__.core
const { listen } = window.__TAURI__.event
const win = window.__TAURI__.window.getCurrentWindow()
const $ = (id) => document.getElementById(id)
const WORDS = {
off: "agent off",
not_linked: "not linked",
starting: "agent starting…",
running: "agent running",
elsewhere: "agent running (service)",
failed: "agent stopped",
}
function render(s) {
let host = ""
try {
host = s.server ? new URL(s.server).host : ""
} catch {}
$("host").textContent = host
$("host").title = s.server || ""
const state = s.agent.state
$("agent").dataset.state = state
$("agent-text").textContent = WORDS[state] ?? state
$("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent"
}
function maximized(on) {
const b = $("maximize")
b.toggleAttribute("data-maximized", on)
b.setAttribute("aria-label", on ? "Restore" : "Maximise")
b.title = on ? "Restore" : "Maximise"
}
$("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close())
$("menu").addEventListener("click", () => invoke("titlebar_menu"))
$("agent").addEventListener("click", () => invoke("titlebar_menu"))
listen("status", (e) => render(e.payload))
listen("maximized", (e) => maximized(!!e.payload))
invoke("get_status").then(render)
win.isMaximized().then(maximized)
+54
View File
@@ -0,0 +1,54 @@
/* LLeMbas Desktop's own pages: one set of tokens (Moria dark, Shire light), as on llembas.eu. */
:root {
--font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-xl: 1.5rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--radius: 2px;
--press: 2px;
--control-h: 2.5rem;
--card-max: 34rem;
color-scheme: dark;
--bg: #0B0D10;
--surface: #11151A;
--surface-hover: #1D232B;
--border: #26303A;
--border-strong: #5C6976;
--ink: #E2E8EC;
--ink-muted: #A6B2BC;
--accent: #8FD8E8;
--accent-hover: #B4E6F1;
--accent-ink: #061418;
--leaf: #7EE081;
--warning: #F0A840;
--shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55);
--danger: #E5534B;
--titlebar-h: 36px;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #F1EAD6;
--surface: #F8F3E5;
--surface-hover: #ECE4CF;
--border: #CBBF9D;
--border-strong: #2A2E22;
--ink: #1F2418;
--ink-muted: #474D3B;
--accent: #285F3C;
--accent-hover: #1F4D30;
--accent-ink: #F8F3E5;
--leaf: #3D6B1C;
--warning: #7E5200;
--shadow-card: 4px 4px 0 var(--border-strong);
--danger: #B3261E;
}
}