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

+38
View File
@@ -0,0 +1,38 @@
# Checks on every push to main and every pull request: formatting, lints, tests, and the page's
# JavaScript. The app is built for real only by release.yml, from a tag.
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: System libraries (WebKitGTK, the tray)
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt, clippy
cache-workspaces: src-tauri
- name: The bundled LLeMbas CLI, verified
run: scripts/fetch-cli.sh
- name: Formatting
working-directory: src-tauri
run: cargo fmt --check
- name: Lints
working-directory: src-tauri
run: cargo clippy --locked --all-targets -- -D warnings
- name: Tests
working-directory: src-tauri
run: cargo test --locked
- name: The first-run page's JavaScript
run: node --check ui/app.js
+64
View File
@@ -0,0 +1,64 @@
# A release, from a signed tag vX.Y.Z on main: the Linux packages (.deb and AppImage) built with
# the LLeMbas CLI inside, SHA256SUMS over them, and the GitHub Release with the tag message as its
# notes. LLeMbas Desktop is developed on Gitea, which mirrors its commits and tags here; the builds
# happen here because they need a full Rust and WebKitGTK toolchain.
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "The tag, e.g. v0.1.0"
required: true
permissions:
contents: write
jobs:
linux:
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
TAG: ${{ inputs.tag || github.ref_name }}
# linuxdeploy is itself an AppImage; runners have no FUSE.
APPIMAGE_EXTRACT_AND_RUN: "1"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- name: The tag object, with its message
run: git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
- name: The tag names this version
run: |
version="$(sed -n -E 's/^version = "([^"]+)"$/\1/p' src-tauri/Cargo.toml | head -n1)"
[ "v$version" = "$TAG" ] || { echo "Cargo.toml says $version, the tag is $TAG" >&2; exit 1; }
echo "VERSION=$version" >> "$GITHUB_ENV"
- name: System libraries (WebKitGTK, the tray, AppImage)
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev file
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: src-tauri
- name: The bundled LLeMbas CLI, verified
run: scripts/fetch-cli.sh
- name: Tauri's CLI
run: cargo install tauri-cli --version "^2" --locked
- name: Build
run: cargo tauri build --bundles deb,appimage
- name: Collect the files and SHA256SUMS
run: |
mkdir dist
cp src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/appimage/*.AppImage dist/
cd dist
{ echo "# llembas-desktop $VERSION"; sha256sum -- *; } > SHA256SUMS
cat SHA256SUMS
- name: Publish the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
git tag -l --format='%(contents)' "$TAG" | sed '/-----BEGIN SSH SIGNATURE-----/,$d' > notes.md
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
--title "LLeMbas Desktop $VERSION" --notes-file notes.md dist/*
+5
View File
@@ -0,0 +1,5 @@
# Rust and Tauri build output
/src-tauri/target/
/src-tauri/gen/
# The bundled LLeMbas CLI: fetched and verified by scripts/fetch-cli.sh, never committed
/src-tauri/binaries/lembas-*
+17
View File
@@ -0,0 +1,17 @@
# Changelog
All notable changes to LLeMbas Desktop. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- The app: the server's web UI in a window of its own, a first-run page to choose the server, and a
tray menu (open, start and stop the agent, link this computer, change server, quit).
- The agent: the bundled LLeMbas CLI runs as `lembas service` while the app runs, restarted when
it fails and stopped cleanly on quit. A CLI service already running on the computer is used
instead of starting a second one.
- Linking: `lembas login --json` with the approval page shown in the window.
- Linux packages (`.deb`, AppImage) built by GitHub Actions from a tag.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jaroslav Beneš
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+56
View File
@@ -0,0 +1,56 @@
# LLeMbas Desktop
Your [LLeMbas](https://llembas.eu) server in a window of its own, with the agent on this computer.
- **The whole web UI**: chats, agent chats, the library, reports, settings. Same account, models,
permissions and credits as in the browser; the app is a front end to the server you already run.
- **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
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,
links the computer, and changes the server.
> **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later.
## Install
Download the `.deb` or the AppImage from the
[latest release](https://github.com/LLeMbas/LLeMbas-Desktop/releases/latest). Check it against
`SHA256SUMS` in the same release.
```sh
sudo apt install ./LLeMbas_*_amd64.deb # Debian, Ubuntu
chmod +x LLeMbas_*.AppImage && ./LLeMbas_*.AppImage
```
On GNOME the tray needs the *AppIndicator* extension; without it, closing the window hides the
app, and starting it again brings the window back.
## Use
1. Start LLeMbas and enter your server's address. Sign in there as you would in a browser.
2. From the tray, **Link this computer**. The approval page opens in the window; approve it.
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
(`~/.config/lembas`). 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.
## Build
Needs Rust (stable) and, on Linux, WebKitGTK 4.1 and an AppIndicator library:
```sh
sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev
scripts/fetch-cli.sh # the bundled CLI, checked against its release signature
cargo install tauri-cli --version "^2" --locked
cargo tauri dev # or: cargo tauri build
```
There is no Node and no bundler: the app's one local page is plain HTML, CSS and JavaScript in
`ui/`. Everything else on screen is the server's own web UI. The version of the CLI it carries is
in `lembas-cli.version`.
## Licence
MIT; see [LICENSE](LICENSE). The bundled LLeMbas CLI is MIT too, with its own third-party notices.
+1
View File
@@ -0,0 +1 @@
1.0.0
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Put the LLeMbas CLI that LLeMbas Desktop carries where Tauri's bundler wants it:
# src-tauri/binaries/lembas-<target triple>
#
# scripts/fetch-cli.sh [target-triple] (default: this machine's, from rustc)
#
# The version is the one in lembas-cli.version. The files are checked exactly as the CLI's own
# get.sh checks them: SHA256SUMS signed by the release key in the namespace lembas-release, naming
# this version, and the binary matching it. Nothing unverified is bundled.
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION="$(tr -d '[:space:]' < lembas-cli.version)"
TRIPLE="${1:-$(rustc -vV | sed -n 's/^host: //p')}"
BASE="${LEMBAS_CLI_RELEASES:-https://github.com/LLeMbas/LLeMbas-CLI/releases/download}/v${VERSION}"
PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDSZO3Byow7R3ZpOH3MCvpPIga2pZBzhfX5wXfNP1cLa"
case "$TRIPLE" in
# The baseline build runs on any x86-64 CPU, not only those with AVX2: a desktop app meets
# older machines than a server does.
x86_64-unknown-linux-gnu) ASSET=lembas-linux-x64-baseline ;;
aarch64-unknown-linux-gnu) ASSET=lembas-linux-arm64 ;;
*) echo "fetch-cli: no LLeMbas CLI build for $TRIPLE yet" >&2; exit 1 ;;
esac
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
for f in SHA256SUMS SHA256SUMS.sig "$ASSET.gz"; do
curl -fsSL --retry 3 -o "$work/$f" "$BASE/$f"
done
printf 'lembas-release %s\n' "$PUBKEY" > "$work/allowed"
ssh-keygen -Y verify -f "$work/allowed" -I lembas-release -n lembas-release \
-s "$work/SHA256SUMS.sig" < "$work/SHA256SUMS" >/dev/null \
|| { echo "fetch-cli: SHA256SUMS is not signed by the release key" >&2; exit 1; }
[ "$(sed -n -E 's/^# lembas //p' "$work/SHA256SUMS" | head -n1)" = "$VERSION" ] \
|| { echo "fetch-cli: SHA256SUMS is not for $VERSION" >&2; exit 1; }
(cd "$work" && grep -E " $ASSET\.gz\$" SHA256SUMS | sha256sum -c --strict --quiet -) \
|| { echo "fetch-cli: $ASSET.gz does not match SHA256SUMS" >&2; exit 1; }
mkdir -p src-tauri/binaries
gunzip -c "$work/$ASSET.gz" > "src-tauri/binaries/lembas-$TRIPLE"
chmod 755 "src-tauri/binaries/lembas-$TRIPLE"
echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas-$TRIPLE"
+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"]
}
}
+130
View File
@@ -0,0 +1,130 @@
/* The first-run page: the LLeMbas look (Moria dark, Shire light), no build step. */
: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; }
html, body { margin: 0; min-height: 100%; }
body {
display: grid;
place-items: center;
min-height: 100vh;
padding: var(--sp-4);
background: var(--bg);
color: var(--ink);
font: var(--text-base) / 1.6 var(--font-body);
}
.card {
width: min(100%, var(--card-max));
display: grid;
gap: var(--sp-6);
padding: var(--sp-8);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
}
.brand { display: grid; grid-template-columns: 40px 1fr; gap: var(--sp-3); align-items: center; }
h1 { margin: 0; font: 600 var(--text-xl) / 1.2 var(--font-ui); }
h1 b { color: var(--leaf); font-weight: 700; }
h2 { margin: 0 0 var(--sp-3); font: 600 var(--text-sm) / 1.2 var(--font-ui); letter-spacing: 0.08em; color: var(--accent); }
p { margin: 0; }
.muted { color: var(--ink-muted); font-size: var(--text-sm); }
/* A field is three stacked parts on fixed rows: label, control row, hint. */
.field { display: grid; grid-template-rows: auto var(--control-h) auto; gap: var(--sp-2); }
label { font: var(--text-sm) / 1.4 var(--font-ui); color: var(--ink-muted); }
.hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: 1.4em; }
.hint.is-error, .status.is-error { color: var(--warning); }
.row { display: grid; grid-template-columns: 1fr auto; gap: var(--sp-2); }
.row--buttons { grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); margin-top: var(--sp-3); }
input {
height: var(--control-h);
min-width: 0;
padding: 0 var(--sp-3);
font: var(--text-base) var(--font-ui);
color: var(--ink);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
}
input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn {
height: var(--control-h);
padding: 0 var(--sp-4);
font: 600 var(--text-sm) var(--font-ui);
color: var(--ink);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--press) var(--press) 0 rgba(0, 0, 0, 0.45);
cursor: pointer;
}
.btn:hover { background: var(--surface-hover); }
.btn:active { transform: translate(var(--press), var(--press)); box-shadow: none; }
.btn:disabled { opacity: 0.55; cursor: default; }
.btn--primary { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
.btn--primary:hover { background: var(--accent-hover); }
.status { font: var(--text-sm) / 1.4 var(--font-ui); margin-top: var(--sp-3); }
.status.is-ok { color: var(--leaf); }
@media (max-width: 30rem) {
.card { padding: var(--sp-4); gap: var(--sp-4); }
.row { grid-template-columns: 1fr; grid-template-rows: var(--control-h) var(--control-h); }
.field { grid-template-rows: auto auto auto; }
}
+74
View File
@@ -0,0 +1,74 @@
// The first-run page. It talks to the app over Tauri's IPC — four commands, see src-tauri/src/lib.rs.
// The server's own pages never get this: they are remote content with no capability.
"use strict"
const { invoke } = window.__TAURI__.core
const { listen } = window.__TAURI__.event
const $ = (id) => document.getElementById(id)
const AGENT = {
off: () => ["The agent is off.", ""],
not_linked: () => ["This computer is not linked to the server yet.", ""],
starting: () => ["The agent is starting…", ""],
running: () => ["The agent is running: agent chats can use this computer.", "is-ok"],
elsewhere: (pid) => [`A LLeMbas CLI service already runs here (pid ${pid}); it is this computer's agent.`, "is-ok"],
failed: (why) => [`The agent stopped: ${why}. Trying again shortly.`, "is-error"],
}
function render(s) {
$("version").textContent = s.version
if (s.server && !$("address").value) $("address").value = s.server
$("agent-section").hidden = !s.server
const [text, cls] = (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
const status = $("agent-status")
status.textContent = text
status.className = `status ${cls}`
$("link").textContent = s.linked ? "Link again" : "Link this computer"
$("toggle").textContent = s.agent_wanted ? "Stop the agent" : "Start the agent"
$("toggle").disabled = !s.linked && !s.agent_wanted
}
async function refresh() {
render(await invoke("get_status"))
}
$("server-form").addEventListener("submit", async (e) => {
e.preventDefault()
const hint = $("server-hint")
const button = e.target.querySelector("button")
button.disabled = true
hint.className = "hint"
hint.textContent = "Looking for a LLeMbas server there…"
try {
await invoke("set_server", { address: $("address").value })
// The window now shows the server.
} catch (err) {
hint.className = "hint is-error"
hint.textContent = String(err)
} finally {
button.disabled = false
}
})
$("link").addEventListener("click", async () => {
try {
await invoke("link_computer")
} catch (err) {
$("agent-status").className = "status is-error"
$("agent-status").textContent = String(err)
}
})
$("toggle").addEventListener("click", async () => {
const s = await invoke("get_status")
await invoke("set_agent", { on: !s.agent_wanted })
})
$("open").addEventListener("click", async () => {
const s = await invoke("get_status")
if (s.server) await invoke("set_server", { address: s.server })
})
listen("status", (e) => render(e.payload))
refresh()
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>LLeMbas</title>
<link rel="stylesheet" href="app.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="card" aria-labelledby="title">
<header class="brand">
<img src="logo-mark.svg" alt="" width="40" height="40">
<div>
<h1 id="title"><b>LL</b>e<b>M</b>bas</h1>
<p class="muted">Desktop <span id="version"></span></p>
</div>
</header>
<section aria-labelledby="server-h">
<h2 id="server-h">[ SERVER ]</h2>
<form id="server-form" class="field" novalidate>
<label for="address">Address of your LLeMbas server</label>
<div class="row">
<input id="address" name="address" type="text" inputmode="url" autocomplete="url"
placeholder="https://ai.example" spellcheck="false" required>
<button type="submit" class="btn btn--primary">Connect</button>
</div>
<p class="hint" id="server-hint" aria-live="polite">Sign in there as you would in a browser.</p>
</form>
</section>
<section aria-labelledby="agent-h" id="agent-section" hidden>
<h2 id="agent-h">[ AGENT ]</h2>
<p class="muted">The agent lets agent chats on your server work in this computer's projects.
It is the LLeMbas CLI, built in, running in the background while the app runs.</p>
<p class="status" id="agent-status" aria-live="polite"></p>
<div class="row row--buttons">
<button type="button" class="btn" id="link">Link this computer</button>
<button type="button" class="btn" id="toggle">Start the agent</button>
<button type="button" class="btn btn--primary" id="open">Open the server</button>
</div>
</section>
</main>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="64" height="64"
shape-rendering="crispEdges" role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<desc>A pixel mallorn leaf with a dark one-pixel edge.</desc>
<g transform="translate(0 0) scale(1)" shape-rendering="crispEdges"><rect x="10" y="0" width="5" height="1" fill="#0B0D10"/><rect x="8" y="1" width="2" height="1" fill="#0B0D10"/><rect x="10" y="1" width="1" height="1" fill="#B6F2A6"/><rect x="11" y="1" width="4" height="1" fill="#7EE081"/><rect x="15" y="1" width="1" height="1" fill="#0B0D10"/><rect x="6" y="2" width="2" height="1" fill="#0B0D10"/><rect x="8" y="2" width="2" height="1" fill="#B6F2A6"/><rect x="10" y="2" width="2" height="1" fill="#7EE081"/><rect x="12" y="2" width="2" height="1" fill="#D8FFD0"/><rect x="14" y="2" width="1" height="1" fill="#4FAE5A"/><rect x="15" y="2" width="1" height="1" fill="#0B0D10"/><rect x="5" y="3" width="1" height="1" fill="#0B0D10"/><rect x="6" y="3" width="3" height="1" fill="#B6F2A6"/><rect x="9" y="3" width="2" height="1" fill="#7EE081"/><rect x="11" y="3" width="2" height="1" fill="#D8FFD0"/><rect x="13" y="3" width="2" height="1" fill="#4FAE5A"/><rect x="15" y="3" width="1" height="1" fill="#0B0D10"/><rect x="5" y="4" width="1" height="1" fill="#0B0D10"/><rect x="6" y="4" width="2" height="1" fill="#B6F2A6"/><rect x="8" y="4" width="2" height="1" fill="#7EE081"/><rect x="10" y="4" width="2" height="1" fill="#D8FFD0"/><rect x="12" y="4" width="2" height="1" fill="#4FAE5A"/><rect x="14" y="4" width="1" height="1" fill="#0B0D10"/><rect x="4" y="5" width="1" height="1" fill="#0B0D10"/><rect x="5" y="5" width="2" height="1" fill="#B6F2A6"/><rect x="7" y="5" width="2" height="1" fill="#7EE081"/><rect x="9" y="5" width="2" height="1" fill="#D8FFD0"/><rect x="11" y="5" width="3" height="1" fill="#4FAE5A"/><rect x="14" y="5" width="1" height="1" fill="#0B0D10"/><rect x="4" y="6" width="1" height="1" fill="#0B0D10"/><rect x="5" y="6" width="1" height="1" fill="#B6F2A6"/><rect x="6" y="6" width="2" height="1" fill="#7EE081"/><rect x="8" y="6" width="2" height="1" fill="#D8FFD0"/><rect x="10" y="6" width="4" height="1" fill="#4FAE5A"/><rect x="14" y="6" width="1" height="1" fill="#0B0D10"/><rect x="3" y="7" width="1" height="1" fill="#0B0D10"/><rect x="4" y="7" width="1" height="1" fill="#B6F2A6"/><rect x="5" y="7" width="2" height="1" fill="#7EE081"/><rect x="7" y="7" width="2" height="1" fill="#D8FFD0"/><rect x="9" y="7" width="4" height="1" fill="#4FAE5A"/><rect x="13" y="7" width="1" height="1" fill="#0B0D10"/><rect x="3" y="8" width="1" height="1" fill="#0B0D10"/><rect x="4" y="8" width="2" height="1" fill="#7EE081"/><rect x="6" y="8" width="2" height="1" fill="#D8FFD0"/><rect x="8" y="8" width="4" height="1" fill="#4FAE5A"/><rect x="12" y="8" width="1" height="1" fill="#0B0D10"/><rect x="3" y="9" width="1" height="1" fill="#0B0D10"/><rect x="4" y="9" width="1" height="1" fill="#7EE081"/><rect x="5" y="9" width="2" height="1" fill="#D8FFD0"/><rect x="7" y="9" width="4" height="1" fill="#4FAE5A"/><rect x="11" y="9" width="1" height="1" fill="#0B0D10"/><rect x="3" y="10" width="1" height="1" fill="#0B0D10"/><rect x="4" y="10" width="2" height="1" fill="#D8FFD0"/><rect x="6" y="10" width="4" height="1" fill="#4FAE5A"/><rect x="10" y="10" width="1" height="1" fill="#0B0D10"/><rect x="3" y="11" width="1" height="1" fill="#0B0D10"/><rect x="4" y="11" width="1" height="1" fill="#7EE081"/><rect x="5" y="11" width="2" height="1" fill="#4FAE5A"/><rect x="7" y="11" width="3" height="1" fill="#0B0D10"/><rect x="2" y="12" width="1" height="1" fill="#0B0D10"/><rect x="3" y="12" width="1" height="1" fill="#4FAE5A"/><rect x="4" y="12" width="3" height="1" fill="#0B0D10"/><rect x="1" y="13" width="1" height="1" fill="#0B0D10"/><rect x="2" y="13" width="1" height="1" fill="#4FAE5A"/><rect x="3" y="13" width="1" height="1" fill="#0B0D10"/><rect x="0" y="14" width="1" height="1" fill="#0B0D10"/><rect x="1" y="14" width="1" height="1" fill="#4FAE5A"/><rect x="2" y="14" width="1" height="1" fill="#0B0D10"/><rect x="1" y="15" width="1" height="1" fill="#0B0D10"/></g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB