LLeMbas Desktop: the server in a window, the CLI's agent on this computer
ci / check (push) Failing after 24s
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:
28 files changed
+6801
No files matched your search
@@ -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
|
||||||
@@ -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/*
|
||||||
@@ -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-*
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.0.0
|
||||||
Executable
+44
@@ -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"
|
||||||
Generated
+5241
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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>
|
||||||
@@ -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 |
Reference in new issue
Block a user