- 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>
No files matched your search
@@ -1987,7 +1987,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "llembas-desktop"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
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"
|
||||
authors = ["Jaroslav Beneš"]
|
||||
license = "MIT"
|
||||
@@ -17,7 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri = { version = "2", features = ["tray-icon", "unstable"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
{
|
||||
"$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.",
|
||||
"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"],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 827 B |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 515 B |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -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
|
||||
@@ -6,22 +6,38 @@
|
||||
//! installed by hand on the same computer are one device to the server, not two.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The bundled binary: Tauri puts an `externalBin` beside the app's own executable.
|
||||
/// `LLEMBAS_DESKTOP_CLI` points elsewhere, for development.
|
||||
static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// 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 {
|
||||
if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let name = if cfg!(windows) {
|
||||
"lembas.exe"
|
||||
} else {
|
||||
"lembas"
|
||||
};
|
||||
if let Some(p) = BUNDLED.get().filter(|p| p.exists()) {
|
||||
return p.clone();
|
||||
}
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|e| e.parent().map(|d| d.join(name)))
|
||||
.unwrap_or_else(|| PathBuf::from(name))
|
||||
.and_then(|e| e.parent().map(|d| d.join(exe_name())))
|
||||
.unwrap_or_else(|| PathBuf::from(exe_name()))
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! 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.
|
||||
//! - **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 agent is the CLI's**: `lembas login --json` links this computer, and
|
||||
@@ -20,8 +23,11 @@ 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, 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_opener::OpenerExt;
|
||||
@@ -30,6 +36,13 @@ use agent::{Agent, State as AgentState};
|
||||
use settings::Settings;
|
||||
|
||||
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 {
|
||||
config_dir: PathBuf,
|
||||
@@ -156,35 +169,92 @@ fn local_page() -> 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.
|
||||
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))
|
||||
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 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 {
|
||||
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())?;
|
||||
window.add_child(
|
||||
WebviewBuilder::new(CONTENT, url).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),
|
||||
),
|
||||
)?;
|
||||
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.
|
||||
fn allowed(handle: &AppHandle, url: &Url) -> bool {
|
||||
let local = matches!(url.scheme(), "tauri" | "asset")
|
||||
@@ -250,8 +320,10 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||
.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);
|
||||
if let Some(c) = content(&handle) {
|
||||
let _ = c.navigate(url);
|
||||
}
|
||||
if let Some(w) = main_window(&handle) {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
@@ -304,7 +376,7 @@ fn status_of(handle: &AppHandle) -> Status {
|
||||
|
||||
fn show(handle: &AppHandle) {
|
||||
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.unminimize();
|
||||
let _ = w.set_focus();
|
||||
@@ -341,35 +413,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
|
||||
)?;
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
.menu(&menu);
|
||||
if let Some(icon) = handle.default_window_icon() {
|
||||
tray = tray.icon(icon.clone());
|
||||
}
|
||||
@@ -377,6 +421,85 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
|
||||
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),
|
||||
"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(|_| {});
|
||||
}
|
||||
}
|
||||
"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).
|
||||
#[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) {
|
||||
let app = handle.state::<App>();
|
||||
if let Some(t) = app.tray.lock().unwrap().as_ref() {
|
||||
@@ -399,11 +522,16 @@ pub fn run() {
|
||||
get_status,
|
||||
set_server,
|
||||
set_agent,
|
||||
link_computer
|
||||
link_computer,
|
||||
titlebar_menu
|
||||
])
|
||||
.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| {
|
||||
@@ -428,14 +556,23 @@ pub fn run() {
|
||||
}
|
||||
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.
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == MAIN {
|
||||
let _ = window.hide();
|
||||
api.prevent_close();
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LLeMbas",
|
||||
"productName": "llembas-desktop",
|
||||
"mainBinaryName": "llembas-desktop",
|
||||
"identifier": "eu.llembas.desktop",
|
||||
"build": {
|
||||
@@ -15,7 +15,10 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "appimage"],
|
||||
"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.",
|
||||
@@ -30,6 +33,19 @@
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"externalBin": ["binaries/lembas"]
|
||||
"resources": {
|
||||
"binaries/lembas": "lembas"
|
||||
},
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "llembas-desktop.desktop",
|
||||
"conflicts": [
|
||||
"l-le-mbas"
|
||||
],
|
||||
"replaces": [
|
||||
"l-le-mbas"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||