4 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 5028dd7ca1 LLeMbas Desktop 0.4.0, carrying LLeMbas CLI 1.2.0
ci / check (push) Failing after 2s
release / linux (push) Failing after 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:26:53 +00:00
HomerandClaude Opus 5.5 1e2fa39193 Project folders for the agent, Local, the menu under its button
ci / check (push) Failing after 3s
- Settings… lists the folders the agent may work in, with the system's
  folder dialog, remove, max mode and terminal, all through
  'lembas remote … --json' (CLI 1.2.0). Turning the agent on with no
  folder opens it; the title bar says 'agent: no folders' instead of green.
- The content webview's User-Agent carries LLeMbasDesktop/<v> (host=…),
  so the server can show this computer's device as Local.
- The title bar menu pops up at the button (popup_menu_at): Wayland gave
  the pointer-less popup the middle of the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:52:25 +00:00
HomerandClaude Opus 5.5 bb91ac62b0 LLeMbas Desktop 0.3.0
ci / check (push) Failing after 3s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:22:12 +00:00
HomerandClaude Opus 5.5 5eff067e75 Fill the window on Linux; updates
ci / check (push) Failing after 3s
- Linux: Tauri packs a window's webviews into one GTK box with equal
  shares and ignores their bounds, so the title bar took half the window.
  The title bar webview now has a fixed height and no share; the box's
  border is the resize frame (none when maximized).
- tauri-plugin-updater: checks llembas.eu/desktop/latest.json at start
  and every 6 h, an update button in the title bar and menu entries,
  .deb via pkexec, AppImage in place, then restart. Packages signed with
  the updater's minisign key; requireSignedVersion on.
  scripts/publish-update.sh signs a GitHub release and writes the manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:19:36 +00:00
17 changed files with 999 additions and 30 deletions

No files matched your search

