7 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
HomerandClaude Opus 5.5 971e1ddc9f LLeMbas Desktop 0.2.0
ci / check (push) Failing after 3s
release / linux (push) Failing after 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:56:46 +00:00
HomerandClaude Opus 5.5 daf2cf31ce LLeMbas's own window, the leaf icon, one name for the app
ci / check (push) Failing after 3s
- The window has no system decoration: a local title bar webview (agent
  state, a native menu with the tray's items, window buttons) above the
  content webview, which stays remote with no IPC. Laid out on resize,
  with a frame to resize by unless maximized.
- Icons from the transparent pixel leaf, not the server's tiled icon.
- productName llembas-desktop: package, .desktop file and the window's
  app id now match (KWin showed a generic icon); the visible name stays
  LLeMbas through a desktop template. Replaces the 0.1.0 package l-le-mbas.
- The bundled CLI is a resource in the app's own directory, not
  /usr/bin/lembas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:54:55 +00:00
HomerandClaude Opus 5.5 5045ba025a release: SHA256SUMS no longer lists itself
ci / check (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:36:11 +00:00
27 changed files with 1502 additions and 147 deletions

No files matched your search

+3 -1
View File
@@ -53,7 +53,9 @@ jobs:
mkdir dist mkdir dist
cp src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/appimage/*.AppImage dist/ cp src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/appimage/*.AppImage dist/
cd dist cd dist
{ echo "# llembas-desktop $VERSION"; sha256sum -- *; } > SHA256SUMS # Written beside the folder first: inside it, the glob would list SHA256SUMS itself.
{ echo "# llembas-desktop $VERSION"; sha256sum -- *; } > ../SHA256SUMS
mv ../SHA256SUMS .
cat SHA256SUMS cat SHA256SUMS
- name: Publish the GitHub Release - name: Publish the GitHub Release
env: env:
+1
View File
@@ -2,4 +2,5 @@
/src-tauri/target/ /src-tauri/target/
/src-tauri/gen/ /src-tauri/gen/
# The bundled LLeMbas CLI: fetched and verified by scripts/fetch-cli.sh, never committed # The bundled LLeMbas CLI: fetched and verified by scripts/fetch-cli.sh, never committed
/src-tauri/binaries/lembas
/src-tauri/binaries/lembas-* /src-tauri/binaries/lembas-*
+52
View File
@@ -6,6 +6,58 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased] ## [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
- The window is LLeMbas's own: a title bar in the LLeMbas look with the server's address, the
agent's state, a menu (the tray's items, plus Reload) and the window buttons. Closing still keeps
the app in the tray. The server's pages stay in a view of their own, with no access to the app.
### Fixed
- The app icon is the leaf alone, on a transparent background, instead of a dark square tile.
- The window shows the app's icon instead of a generic one (the window, the menu entry and the
package now share one name, `llembas-desktop`).
- The package is called `llembas-desktop` (it was `l-le-mbas`) and replaces the old one.
- The bundled LLeMbas CLI is installed in the app's own directory instead of as `/usr/bin/lembas`,
where it could hide, or be hidden by, a `lembas` installed by hand.
- `SHA256SUMS` on the GitHub release no longer lists itself.
## [0.1.0] — 2026-10-14 ## [0.1.0] — 2026-10-14
The first alpha, for Linux. It carries LLeMbas CLI 1.1.0. The first alpha, for Linux. It carries LLeMbas CLI 1.1.0.
+20 -8
View File
@@ -7,8 +7,9 @@ Your [LLeMbas](https://llembas.eu) server in a window of its own, with the agent
- **The agent, built in.** LLeMbas Desktop carries the [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI) - **The agent, built in.** LLeMbas Desktop carries the [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI)
and runs it in the background as this computer's device, so agent chats started on the server and runs it in the background as this computer's device, so agent chats started on the server
can work in your projects here, without a terminal. can work in your projects here, without a terminal.
- **In the tray**: close the window and the agent keeps running; the tray menu starts and stops it, - **Its own window**: a LLeMbas title bar with the agent's state and a menu (start and stop the
links the computer, and changes the server. agent, link the computer, reload, change server, quit). Close the window and the agent keeps
running in the tray, which has the same menu.
> **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later. > **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later.
@@ -19,21 +20,32 @@ Download the `.deb` or the AppImage from the
`SHA256SUMS` in the same release. `SHA256SUMS` in the same release.
```sh ```sh
sudo apt install ./LLeMbas_*_amd64.deb # Debian, Ubuntu sudo apt install ./llembas-desktop_*_amd64.deb # Debian, Ubuntu (replaces 0.1.0's l-le-mbas)
chmod +x LLeMbas_*.AppImage && ./LLeMbas_*.AppImage chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage
``` ```
On GNOME the tray needs the *AppIndicator* extension; without it, closing the window hides the On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has
app, and starting it again brings the window back. everything, and starting the app again brings a hidden window back.
## 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 ## Use
1. Start LLeMbas and enter your server's address. Sign in there as you would in a browser. 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. 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 The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
(`~/.config/lembas`). If you also installed the CLI and run `lembas service` yourself, the app sees (`~/.config/lembas`). The app's copy of the CLI lives in its own directory
(`/usr/lib/llembas-desktop/`), not on your `PATH`. If you also installed the CLI and run `lembas service` yourself, the app sees
it and does not start a second one: they are the same device. it and does not start a second one: they are the same device.
## Build ## Build
+1 -1
View File
@@ -1 +1 @@
1.1.0 1.2.0
+6 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Put the LLeMbas CLI that LLeMbas Desktop carries where Tauri's bundler wants it: # Put the LLeMbas CLI that LLeMbas Desktop carries where the bundle takes it from:
# src-tauri/binaries/lembas-<target triple> # src-tauri/binaries/lembas (a resource: installed in the app's own directory, never on PATH)
# #
# scripts/fetch-cli.sh [target-triple] (default: this machine's, from rustc) # scripts/fetch-cli.sh [target-triple] (default: this machine's, from rustc)
# #
@@ -39,6 +39,7 @@ ssh-keygen -Y verify -f "$work/allowed" -I lembas-release -n lembas-release \
|| { echo "fetch-cli: $ASSET.gz does not match SHA256SUMS" >&2; exit 1; } || { echo "fetch-cli: $ASSET.gz does not match SHA256SUMS" >&2; exit 1; }
mkdir -p src-tauri/binaries mkdir -p src-tauri/binaries
gunzip -c "$work/$ASSET.gz" > "src-tauri/binaries/lembas-$TRIPLE" gunzip -c "$work/$ASSET.gz" > src-tauri/binaries/lembas
chmod 755 "src-tauri/binaries/lembas-$TRIPLE" chmod 755 src-tauri/binaries/lembas
echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas-$TRIPLE" rm -f src-tauri/binaries/lembas-* # where an earlier version of this script put it
echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas"
+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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -708,6 +717,17 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -976,6 +996,16 @@ dependencies = [
"rustc_version", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
@@ -1520,6 +1550,21 @@ dependencies = [
"want", "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]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.21" version = "0.1.21"
@@ -1539,9 +1584,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1844,6 +1891,36 @@ dependencies = [
"windows-sys 0.45.0", "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]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -1987,8 +2064,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]] [[package]]
name = "llembas-desktop" name = "llembas-desktop"
version = "0.1.0" version = "0.4.0"
dependencies = [ dependencies = [
"gtk",
"libc", "libc",
"serde", "serde",
"serde_json", "serde_json",
@@ -1997,6 +2075,7 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater",
"ureq", "ureq",
] ]
@@ -2047,6 +2126,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2312,6 +2397,18 @@ dependencies = [
"objc2-core-foundation", "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]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2392,6 +2489,12 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2408,6 +2511,20 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2813,15 +2930,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -2920,11 +3042,23 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
dependencies = [ dependencies = [
"openssl-probe", "openssl-probe 0.1.6",
"rustls-pemfile", "rustls-pemfile",
"rustls-pki-types", "rustls-pki-types",
"schannel", "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]] [[package]]
@@ -2945,6 +3079,33 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.15" version = "0.103.15"
@@ -3050,6 +3211,19 @@ dependencies = [
"security-framework-sys", "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]] [[package]]
name = "security-framework-sys" name = "security-framework-sys"
version = "2.17.0" version = "2.17.0"
@@ -3281,6 +3455,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" 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]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.4" version = "1.0.4"
@@ -3462,6 +3652,27 @@ dependencies = [
"syn 3.0.8", "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]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3493,7 +3704,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3526,6 +3737,17 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -3549,7 +3771,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -3741,6 +3963,40 @@ dependencies = [
"zbus", "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]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.12.1" version = "2.12.1"
@@ -3751,7 +4007,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -3774,7 +4030,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -3963,6 +4219,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.20" version = "0.7.20"
@@ -4227,7 +4493,7 @@ dependencies = [
"log", "log",
"once_cell", "once_cell",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs 0.7.3",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@@ -4489,6 +4755,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@@ -4671,6 +4946,17 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@@ -4987,7 +5273,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -5034,6 +5320,16 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -5187,6 +5483,18 @@ dependencies = [
"syn 3.0.8", "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]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
+7 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "llembas-desktop" name = "llembas-desktop"
version = "0.1.0" version = "0.4.0"
description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer" description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer"
authors = ["Jaroslav Beneš"] authors = ["Jaroslav Beneš"]
license = "MIT" license = "MIT"
@@ -17,10 +17,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2", features = ["tray-icon", "unstable"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] } 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] [target.'cfg(unix)'.dependencies]
libc = "0.2" 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] [profile.release]
codegen-units = 1 codegen-units = 1
lto = true lto = true
+10 -2
View File
@@ -1,7 +1,15 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "The app's own pages (the first-run page). The server's web UI is a remote page and gets no IPC at all: this capability lists no remote URLs.", "description": "The app's own pages: the title bar and the first-run page. The server's web UI is a remote page in the same window and gets no IPC at all: this capability lists no remote URL. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default"] "permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-internal-toggle-maximize",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close"
]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Categories={{categories}}
{{#if comment}}
Comment={{comment}}
{{/if}}
Exec={{exec}}
StartupWMClass={{exec}}
Icon={{icon}}
Name=LLeMbas
GenericName=LLM workspace
Terminal=false
Type=Application
+44 -9
View File
@@ -6,22 +6,38 @@
//! installed by hand on the same computer are one device to the server, not two. //! installed by hand on the same computer are one device to the server, not two.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// The bundled binary: Tauri puts an `externalBin` beside the app's own executable. static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
/// `LLEMBAS_DESKTOP_CLI` points elsewhere, for development.
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
/// the .deb, inside the AppImage otherwise) — the app's own copy, never on PATH, so it neither
/// shadows nor is shadowed by a `lembas` installed by hand. Set once at start.
pub fn set_bundled(resource_dir: &Path) {
let _ = BUNDLED.set(resource_dir.join(exe_name()));
}
fn exe_name() -> &'static str {
if cfg!(windows) {
"lembas.exe"
} else {
"lembas"
}
}
/// The CLI to run: `LLEMBAS_DESKTOP_CLI` (development), else the bundled one, else one beside
/// the app's executable (`cargo tauri dev`).
pub fn binary() -> PathBuf { pub fn binary() -> PathBuf {
if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") { if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") {
return PathBuf::from(p); return PathBuf::from(p);
} }
let name = if cfg!(windows) { if let Some(p) = BUNDLED.get().filter(|p| p.exists()) {
"lembas.exe" return p.clone();
} else { }
"lembas"
};
std::env::current_exe() std::env::current_exe()
.ok() .ok()
.and_then(|e| e.parent().map(|d| d.join(name))) .and_then(|e| e.parent().map(|d| d.join(exe_name())))
.unwrap_or_else(|| PathBuf::from(name)) .unwrap_or_else(|| PathBuf::from(exe_name()))
} }
fn home() -> PathBuf { fn home() -> PathBuf {
@@ -92,6 +108,25 @@ fn running_in(state: &Path) -> Option<u32> {
(pid != 0 && alive(pid)).then_some(pid) (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)] #[cfg(unix)]
pub fn alive(pid: u32) -> bool { pub fn alive(pid: u32) -> bool {
// Signal 0 checks without sending; EPERM means it exists but is someone else's. // Signal 0 checks without sending; EPERM means it exists but is someone else's.
+499 -53
View File
@@ -1,8 +1,11 @@
//! LLeMbas Desktop: the server's web UI in a window of its own, and the LLeMbas CLI's agent //! LLeMbas Desktop: the server's web UI in a window of its own, and the LLeMbas CLI's agent
//! running on this computer, so agent chats can work in its projects without a terminal. //! running on this computer, so agent chats can work in its projects without a terminal.
//! //!
//! - **One window, `main`.** Before a server is chosen it shows the app's own first-run page //! - **One window, `main`, drawn by the app**: no system decoration, the app's own title bar (a
//! (`ui/`); after, the server itself. Closing it hides it; the tray brings it back. //! local page, `ui/titlebar.html`) on top and the content below it — before a server is chosen
//! the first-run page (`ui/index.html`), after it the server itself. Two webviews in one window,
//! so the server's pages never share a document with the app's controls. Closing hides the
//! window; the tray, the title bar's menu or starting the app again bring it back.
//! - **The server's pages get no IPC.** They are remote content, and the one capability lists no //! - **The server's pages get no IPC.** They are remote content, and the one capability lists no
//! remote URL. Links that leave the server open in the system browser. //! remote URL. Links that leave the server open in the system browser.
//! - **The agent is the CLI's**: `lembas login --json` links this computer, and //! - **The agent is the CLI's**: `lembas login --json` links this computer, and
@@ -10,6 +13,7 @@
mod agent; mod agent;
mod cli; mod cli;
mod remote;
mod settings; mod settings;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
@@ -20,16 +24,28 @@ use std::sync::Mutex;
use serde::Serialize; use serde::Serialize;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
use tauri::webview::WebviewBuilder;
use tauri::window::{Color, WindowBuilder};
use tauri::{ use tauri::{
AppHandle, Emitter, Manager, RunEvent, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, Webview, WebviewUrl,
Window, WindowEvent, Wry,
}; };
use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tauri_plugin_updater::UpdaterExt;
use agent::{Agent, State as AgentState}; use agent::{Agent, State as AgentState};
use remote::Remote;
use settings::Settings; use settings::Settings;
const MAIN: &str = "main"; const MAIN: &str = "main";
const TITLEBAR: &str = "titlebar";
const CONTENT: &str = "content";
/// The title bar's height, in logical pixels: `--titlebar-h` in `ui/titlebar.css`.
const TITLE_H: f64 = 36.0;
/// The frame left around the webviews while the window is not maximized: the window's own
/// surface, where the pointer resizes it (a webview over the edge would take the events).
const FRAME: f64 = 4.0;
struct App { struct App {
config_dir: PathBuf, config_dir: PathBuf,
@@ -37,6 +53,10 @@ struct App {
agent: Agent, agent: Agent,
tray: Mutex<Option<TrayItems>>, tray: Mutex<Option<TrayItems>>,
linking: Mutex<bool>, 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 { struct TrayItems {
@@ -66,6 +86,10 @@ struct Status {
linked: bool, linked: bool,
agent: AgentState, agent: AgentState,
agent_wanted: bool, 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 { fn status(app: &App) -> Status {
@@ -76,6 +100,8 @@ fn status(app: &App) -> Status {
server: s.server, server: s.server,
agent: app.agent.state(), agent: app.agent.state(),
agent_wanted: app.agent.wanted(), agent_wanted: app.agent.wanted(),
update: app.update.lock().unwrap().clone(),
remote: app.remote.lock().unwrap().clone(),
} }
} }
@@ -140,12 +166,134 @@ fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
let app = handle.state::<App>(); let app = handle.state::<App>();
let s = app.update(|s| s.agent = on)?; let s = app.update(|s| s.agent = on)?;
match (on, s.server) { 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(), _ => app.agent.stop(),
} }
Ok(()) 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. /// The app's own first-run page, as the webview addresses it on this platform.
fn local_page() -> Url { fn local_page() -> Url {
let base = if cfg!(windows) { let base = if cfg!(windows) {
@@ -156,35 +304,131 @@ fn local_page() -> Url {
Url::parse(base).expect("a fixed URL") Url::parse(base).expect("a fixed URL")
} }
fn content(handle: &AppHandle) -> Option<Webview> {
handle.get_webview(CONTENT)
}
fn main_window(handle: &AppHandle) -> Option<Window> {
handle.get_window(MAIN)
}
/// Show the main window on `server`, or on the first-run page; made when it does not exist. /// Show the main window on `server`, or on the first-run page; made when it does not exist.
fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> { fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> {
let target = match server { let target = match server {
Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?), Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?),
None => None, None => None,
}; };
if let Some(w) = handle.get_webview_window(MAIN) { if let (Some(w), Some(c)) = (main_window(handle), content(handle)) {
w.navigate(target.unwrap_or_else(local_page)) c.navigate(target.unwrap_or_else(local_page))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let _ = w.show(); let _ = w.show();
let _ = w.unminimize(); let _ = w.unminimize();
let _ = w.set_focus(); let _ = w.set_focus();
return Ok(()); return Ok(());
} }
build_main(handle, target).map_err(|e| e.to_string())
}
fn build_main(handle: &AppHandle, target: Option<Url>) -> tauri::Result<()> {
let window = WindowBuilder::new(handle, MAIN)
.title("LLeMbas")
.inner_size(1280.0, 840.0)
.min_inner_size(420.0, 560.0)
.decorations(false)
// The frame's colour: the Moria border, as `--border` in the pages.
.background_color(Color(0x26, 0x30, 0x3A, 0xFF))
.build()?;
let size = window
.inner_size()?
.to_logical::<f64>(window.scale_factor()?);
window.add_child(
WebviewBuilder::new(TITLEBAR, WebviewUrl::App("titlebar.html".into())),
LogicalPosition::new(FRAME, FRAME),
LogicalSize::new(size.width - 2.0 * FRAME, TITLE_H),
)?;
let url = match target { let url = match target {
Some(u) => WebviewUrl::External(u), Some(u) => WebviewUrl::External(u),
None => WebviewUrl::App("index.html".into()), None => WebviewUrl::App("index.html".into()),
}; };
#[cfg(target_os = "linux")]
if let Some(bar) = handle.get_webview(TITLEBAR) {
pin_titlebar(&bar);
}
let nav = handle.clone(); let nav = handle.clone();
WebviewWindowBuilder::new(handle, MAIN, url) window.add_child(
.title("LLeMbas") WebviewBuilder::new(CONTENT, url)
.inner_size(1280.0, 840.0) .user_agent(&user_agent())
.min_inner_size(420.0, 560.0) .on_navigation(move |url| allowed(&nav, url)),
.on_navigation(move |url| allowed(&nav, url)) LogicalPosition::new(FRAME, FRAME + TITLE_H),
.build() LogicalSize::new(
.map_err(|e| e.to_string())?; size.width - 2.0 * FRAME,
(size.height - TITLE_H - 2.0 * FRAME).max(0.0),
),
)?;
layout(&window);
Ok(()) 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 {
return;
};
let (Ok(size), Ok(scale)) = (window.inner_size(), window.scale_factor()) else {
return;
};
let frame = if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false)
{
0
} else {
(FRAME * scale).round() as u32
};
let title = (TITLE_H * scale).round() as u32;
let width = size.width.saturating_sub(2 * frame);
let _ = bar.set_position(PhysicalPosition::new(frame, frame));
let _ = bar.set_size(PhysicalSize::new(width, title));
let _ = body.set_position(PhysicalPosition::new(frame, frame + title));
let _ = body.set_size(PhysicalSize::new(
width,
size.height.saturating_sub(title + 2 * frame),
));
}
/// The window may show the app's own pages and the server; anything else opens in the browser. /// The window may show the app's own pages and the server; anything else opens in the browser.
fn allowed(handle: &AppHandle, url: &Url) -> bool { fn allowed(handle: &AppHandle, url: &Url) -> bool {
let local = matches!(url.scheme(), "tauri" | "asset") let local = matches!(url.scheme(), "tauri" | "asset")
@@ -250,8 +494,10 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
if let Ok(url) = Url::parse(link) { if let Ok(url) = Url::parse(link) {
if let Some(w) = handle.get_webview_window(MAIN) { if let Some(c) = content(&handle) {
let _ = w.navigate(url); let _ = c.navigate(url);
}
if let Some(w) = main_window(&handle) {
let _ = w.show(); let _ = w.show();
let _ = w.set_focus(); let _ = w.set_focus();
} }
@@ -304,7 +550,7 @@ fn status_of(handle: &AppHandle) -> Status {
fn show(handle: &AppHandle) { fn show(handle: &AppHandle) {
let server = handle.state::<App>().settings().server; let server = handle.state::<App>().settings().server;
if let Some(w) = handle.get_webview_window(MAIN) { if let Some(w) = main_window(handle) {
let _ = w.show(); let _ = w.show();
let _ = w.unminimize(); let _ = w.unminimize();
let _ = w.set_focus(); let _ = w.set_focus();
@@ -324,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 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 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 quit = MenuItem::with_id(handle, "quit", "Quit LLeMbas", true, None::<&str>)?;
let menu = Menu::with_items( let menu = Menu::with_items(
handle, handle,
@@ -341,35 +587,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
)?; )?;
let mut tray = TrayIconBuilder::with_id("tray") let mut tray = TrayIconBuilder::with_id("tray")
.tooltip("LLeMbas") .tooltip("LLeMbas")
.menu(&menu) .menu(&menu);
.on_menu_event(|h, ev| match ev.id().as_ref() {
"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() { if let Some(icon) = handle.default_window_icon() {
tray = tray.icon(icon.clone()); tray = tray.icon(icon.clone());
} }
@@ -377,6 +595,204 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
Ok(TrayItems { status, toggle }) 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 {
"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(|_| {});
}
}
"update" => offer_update(h),
"reload" => {
if let Some(c) = content(h) {
if let Ok(url) = c.url() {
let _ = c.navigate(url);
}
}
}
"server" => {
let _ = open_main(h, None);
}
"quit" => {
h.state::<App>().agent.stop();
h.exit(0);
}
_ => {}
}
}
/// The title bar's menu: the tray's items, where the window is (a desktop without a tray —
/// GNOME without the AppIndicator extension — has no other way to reach them).
/// 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, 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>);
let build = || -> tauri::Result<Menu<Wry>> {
let status = m("status", &app.agent.state().label(), false)?;
let toggle = m(
"toggle",
if app.agent.wanted() {
"Stop the agent"
} else {
"Start the agent"
},
true,
)?;
let link = m("link", "Link this computer…", true)?;
let 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", "Settings…", true)?;
let quit = m("quit", "Quit LLeMbas", true)?;
Menu::with_items(
h,
&[
&status,
&toggle,
&link,
&PredefinedMenuItem::separator(h)?,
&reload,
&update,
&server,
&PredefinedMenuItem::separator(h)?,
&quit,
],
)
};
let menu = build().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) { fn on_agent_state(handle: &AppHandle, state: &AgentState) {
let app = handle.state::<App>(); let app = handle.state::<App>();
if let Some(t) = app.tray.lock().unwrap().as_ref() { if let Some(t) = app.tray.lock().unwrap().as_ref() {
@@ -395,15 +811,27 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|h, _argv, _cwd| show(h))) .plugin(tauri_plugin_single_instance::init(|h, _argv, _cwd| show(h)))
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_status, get_status,
set_server, set_server,
set_agent, set_agent,
link_computer link_computer,
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| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
let config_dir = app.path().app_config_dir()?; let config_dir = app.path().app_config_dir()?;
if let Ok(dir) = app.path().resource_dir() {
cli::set_bundled(&dir);
}
let log = app.path().app_log_dir()?.join("agent.log"); let log = app.path().app_log_dir()?.join("agent.log");
let notify_handle = handle.clone(); let notify_handle = handle.clone();
let agent = Agent::new(log, move |s| { let agent = Agent::new(log, move |s| {
@@ -419,23 +847,41 @@ pub fn run() {
agent, agent,
tray: Mutex::new(None), tray: Mutex::new(None),
linking: Mutex::new(false), linking: Mutex::new(false),
update: Mutex::new(None),
remote: Mutex::new(None),
}); });
let items = build_tray(&handle)?; let items = build_tray(&handle)?;
*handle.state::<App>().tray.lock().unwrap() = Some(items); *handle.state::<App>().tray.lock().unwrap() = Some(items);
open_main(&handle, loaded.server.as_deref())?; 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) { if let (true, Some(server)) = (loaded.agent, loaded.server) {
handle.state::<App>().agent.start(server); handle.state::<App>().agent.start(server);
} }
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| match event {
// Closing the window keeps the app (and the agent) in the tray. // Closing the window keeps the app (and the agent) in the tray.
if let WindowEvent::CloseRequested { api, .. } = event { WindowEvent::CloseRequested { api, .. } if window.label() == MAIN => {
if window.label() == MAIN { let _ = window.hide();
let _ = window.hide(); api.prevent_close();
api.prevent_close();
}
} }
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. }
if window.label() == MAIN =>
{
layout(window);
let _ = window.emit_to(
TITLEBAR,
"maximized",
window.is_maximized().unwrap_or(false),
);
}
_ => {}
}) })
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("LLeMbas Desktop could not start") .expect("LLeMbas Desktop could not start")
+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());
}
}
+28 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "LLeMbas", "productName": "llembas-desktop",
"mainBinaryName": "llembas-desktop", "mainBinaryName": "llembas-desktop",
"identifier": "eu.llembas.desktop", "identifier": "eu.llembas.desktop",
"build": { "build": {
@@ -15,7 +15,10 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["deb", "appimage"], "targets": [
"deb",
"appimage"
],
"category": "Productivity", "category": "Productivity",
"shortDescription": "Your LLeMbas server in a window, with the agent on this computer", "shortDescription": "Your LLeMbas server in a window, with the agent on this computer",
"longDescription": "LLeMbas Desktop opens your LLeMbas server's web UI in a window of its own and runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal.", "longDescription": "LLeMbas Desktop opens your LLeMbas server's web UI in a window of its own and runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal.",
@@ -30,6 +33,28 @@
"icons/icon.png", "icons/icon.png",
"icons/icon.ico" "icons/icon.ico"
], ],
"externalBin": ["binaries/lembas"] "resources": {
"binaries/lembas": "lembas"
},
"linux": {
"deb": {
"desktopTemplate": "llembas-desktop.desktop",
"conflicts": [
"l-le-mbas"
],
"replaces": [
"l-le-mbas"
]
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgMzNBNUJCMkNCQTBCOTEwRgpSV1FQa1F1NkxMdWxNOCtnT2NMQU5QOTBHZ3hUaC9UNHVFZThUR2lqOGdWWUVObi9mbU00amoyago=",
"endpoints": [
"https://llembas.eu/desktop/latest.json"
],
"requireSignedVersion": true
}
} }
} }
+52 -53
View File
@@ -1,55 +1,4 @@
/* The first-run page: the LLeMbas look (Moria dark, Shire light), no build step. */ /* The first-run page. Tokens in tokens.css. */
:root {
--font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-xl: 1.5rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--radius: 2px;
--press: 2px;
--control-h: 2.5rem;
--card-max: 34rem;
color-scheme: dark;
--bg: #0B0D10;
--surface: #11151A;
--surface-hover: #1D232B;
--border: #26303A;
--border-strong: #5C6976;
--ink: #E2E8EC;
--ink-muted: #A6B2BC;
--accent: #8FD8E8;
--accent-hover: #B4E6F1;
--accent-ink: #061418;
--leaf: #7EE081;
--warning: #F0A840;
--shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55);
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #F1EAD6;
--surface: #F8F3E5;
--surface-hover: #ECE4CF;
--border: #CBBF9D;
--border-strong: #2A2E22;
--ink: #1F2418;
--ink-muted: #474D3B;
--accent: #285F3C;
--accent-hover: #1F4D30;
--accent-ink: #F8F3E5;
--leaf: #3D6B1C;
--warning: #7E5200;
--shadow-card: 4px 4px 0 var(--border-strong);
}
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; } html, body { margin: 0; min-height: 100%; }
@@ -84,7 +33,7 @@ p { margin: 0; }
/* A field is three stacked parts on fixed rows: label, control row, hint. */ /* 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); } .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 { font-size: var(--text-sm); color: var(--ink-muted); min-height: 1.4em; }
.hint.is-error, .status.is-error { color: var(--warning); } .hint.is-error, .status.is-error { color: var(--warning); }
@@ -128,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); } .row { grid-template-columns: 1fr; grid-template-rows: var(--control-h) var(--control-h); }
.field { grid-template-rows: auto auto auto; } .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 $("version").textContent = s.version
if (s.server && !$("address").value) $("address").value = s.server if (s.server && !$("address").value) $("address").value = s.server
$("agent-section").hidden = !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") const status = $("agent-status")
status.textContent = text status.textContent = text
status.className = `status ${cls}` status.className = `status ${cls}`
$("link").textContent = s.linked ? "Link again" : "Link this computer" $("link").textContent = s.linked ? "Link again" : "Link this computer"
$("toggle").textContent = s.agent_wanted ? "Stop the agent" : "Start the agent" $("toggle").textContent = s.agent_wanted ? "Stop the agent" : "Start the agent"
$("toggle").disabled = !s.linked && !s.agent_wanted $("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() { async function refresh() {
@@ -70,5 +110,14 @@ $("open").addEventListener("click", async () => {
if (s.server) await invoke("set_server", { address: s.server }) 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)) listen("status", (e) => render(e.payload))
refresh() refresh()
invoke("remote_refresh")
+30
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>LLeMbas</title> <title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
<script src="app.js" defer></script> <script src="app.js" defer></script>
</head> </head>
@@ -42,6 +43,35 @@
<button type="button" class="btn btn--primary" id="open">Open the server</button> <button type="button" class="btn btn--primary" id="open">Open the server</button>
</div> </div>
</section> </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> </main>
</body> </body>
</html> </html>
+93
View File
@@ -0,0 +1,93 @@
/* The title bar: one row, fixed height, every slot a fixed or shared track so nothing in it
moves when the host name or the agent's state changes length. */
* { box-sizing: border-box; }
html, body {
margin: 0;
height: var(--titlebar-h);
overflow: hidden;
background: var(--bg);
color: var(--ink);
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.bar {
display: grid;
/* leaf · name · host (takes the rest, and is the drag area) · 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);
font: 600 var(--text-sm) / 1 var(--font-ui);
}
.bar__leaf { justify-self: center; width: 18px; height: 18px; image-rendering: pixelated; }
.bar__name b { color: var(--leaf); font-weight: 700; }
.bar__host {
padding: 0 var(--sp-3);
color: var(--ink-muted);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-self: stretch;
display: flex;
align-items: center;
}
button {
height: var(--titlebar-h);
margin: 0;
padding: 0;
font: inherit;
color: var(--ink-muted);
background: transparent;
border: 0;
border-radius: 0;
cursor: pointer;
}
button:hover { color: var(--ink); background: var(--surface-hover); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.bar__agent {
display: grid;
grid-template-columns: 0.5rem auto;
gap: var(--sp-2);
align-items: center;
padding: 0 var(--sp-3);
font-weight: 400;
}
.dot { width: 0.5rem; height: 0.5rem; background: var(--ink-muted); }
.bar__agent[data-state="running"] .dot,
.bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
.bar__agent[data-state="failed"] .dot,
.bar__agent[data-state="not_linked"] .dot,
.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); }
.bar__btn { display: grid; place-items: center; }
.bar__btn svg { width: 16px; height: 16px; }
.bar__btn--close:hover { color: #fff; background: var(--danger); }
#maximize .i-restore { display: none; }
#maximize[data-maximized] .i-max { display: none; }
#maximize[data-maximized] .i-restore { display: block; }
/* Narrow windows: the host goes first, then the agent's words (its dot stays). */
@media (max-width: 34rem) {
.bar__host { visibility: hidden; }
#agent-text { display: none; }
}
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="dark light">
<title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="titlebar.css">
<script src="titlebar.js" defer></script>
</head>
<body>
<header class="bar" data-tauri-drag-region>
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<span class="bar__host" id="host" data-tauri-drag-region></span>
<button class="bar__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>
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu">
<svg viewBox="0 0 16 16" aria-hidden="true"><g fill="currentColor"><rect x="3" y="4" width="10" height="1.5"/><rect x="3" y="7.25" width="10" height="1.5"/><rect x="3" y="10.5" width="10" height="1.5"/></g></svg>
</button>
<span class="bar__sep" aria-hidden="true"></span>
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise">
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="10" width="8" height="1.5" fill="currentColor"/></svg>
</button>
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise">
<svg class="i-max" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.75 4.75h6.5v6.5h-6.5z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="i-restore" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.75 6.25h6v6h-6z M6.25 6.25v-2.5h6v6h-2.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</header>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
// The title bar. Moving and double-click maximising come from `data-tauri-drag-region`; the
// buttons call the window directly; the menu and the agent pill open the app's native menu (the
// tray's items), which can reach past this 36-pixel-high page.
"use strict"
const { invoke } = window.__TAURI__.core
const { listen } = window.__TAURI__.event
const win = window.__TAURI__.window.getCurrentWindow()
const $ = (id) => document.getElementById(id)
const WORDS = {
off: "agent off",
not_linked: "not linked",
starting: "agent starting…",
running: "agent running",
elsewhere: "agent running (service)",
failed: "agent stopped",
no_folders: "agent: no folders",
}
function render(s) {
let host = ""
try {
host = s.server ? new URL(s.server).host : ""
} catch {}
$("host").textContent = host
$("host").title = s.server || ""
// 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) {
const b = $("maximize")
b.toggleAttribute("data-maximized", on)
b.setAttribute("aria-label", on ? "Restore" : "Maximise")
b.title = on ? "Restore" : "Maximise"
}
$("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close())
$("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))
invoke("get_status").then(render)
win.isMaximized().then(maximized)
+54
View File
@@ -0,0 +1,54 @@
/* LLeMbas Desktop's own pages: one set of tokens (Moria dark, Shire light), as on llembas.eu. */
:root {
--font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-xl: 1.5rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--radius: 2px;
--press: 2px;
--control-h: 2.5rem;
--card-max: 40rem;
color-scheme: dark;
--bg: #0B0D10;
--surface: #11151A;
--surface-hover: #1D232B;
--border: #26303A;
--border-strong: #5C6976;
--ink: #E2E8EC;
--ink-muted: #A6B2BC;
--accent: #8FD8E8;
--accent-hover: #B4E6F1;
--accent-ink: #061418;
--leaf: #7EE081;
--warning: #F0A840;
--shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55);
--danger: #E5534B;
--titlebar-h: 36px;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #F1EAD6;
--surface: #F8F3E5;
--surface-hover: #ECE4CF;
--border: #CBBF9D;
--border-strong: #2A2E22;
--ink: #1F2418;
--ink-muted: #474D3B;
--accent: #285F3C;
--accent-hover: #1F4D30;
--accent-ink: #F8F3E5;
--leaf: #3D6B1C;
--warning: #7E5200;
--shadow-card: 4px 4px 0 var(--border-strong);
--danger: #B3261E;
}
}