Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5028dd7ca1
|
||
|
|
1e2fa39193
|
||
|
|
bb91ac62b0
|
||
|
|
5eff067e75
|
||
|
|
971e1ddc9f
|
||
|
|
daf2cf31ce
|
||
|
|
5045ba025a
|
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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-*
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
1.1.0
|
1.2.0
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 827 B |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 515 B |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Categories={{categories}}
|
||||||
|
{{#if comment}}
|
||||||
|
Comment={{comment}}
|
||||||
|
{{/if}}
|
||||||
|
Exec={{exec}}
|
||||||
|
StartupWMClass={{exec}}
|
||||||
|
Icon={{icon}}
|
||||||
|
Name=LLeMbas
|
||||||
|
GenericName=LLM workspace
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
@@ -6,22 +6,38 @@
|
|||||||
//! installed by hand on the same computer are one device to the server, not two.
|
//! 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.
|
||||||
|
|||||||
@@ -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,8 +587,121 @@ 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() {
|
if let Some(icon) = handle.default_window_icon() {
|
||||||
|
tray = tray.icon(icon.clone());
|
||||||
|
}
|
||||||
|
tray.build(handle)?;
|
||||||
|
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),
|
"open" => show(h),
|
||||||
"toggle" => {
|
"toggle" => {
|
||||||
let on = !h.state::<App>().agent.wanted();
|
let on = !h.state::<App>().agent.wanted();
|
||||||
@@ -361,6 +720,14 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
|
|||||||
.show(|_| {});
|
.show(|_| {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"update" => offer_update(h),
|
||||||
|
"reload" => {
|
||||||
|
if let Some(c) = content(h) {
|
||||||
|
if let Ok(url) = c.url() {
|
||||||
|
let _ = c.navigate(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"server" => {
|
"server" => {
|
||||||
let _ = open_main(h, None);
|
let _ = open_main(h, None);
|
||||||
}
|
}
|
||||||
@@ -369,12 +736,61 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
|
|||||||
h.exit(0);
|
h.exit(0);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
|
||||||
if let Some(icon) = handle.default_window_icon() {
|
|
||||||
tray = tray.icon(icon.clone());
|
|
||||||
}
|
}
|
||||||
tray.build(handle)?;
|
}
|
||||||
Ok(TrayItems { status, toggle })
|
|
||||||
|
/// 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) {
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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")
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||