+34
View File
@@ -6,6 +6,40 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased]
## [0.4.0] — 2026-10-14
Carries LLeMbas CLI 1.2.0, whose agent stays linked before any folder is allowed and follows
changes to its folders without a restart.
### Added
- **Project folders**: Settings… (in the title bar's and the tray's menu) lists the folders the
agent may work in, with *Add folder…* (the system's folder dialog), *Remove*, the most an agent
chat may do there, and whether the server may open a terminal. Turning the agent on with no
folder chosen opens this page. Changes go through the bundled CLI (`lembas remote`).
- The server is told it is shown in LLeMbas Desktop and on which computer, so it lists this
computer's device as **Local**, first.
### Fixed
- The title bar's menu opens under the button, not in the middle of the window.
- The agent no longer shows a green light while it may open no folder: the title bar says
*agent: no folders*.
## [0.3.0] — 2026-10-14
### Added
- Updates: the app looks for a new release at start and every six hours, shows an **update**
button in the title bar when there is one, and installs it (the .deb with the system's password
prompt, an AppImage in place) after checking its signature. The menu can also check by hand.
### Fixed
- On Linux the server's page took only the lower half of the window, under an empty title bar
area. The title bar is now its fixed height and the page takes the rest; the frame you resize
the window by is drawn around both.
## [0.2.0] — 2026-10-14
### Added
+11 -1
View File
@@ -27,11 +27,21 @@ chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage
On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has
everything, and starting the app again brings a hidden window back.
## Updates
LLeMbas Desktop looks for a new release when it starts and every six hours. When there is one, an
**update** button appears in the title bar (and *Install LLeMbas x.y.z…* in its menu; *Check for
updates…* otherwise). Installing asks for your password to update the `.deb` (an AppImage updates
itself), then the app starts again. Every package is checked against the release signing key
built into the app before anything is installed.
## 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.
3. The agent starts. Choose the folders it may work in (Settings…, *Add folder…*); a new
computer allows none until you do.
4. In the server's Agents tab, start an agent chat on **Local** and pick one of those folders.
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
(`~/.config/lembas`). The app's copy of the CLI lives in its own directory
+1 -1
View File
@@ -1 +1 @@
1.1.0
1.2.0
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Offer a release to installed copies of LLeMbas Desktop: sign its packages with the updater key
# and write the update manifest the app reads (https://llembas.eu/desktop/latest.json).
#
# MINISIGN_KEY=path/to/updater.key scripts/publish-update.sh v0.3.0 path/to/LLeMbas-Website
#
# The packages are the ones release.yml published on GitHub for the tag, checked against that
# release's SHA256SUMS first. The app verifies each package's signature with the public key in
# src-tauri/tauri.conf.json before installing it, so the manifest's host need not be trusted.
# This writes site/desktop/latest.json in the website's checkout; committing and pushing it is
# what publishes the update.
set -euo pipefail
TAG="${1:?usage: publish-update.sh vX.Y.Z path/to/LLeMbas-Website}"
SITE="${2:?usage: publish-update.sh vX.Y.Z path/to/LLeMbas-Website}"
KEY="${MINISIGN_KEY:?set MINISIGN_KEY to the minisign secret key of the updater}"
VERSION="${TAG#v}"
BASE="https://github.com/LLeMbas/LLeMbas-Desktop/releases/download/$TAG"
DEB="llembas-desktop_${VERSION}_amd64.deb"
APPIMAGE="llembas-desktop_${VERSION}_amd64.AppImage"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
for f in SHA256SUMS "$DEB" "$APPIMAGE"; do
curl -fsSL --retry 3 -o "$work/$f" "$BASE/$f"
done
[ "$(sed -n -E 's/^# llembas-desktop //p' "$work/SHA256SUMS" | head -n1)" = "$VERSION" ] \
|| { echo "publish-update: SHA256SUMS is not for $VERSION" >&2; exit 1; }
(cd "$work" && grep -E " ($DEB|$APPIMAGE)\$" SHA256SUMS | sha256sum -c --strict --quiet -) \
|| { echo "publish-update: the packages do not match SHA256SUMS" >&2; exit 1; }
sig() {
# Tab-separated, with the version: the app refuses a package whose signature names another one.
minisign -S -s "$KEY" -m "$work/$1" -x "$work/$1.minisig" \
-t "$(printf 'timestamp:%s\tfile:%s\tversion:%s' "$(date +%s)" "$1" "$VERSION")" >/dev/null
base64 -w0 "$work/$1.minisig"
}
notes="$(git tag -l --format='%(contents)' "$TAG" | sed '/-----BEGIN SSH SIGNATURE-----/,$d' | tail -n +3)"
mkdir -p "$SITE/site/desktop"
jq -n --arg v "$VERSION" --arg notes "$notes" --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg deb_url "$BASE/$DEB" --arg deb_sig "$(sig "$DEB")" \
--arg app_url "$BASE/$APPIMAGE" --arg app_sig "$(sig "$APPIMAGE")" \
'{version: $v, notes: $notes, pub_date: $date, platforms: {
"linux-x86_64-deb": {url: $deb_url, signature: $deb_sig},
"linux-x86_64-appimage": {url: $app_url, signature: $app_sig}}}' \
> "$SITE/site/desktop/latest.json"
echo "publish-update: wrote $SITE/site/desktop/latest.json for $VERSION — commit and push it to publish"
+317 -9
View File
@@ -47,6 +47,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -708,6 +717,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -976,6 +996,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1520,6 +1550,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
@@ -1539,9 +1584,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1844,6 +1891,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -1987,8 +2064,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "llembas-desktop"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"gtk",
"libc",
"serde",
"serde_json",
@@ -1997,6 +2075,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"ureq",
]
@@ -2047,6 +2126,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2312,6 +2397,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2392,6 +2489,12 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2408,6 +2511,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2813,15 +2930,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -2920,11 +3042,23 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
dependencies = [
"openssl-probe",
"openssl-probe 0.1.6",
"rustls-pemfile",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 2.11.1",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe 0.2.1",
"rustls-pki-types",
"schannel",
"security-framework 3.7.0",
]
[[package]]
@@ -2945,6 +3079,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs 0.8.5",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
@@ -3050,6 +3211,19 @@ dependencies = [
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
@@ -3281,6 +3455,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.4"
@@ -3462,6 +3652,27 @@ dependencies = [
"syn 3.0.8",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3493,7 +3704,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3526,6 +3737,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -3549,7 +3771,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3741,6 +3963,40 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"libc",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.21",
"time",
"tokio",
"url",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.12.1"
@@ -3751,7 +4007,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3774,7 +4030,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -3963,6 +4219,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
@@ -4227,7 +4493,7 @@ dependencies = [
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-native-certs 0.7.3",
"rustls-pki-types",
"serde",
"serde_json",
@@ -4489,6 +4755,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -4671,6 +4946,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -4987,7 +5273,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -5034,6 +5320,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5187,6 +5483,18 @@ dependencies = [
"syn 3.0.8",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.2",
"memchr",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "llembas-desktop"
version = "0.2.0"
version = "0.4.0"
description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer"
authors = ["Jaroslav Beneš"]
license = "MIT"
@@ -21,6 +21,7 @@ tauri = { version = "2", features = ["tray-icon", "unstable"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
@@ -28,6 +29,10 @@ ureq = { version = "2", default-features = false, features = ["tls", "native-cer
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
# The window's GTK box: Tauri packs every webview into it with an equal share on Linux.
gtk = "0.18"
[profile.release]
codegen-units = 1
lto = true
+19
View File
@@ -108,6 +108,25 @@ fn running_in(state: &Path) -> Option<u32> {
(pid != 0 && alive(pid)).then_some(pid)
}
/// This computer's host name: what the CLI sent the server as `device_host` at login
/// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one.
#[cfg(unix)]
pub fn hostname() -> Option<String> {
let mut buf = [0u8; 256];
let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
if r != 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let name = String::from_utf8_lossy(&buf[..end]).trim().to_string();
(!name.is_empty()).then_some(name)
}
#[cfg(not(unix))]
pub fn hostname() -> Option<String> {
std::env::var("COMPUTERNAME").ok().filter(|s| !s.is_empty())
}
#[cfg(unix)]
pub fn alive(pid: u32) -> bool {
// Signal 0 checks without sending; EPERM means it exists but is someone else's.
+318 -9
View File
@@ -13,6 +13,7 @@
mod agent;
mod cli;
mod remote;
mod settings;
use std::io::{BufRead, BufReader};
@@ -26,13 +27,15 @@ use tauri::tray::TrayIconBuilder;
use tauri::webview::WebviewBuilder;
use tauri::window::{Color, WindowBuilder};
use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize,
RunEvent, Url, Webview, WebviewUrl, Window, WindowEvent, Wry,
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, Webview, WebviewUrl,
Window, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_updater::UpdaterExt;
use agent::{Agent, State as AgentState};
use remote::Remote;
use settings::Settings;
const MAIN: &str = "main";
@@ -50,6 +53,10 @@ struct App {
agent: Agent,
tray: Mutex<Option<TrayItems>>,
linking: Mutex<bool>,
/// The newer release the last check found, if any.
update: Mutex<Option<String>>,
/// The agent's folders, as `lembas remote` last answered.
remote: Mutex<Option<Remote>>,
}
struct TrayItems {
@@ -79,6 +86,10 @@ struct Status {
linked: bool,
agent: AgentState,
agent_wanted: bool,
/// A newer release of the app, when the last check found one.
update: Option<String>,
/// Which folders the agent may open sessions in (None: not known yet).
remote: Option<Remote>,
}
fn status(app: &App) -> Status {
@@ -89,6 +100,8 @@ fn status(app: &App) -> Status {
server: s.server,
agent: app.agent.state(),
agent_wanted: app.agent.wanted(),
update: app.update.lock().unwrap().clone(),
remote: app.remote.lock().unwrap().clone(),
}
}
@@ -153,12 +166,134 @@ 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),
(true, Some(server)) => {
app.agent.start(server);
let h = handle.clone();
std::thread::spawn(move || {
// Connected but allowed into no folder is the state a new computer starts in:
// say so where the folders are chosen, rather than showing a green light.
if let Some(r) = refresh_remote(&h) {
if !r.usable() {
let _ = h.run_on_main_thread({
let h = h.clone();
move || {
let _ = open_main(&h, None);
}
});
}
}
});
}
_ => app.agent.stop(),
}
Ok(())
}
/// Ask the CLI which folders the agent may use; remember and announce it.
fn refresh_remote(handle: &AppHandle) -> Option<Remote> {
let r = remote::run(&["list"]).ok();
*handle.state::<App>().remote.lock().unwrap() = r.clone();
let _ = handle.emit("status", status_of(handle));
r
}
/// After a change: remember the new state, and start the app's own agent again when the CLI
/// says the running service cannot take the change live.
fn remote_changed(handle: &AppHandle, r: Remote) {
let app = handle.state::<App>();
*app.remote.lock().unwrap() = Some(r.clone());
if r.restart_needed && app.agent.wanted() {
if let Some(server) = app.settings().server {
app.agent.start(server);
}
}
let _ = handle.emit("status", status_of(handle));
}
/// Run a `lembas remote` change off the main thread; the page hears the outcome as `status`,
/// or as `remote-error`.
fn change_remote(handle: &AppHandle, args: Vec<String>) {
let h = handle.clone();
std::thread::spawn(move || {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
match remote::run(&refs) {
Ok(r) => remote_changed(&h, r),
Err(e) => {
let _ = h.emit("remote-error", e);
}
}
});
}
#[tauri::command]
fn remote_refresh(handle: AppHandle) {
std::thread::spawn(move || {
refresh_remote(&handle);
});
}
/// Choose a folder with the system's dialog and let the agent work in it (this turns remote
/// control on, if it was off).
#[tauri::command]
fn remote_add(handle: AppHandle) {
let h = handle.clone();
handle
.dialog()
.file()
.set_title("A folder the agent may work in")
.pick_folder(move |picked| {
if let Some(path) = picked.and_then(|p| p.into_path().ok()) {
change_remote(&h, vec!["add".into(), path.to_string_lossy().into_owned()]);
}
});
}
#[tauri::command]
fn remote_remove(handle: AppHandle, dir: String) {
change_remote(&handle, vec!["remove".into(), dir]);
}
#[tauri::command]
fn remote_mode(handle: AppHandle, mode: String) -> Result<(), String> {
if !["manual", "edit", "auto", "plan"].contains(&mode.as_str()) {
return Err(format!("no such mode: {mode}"));
}
change_remote(&handle, vec!["mode".into(), mode]);
Ok(())
}
#[tauri::command]
fn remote_terminal(handle: AppHandle, on: bool) {
change_remote(
&handle,
vec!["terminal".into(), if on { "on" } else { "off" }.into()],
);
}
/// The server's pages are told they are in LLeMbas Desktop, and on which computer: it shows
/// the device that is this computer as "Local". A label only — it grants nothing.
fn user_agent() -> String {
let platform = if cfg!(target_os = "linux") {
"X11; Linux x86_64"
} else if cfg!(target_os = "macos") {
"Macintosh; Intel Mac OS X 10_15_7"
} else {
"Windows NT 10.0; Win64; x64"
};
let host = cli::hostname()
.filter(|h| {
h.len() <= 253
&& h.chars()
.all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c))
})
.map(|h| format!(" (host={h})"))
.unwrap_or_default();
format!(
"Mozilla/5.0 ({platform}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 LLeMbasDesktop/{}{host}",
env!("CARGO_PKG_VERSION")
)
}
/// The app's own first-run page, as the webview addresses it on this platform.
fn local_page() -> Url {
let base = if cfg!(windows) {
@@ -215,21 +350,60 @@ fn build_main(handle: &AppHandle, target: Option<Url>) -> tauri::Result<()> {
Some(u) => WebviewUrl::External(u),
None => WebviewUrl::App("index.html".into()),
};
#[cfg(target_os = "linux")]
if let Some(bar) = handle.get_webview(TITLEBAR) {
pin_titlebar(&bar);
}
let nav = handle.clone();
window.add_child(
WebviewBuilder::new(CONTENT, url).on_navigation(move |url| allowed(&nav, url)),
WebviewBuilder::new(CONTENT, url)
.user_agent(&user_agent())
.on_navigation(move |url| allowed(&nav, url)),
LogicalPosition::new(FRAME, FRAME + TITLE_H),
LogicalSize::new(
size.width - 2.0 * FRAME,
(size.height - TITLE_H - 2.0 * FRAME).max(0.0),
),
)?;
layout(&window);
Ok(())
}
/// On Linux, Tauri packs every webview of a window into the window's GTK box, each with an equal
/// share of the height, and positions and sizes set on a webview are not applied there. So the
/// title bar is told directly: a fixed height, no share of what is left (the content takes it).
#[cfg(target_os = "linux")]
fn pin_titlebar(bar: &Webview) {
let _ = bar.with_webview(|pv| {
use gtk::prelude::*;
let view = pv.inner();
view.set_size_request(-1, TITLE_H as i32);
if let Some(parent) = view.parent().and_then(|p| p.downcast::<gtk::Box>().ok()) {
parent.set_child_packing(&view, false, false, 0, gtk::PackType::Start);
}
});
}
/// Lay the two webviews out in the window: the title bar on top, the content under it, and the
/// frame around both unless the window is maximized (then there is nothing to resize).
fn layout(window: &Window) {
let framed =
!(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false));
#[cfg(target_os = "linux")]
{
use gtk::prelude::*;
// The box's border is the frame: the window's own surface, which GTK resizes by.
if let Ok(vbox) = window.default_vbox() {
vbox.set_border_width(if framed { FRAME as u32 } else { 0 });
}
}
#[cfg(not(target_os = "linux"))]
place(window, framed);
}
#[cfg(not(target_os = "linux"))]
fn place(window: &Window, framed: bool) {
use tauri::{PhysicalPosition, PhysicalSize};
let handle = window.app_handle();
let (Some(bar), Some(body)) = (handle.get_webview(TITLEBAR), handle.get_webview(CONTENT))
else {
@@ -396,7 +570,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
)?;
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 server = MenuItem::with_id(handle, "server", "Settings…", true, None::<&str>)?;
let quit = MenuItem::with_id(handle, "quit", "Quit LLeMbas", true, None::<&str>)?;
let menu = Menu::with_items(
handle,
@@ -421,6 +595,110 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
Ok(TrayItems { status, toggle })
}
/// How often a running app looks for a new release (and once shortly after it starts).
const UPDATE_EVERY: std::time::Duration = std::time::Duration::from_secs(6 * 3600);
/// Ask the update manifest whether a newer release exists; remember and announce the answer.
/// The manifest and each package are signed with the updater key, which the app checks before
/// installing anything.
async fn check_update(handle: &AppHandle) -> Result<Option<String>, String> {
let found = handle
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.map(|u| u.version);
*handle.state::<App>().update.lock().unwrap() = found.clone();
let _ = handle.emit("status", status_of(handle));
Ok(found)
}
fn watch_updates(handle: AppHandle) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(20));
loop {
let _ = tauri::async_runtime::block_on(check_update(&handle));
std::thread::sleep(UPDATE_EVERY);
}
});
}
/// Download and install the newer release, then start again on it. A .deb is installed with
/// the system's password prompt (pkexec); an AppImage replaces itself.
async fn install_update(handle: AppHandle) -> Result<(), String> {
let update = handle
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.ok_or("LLeMbas is up to date.")?;
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|e| format!("The update could not be installed: {e}"))?;
handle.state::<App>().agent.stop();
handle.restart()
}
fn offer_update(h: &AppHandle) {
let h = h.clone();
tauri::async_runtime::spawn(async move {
let version = match check_update(&h).await {
Ok(Some(v)) => v,
Ok(None) => {
h.dialog()
.message(format!(
"LLeMbas {} is the newest release.",
env!("CARGO_PKG_VERSION")
))
.title("LLeMbas is up to date")
.show(|_| {});
return;
}
Err(e) => {
h.dialog()
.message(format!("Could not look for updates: {e}"))
.title("LLeMbas")
.kind(MessageDialogKind::Warning)
.show(|_| {});
return;
}
};
let again = h.clone();
h.dialog()
.message(format!(
"LLeMbas {version} is available (this is {}). Install it now? LLeMbas starts again afterwards; the agent stops for a moment.",
env!("CARGO_PKG_VERSION")
))
.title("Update LLeMbas")
.buttons(tauri_plugin_dialog::MessageDialogButtons::OkCancelCustom(
"Install".into(),
"Later".into(),
))
.show(move |yes| {
if yes {
tauri::async_runtime::spawn(async move {
if let Err(e) = install_update(again.clone()).await {
again
.dialog()
.message(e)
.title("LLeMbas")
.kind(MessageDialogKind::Error)
.show(|_| {});
}
});
}
});
});
}
#[tauri::command]
fn update_app(handle: AppHandle) {
offer_update(&handle);
}
/// A menu item chosen, from the tray or from the title bar's menu: the same ids in both.
fn on_menu(h: &AppHandle, id: &str) {
match id {
@@ -442,6 +720,7 @@ fn on_menu(h: &AppHandle, id: &str) {
.show(|_| {});
}
}
"update" => offer_update(h),
"reload" => {
if let Some(c) = content(h) {
if let Ok(url) = c.url() {
@@ -462,8 +741,11 @@ fn on_menu(h: &AppHandle, id: &str) {
/// The title bar's menu: the tray's items, where the window is (a desktop without a tray —
/// GNOME without the AppIndicator extension — has no other way to reach them).
/// The title bar's menu, opened under the button that asked for it: `x`, `y` are the button's
/// bottom-left corner in the title bar page. (A plain popup opens at the pointer, which a Wayland
/// compositor does not tell the app — it landed in the middle of the window.)
#[tauri::command]
fn titlebar_menu(window: Window) -> Result<(), String> {
fn titlebar_menu(window: Window, x: f64, y: f64) -> Result<(), String> {
let h = window.app_handle();
let app = h.state::<App>();
let m = |id: &str, text: &str, on: bool| MenuItem::with_id(h, id, text, on, None::<&str>);
@@ -479,8 +761,12 @@ fn titlebar_menu(window: Window) -> Result<(), String> {
true,
)?;
let link = m("link", "Link this computer…", true)?;
let update = match app.update.lock().unwrap().clone() {
Some(v) => m("update", &format!("Install LLeMbas {v}…"), true)?,
None => m("update", "Check for updates…", true)?,
};
let reload = m("reload", "Reload", true)?;
let server = m("server", "Change server…", true)?;
let server = m("server", "Settings…", true)?;
let quit = m("quit", "Quit LLeMbas", true)?;
Menu::with_items(
h,
@@ -490,6 +776,7 @@ fn titlebar_menu(window: Window) -> Result<(), String> {
&link,
&PredefinedMenuItem::separator(h)?,
&reload,
&update,
&server,
&PredefinedMenuItem::separator(h)?,
&quit,
@@ -497,7 +784,13 @@ fn titlebar_menu(window: Window) -> Result<(), String> {
)
};
let menu = build().map_err(|e| e.to_string())?;
window.popup_menu(&menu).map_err(|e| e.to_string())
// The title bar sits inside the frame while the window is not maximized.
let framed =
!(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false));
let inset = if framed { FRAME } else { 0.0 };
window
.popup_menu_at(&menu, LogicalPosition::new(x + inset, y + inset))
.map_err(|e| e.to_string())
}
fn on_agent_state(handle: &AppHandle, state: &AgentState) {
@@ -518,12 +811,19 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|h, _argv, _cwd| show(h)))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
get_status,
set_server,
set_agent,
link_computer,
titlebar_menu
titlebar_menu,
update_app,
remote_refresh,
remote_add,
remote_remove,
remote_mode,
remote_terminal
])
.on_menu_event(|h, ev| on_menu(h, ev.id().as_ref()))
.setup(|app| {
@@ -547,10 +847,19 @@ pub fn run() {
agent,
tray: Mutex::new(None),
linking: Mutex::new(false),
update: Mutex::new(None),
remote: Mutex::new(None),
});
let items = build_tray(&handle)?;
*handle.state::<App>().tray.lock().unwrap() = Some(items);
open_main(&handle, loaded.server.as_deref())?;
watch_updates(handle.clone());
{
let h = handle.clone();
std::thread::spawn(move || {
refresh_remote(&h);
});
}
if let (true, Some(server)) = (loaded.agent, loaded.server) {
handle.state::<App>().agent.start(server);
}
+77
View File
@@ -0,0 +1,77 @@
//! Which folders the agent may work in: the CLI's `remote` settings, read and changed through
//! `lembas remote … --json` so the app never edits the CLI's files itself. A freshly linked
//! computer allows no folder at all (remote control is off and `roots` is empty), so the agent
//! is connected but every agent chat on it is refused until at least one folder is added.
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use crate::cli;
/// `lembas remote list --json`, and what every changing command answers with.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Remote {
pub enabled: bool,
pub roots: Vec<String>,
pub max_mode: String,
pub terminal: bool,
/// The running service must start again for the change to apply.
pub restart_needed: bool,
}
impl Remote {
/// Agent chats can be opened here at all.
pub fn usable(&self) -> bool {
self.enabled && !self.roots.is_empty()
}
}
/// Run `lembas remote <args…> --json`: the new state, or what the CLI said went wrong.
pub fn run(args: &[&str]) -> Result<Remote, String> {
let out = Command::new(cli::binary())
.arg("remote")
.args(args)
.arg("--json")
.stdin(Stdio::null())
.output()
.map_err(|e| format!("Could not start {}: {e}", cli::binary().display()))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(if err.is_empty() {
format!("lembas remote {} failed ({})", args.join(" "), out.status)
} else {
err.trim_start_matches("lembas: ").to_string()
});
}
parse(&String::from_utf8_lossy(&out.stdout))
}
/// The last JSON object the CLI printed (it may say a word before it).
fn parse(stdout: &str) -> Result<Remote, String> {
stdout
.lines()
.rev()
.find_map(|l| serde_json::from_str::<Remote>(l.trim()).ok())
.ok_or_else(|| "lembas remote gave no answer this version of the app understands — is the bundled LLeMbas CLI too old?".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_the_state_and_defaults_what_is_missing() {
let r = parse(
"added /home/a/project\n{\"enabled\":true,\"roots\":[\"/home/a/project\"],\"max_mode\":\"edit\",\"terminal\":false}\n",
)
.unwrap();
assert!(r.usable());
assert_eq!(r.roots, vec!["/home/a/project"]);
assert!(!r.restart_needed);
let off = parse("{\"enabled\":false,\"roots\":[]}").unwrap();
assert!(!off.usable());
assert!(parse("lembas: unknown command remote").is_err());
}
}
+9
View File
@@ -47,5 +47,14 @@
]
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgMzNBNUJCMkNCQTBCOTEwRgpSV1FQa1F1NkxMdWxNOCtnT2NMQU5QOTBHZ3hUaC9UNHVFZThUR2lqOGdWWUVObi9mbU00amoyago=",
"endpoints": [
"https://llembas.eu/desktop/latest.json"
],
"requireSignedVersion": true
}
}
}
+51 -1
View File
@@ -33,7 +33,7 @@ p { margin: 0; }
/* 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); }
label, .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); }
@@ -77,3 +77,53 @@ input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outl
.row { grid-template-columns: 1fr; grid-template-rows: var(--control-h) var(--control-h); }
.field { grid-template-rows: auto auto auto; }
}
/* The folder list: every row the same two tracks, the path cut from the left (its end is the
part that tells folders apart), the full path in the title. */
.folders { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
.folders li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
min-height: var(--control-h);
padding: 0 0 0 var(--sp-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.folders .path {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
font: var(--text-sm) var(--font-ui);
}
.folders .path bdi { direction: ltr; unicode-bidi: isolate; }
.folders .btn { height: calc(var(--control-h) - 2px); box-shadow: none; border-width: 0 0 0 1px; }
.folders .empty { padding: var(--sp-3); color: var(--warning); font-size: var(--text-sm); }
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
grid-auto-rows: auto var(--control-h) auto;
column-gap: var(--sp-4);
row-gap: var(--sp-2);
margin-top: var(--sp-4);
}
/* Each field spans three shared rows (label, control, hint), so the controls of side-by-side
fields sit on one line whatever their labels and hints do. */
.settings-grid .field { grid-row: span 3; grid-template-rows: subgrid; }
select {
height: var(--control-h);
min-width: 0;
padding: 0 var(--sp-2);
font: var(--text-sm) var(--font-ui);
color: var(--ink);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
}
.check { display: flex; align-items: center; gap: var(--sp-2); color: var(--ink); }
.check input { width: 1.1rem; height: 1.1rem; margin: 0; accent-color: var(--accent); }
+50 -1
View File
@@ -20,13 +20,53 @@ 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 noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
const [text, cls] =
s.agent.state === "running" && noFolders
? ["The agent is connected, but may open no folder yet: add one below.", "is-error"]
: (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
renderFolders(s)
}
function renderFolders(s) {
const r = s.remote
$("folders-section").hidden = !s.server || !s.linked || !r
if (!r) return
const list = $("folders")
list.replaceChildren()
if (!r.enabled || !r.roots.length) {
const li = document.createElement("li")
li.className = "empty"
li.textContent = "No folder yet: the agent can open nothing. Add the folder of a project."
list.append(li)
}
for (const dir of r.enabled ? r.roots : []) {
const li = document.createElement("li")
const path = document.createElement("span")
path.className = "path"
path.title = dir
const inner = document.createElement("bdi")
inner.textContent = dir
path.append(inner)
const remove = document.createElement("button")
remove.type = "button"
remove.className = "btn"
remove.textContent = "Remove"
remove.setAttribute("aria-label", `Remove ${dir}`)
remove.addEventListener("click", () => invoke("remote_remove", { dir }))
li.append(path, remove)
list.append(li)
}
if (r.max_mode) $("max-mode").value = r.max_mode
$("terminal").checked = !!r.terminal
$("folders-status").className = "status"
$("folders-status").textContent = ""
}
async function refresh() {
@@ -70,5 +110,14 @@ $("open").addEventListener("click", async () => {
if (s.server) await invoke("set_server", { address: s.server })
})
$("add-folder").addEventListener("click", () => invoke("remote_add"))
$("max-mode").addEventListener("change", (e) => invoke("remote_mode", { mode: e.target.value }))
$("terminal").addEventListener("change", (e) => invoke("remote_terminal", { on: e.target.checked }))
listen("remote-error", (e) => {
$("folders-status").className = "status is-error"
$("folders-status").textContent = String(e.payload)
})
listen("status", (e) => render(e.payload))
refresh()
invoke("remote_refresh")
+29
View File
@@ -43,6 +43,35 @@
<button type="button" class="btn btn--primary" id="open">Open the server</button>
</div>
</section>
<section aria-labelledby="folders-h" id="folders-section" hidden>
<h2 id="folders-h">[ PROJECT FOLDERS ]</h2>
<p class="muted">Agent chats from your server can work in these folders and anything inside
them — nowhere else on this computer. Start one in the server's Agents tab, on
<strong>Local</strong>.</p>
<ul class="folders" id="folders"></ul>
<p class="status" id="folders-status" aria-live="polite"></p>
<div class="row row--buttons">
<button type="button" class="btn btn--primary" id="add-folder">Add folder…</button>
</div>
<div class="settings-grid">
<div class="field">
<label for="max-mode">The most an agent chat may do here</label>
<select id="max-mode">
<option value="manual">Manual — asks first</option>
<option value="edit">Edit — asks before commands</option>
<option value="auto">Auto — asks nothing</option>
<option value="plan">Plan — changes nothing</option>
</select>
<p class="hint">A chat asking for more is lowered to this.</p>
</div>
<div class="field">
<span class="label">Terminal</span>
<label class="check"><input type="checkbox" id="terminal"> Let the server open a terminal here</label>
<p class="hint">A shell as you, in one of these folders.</p>
</div>
</div>
</section>
</main>
</body>
</html>
+13 -3
View File
@@ -15,8 +15,8 @@ html, body {
.bar {
display: grid;
/* leaf · name · host (takes the rest, and is the drag area) · agent · menu · sep · 3 controls */
grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto var(--titlebar-h) 1px repeat(3, 2.875rem);
/* leaf · name · host (takes the rest, and is the drag area) · update · agent · menu · sep · 3 controls */
grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) 1px repeat(3, 2.875rem);
align-items: center;
height: var(--titlebar-h);
border-bottom: 1px solid var(--border);
@@ -64,7 +64,17 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
.bar__agent[data-state="failed"] .dot,
.bar__agent[data-state="not_linked"] .dot { background: var(--warning); }
.bar__agent[data-state="not_linked"] .dot,
.bar__agent[data-state="no_folders"] .dot { background: var(--warning); }
.bar__update {
padding: 0 var(--sp-3);
color: var(--accent-ink);
background: var(--accent);
}
.bar__update:hover { color: var(--accent-ink); background: var(--accent-hover); }
/* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */
.bar__update[hidden] { display: block; width: 0; padding: 0; overflow: hidden; }
.bar__sep { width: 1px; height: 1.25rem; background: var(--border); }
+1
View File
@@ -14,6 +14,7 @@
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<span class="bar__host" id="host" data-tauri-drag-region></span>
<button class="bar__update" id="update" type="button" hidden>update</button>
<button class="bar__agent" id="agent" type="button" title="Agent">
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
</button>
+15 -3
View File
@@ -16,6 +16,7 @@ const WORDS = {
running: "agent running",
elsewhere: "agent running (service)",
failed: "agent stopped",
no_folders: "agent: no folders",
}
function render(s) {
@@ -25,10 +26,15 @@ function render(s) {
} catch {}
$("host").textContent = host
$("host").title = s.server || ""
const state = s.agent.state
// Connected but allowed into no folder: a green light would say it works when it cannot.
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state
$("agent").dataset.state = state
$("agent-text").textContent = WORDS[state] ?? state
$("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent"
$("update").hidden = !s.update
$("update").textContent = s.update ? `update ${s.update}` : "update"
$("update").title = s.update ? `LLeMbas ${s.update} is available (this is ${s.version})` : ""
}
function maximized(on) {
@@ -41,8 +47,14 @@ function maximized(on) {
$("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close())
$("menu").addEventListener("click", () => invoke("titlebar_menu"))
$("agent").addEventListener("click", () => invoke("titlebar_menu"))
$("update").addEventListener("click", () => invoke("update_app"))
// The menu opens under the button that was clicked: its bottom-left corner, in page pixels.
function menuAt(button) {
const r = button.getBoundingClientRect()
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
}
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
$("agent").addEventListener("click", (e) => menuAt(e.currentTarget))
listen("status", (e) => render(e.payload))
listen("maximized", (e) => maximized(!!e.payload))
+1 -1
View File
@@ -13,7 +13,7 @@
--radius: 2px;
--press: 2px;
--control-h: 2.5rem;
--card-max: 34rem;
--card-max: 40rem;
color-scheme: dark;
--bg: #0B0D10;