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

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

No files matched your search

+14
View File
@@ -6,6 +6,20 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased] ## [Unreleased]
## [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 ## [0.2.0] — 2026-10-14
### Added ### Added
+8
View File
@@ -27,6 +27,14 @@ chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage
On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has
everything, and starting the app again brings a hidden window back. 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.
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Offer a release to installed copies of LLeMbas Desktop: sign its packages with the updater key
# and write the update manifest the app reads (https://llembas.eu/desktop/latest.json).
#
# MINISIGN_KEY=path/to/updater.key scripts/publish-update.sh v0.3.0 path/to/LLeMbas-Website
#
# The packages are the ones release.yml published on GitHub for the tag, checked against that
# release's SHA256SUMS first. The app verifies each package's signature with the public key in
# src-tauri/tauri.conf.json before installing it, so the manifest's host need not be trusted.
# This writes site/desktop/latest.json in the website's checkout; committing and pushing it is
# what publishes the update.
set -euo pipefail
TAG="${1:?usage: publish-update.sh vX.Y.Z path/to/LLeMbas-Website}"
SITE="${2:?usage: publish-update.sh vX.Y.Z path/to/LLeMbas-Website}"
KEY="${MINISIGN_KEY:?set MINISIGN_KEY to the minisign secret key of the updater}"
VERSION="${TAG#v}"
BASE="https://github.com/LLeMbas/LLeMbas-Desktop/releases/download/$TAG"
DEB="llembas-desktop_${VERSION}_amd64.deb"
APPIMAGE="llembas-desktop_${VERSION}_amd64.AppImage"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
for f in SHA256SUMS "$DEB" "$APPIMAGE"; do
curl -fsSL --retry 3 -o "$work/$f" "$BASE/$f"
done
[ "$(sed -n -E 's/^# llembas-desktop //p' "$work/SHA256SUMS" | head -n1)" = "$VERSION" ] \
|| { echo "publish-update: SHA256SUMS is not for $VERSION" >&2; exit 1; }
(cd "$work" && grep -E " ($DEB|$APPIMAGE)\$" SHA256SUMS | sha256sum -c --strict --quiet -) \
|| { echo "publish-update: the packages do not match SHA256SUMS" >&2; exit 1; }
sig() {
# Tab-separated, with the version: the app refuses a package whose signature names another one.
minisign -S -s "$KEY" -m "$work/$1" -x "$work/$1.minisig" \
-t "$(printf 'timestamp:%s\tfile:%s\tversion:%s' "$(date +%s)" "$1" "$VERSION")" >/dev/null
base64 -w0 "$work/$1.minisig"
}
notes="$(git tag -l --format='%(contents)' "$TAG" | sed '/-----BEGIN SSH SIGNATURE-----/,$d' | tail -n +3)"
mkdir -p "$SITE/site/desktop"
jq -n --arg v "$VERSION" --arg notes "$notes" --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg deb_url "$BASE/$DEB" --arg deb_sig "$(sig "$DEB")" \
--arg app_url "$BASE/$APPIMAGE" --arg app_sig "$(sig "$APPIMAGE")" \
'{version: $v, notes: $notes, pub_date: $date, platforms: {
"linux-x86_64-deb": {url: $deb_url, signature: $deb_sig},
"linux-x86_64-appimage": {url: $app_url, signature: $app_sig}}}' \
> "$SITE/site/desktop/latest.json"
echo "publish-update: wrote $SITE/site/desktop/latest.json for $VERSION — commit and push it to publish"
+317 -9
View File
@@ -47,6 +47,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -708,6 +717,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -976,6 +996,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
@@ -1520,6 +1550,21 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.21" version = "0.1.21"
@@ -1539,9 +1584,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1844,6 +1891,36 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -1987,8 +2064,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]] [[package]]
name = "llembas-desktop" name = "llembas-desktop"
version = "0.2.0" version = "0.3.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"
+6 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "llembas-desktop" name = "llembas-desktop"
version = "0.2.0" version = "0.3.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"
@@ -21,6 +21,7 @@ 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
+160 -3
View File
@@ -26,11 +26,12 @@ use tauri::tray::TrayIconBuilder;
use tauri::webview::WebviewBuilder; use tauri::webview::WebviewBuilder;
use tauri::window::{Color, WindowBuilder}; use tauri::window::{Color, WindowBuilder};
use tauri::{ use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize, AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, Webview, WebviewUrl,
RunEvent, Url, Webview, WebviewUrl, Window, WindowEvent, Wry, 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 settings::Settings; use settings::Settings;
@@ -50,6 +51,8 @@ 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>>,
} }
struct TrayItems { struct TrayItems {
@@ -79,6 +82,8 @@ 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>,
} }
fn status(app: &App) -> Status { fn status(app: &App) -> Status {
@@ -89,6 +94,7 @@ 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(),
} }
} }
@@ -215,6 +221,10 @@ fn build_main(handle: &AppHandle, target: Option<Url>) -> tauri::Result<()> {
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();
window.add_child( window.add_child(
WebviewBuilder::new(CONTENT, url).on_navigation(move |url| allowed(&nav, url)), WebviewBuilder::new(CONTENT, url).on_navigation(move |url| allowed(&nav, url)),
@@ -224,12 +234,45 @@ fn build_main(handle: &AppHandle, target: Option<Url>) -> tauri::Result<()> {
(size.height - TITLE_H - 2.0 * FRAME).max(0.0), (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 /// 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). /// frame around both unless the window is maximized (then there is nothing to resize).
fn layout(window: &Window) { 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 handle = window.app_handle();
let (Some(bar), Some(body)) = (handle.get_webview(TITLEBAR), handle.get_webview(CONTENT)) let (Some(bar), Some(body)) = (handle.get_webview(TITLEBAR), handle.get_webview(CONTENT))
else { else {
@@ -421,6 +464,110 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
Ok(TrayItems { status, toggle }) Ok(TrayItems { status, toggle })
} }
/// How often a running app looks for a new release (and once shortly after it starts).
const UPDATE_EVERY: std::time::Duration = std::time::Duration::from_secs(6 * 3600);
/// Ask the update manifest whether a newer release exists; remember and announce the answer.
/// The manifest and each package are signed with the updater key, which the app checks before
/// installing anything.
async fn check_update(handle: &AppHandle) -> Result<Option<String>, String> {
let found = handle
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.map(|u| u.version);
*handle.state::<App>().update.lock().unwrap() = found.clone();
let _ = handle.emit("status", status_of(handle));
Ok(found)
}
fn watch_updates(handle: AppHandle) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(20));
loop {
let _ = tauri::async_runtime::block_on(check_update(&handle));
std::thread::sleep(UPDATE_EVERY);
}
});
}
/// Download and install the newer release, then start again on it. A .deb is installed with
/// the system's password prompt (pkexec); an AppImage replaces itself.
async fn install_update(handle: AppHandle) -> Result<(), String> {
let update = handle
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.ok_or("LLeMbas is up to date.")?;
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|e| format!("The update could not be installed: {e}"))?;
handle.state::<App>().agent.stop();
handle.restart()
}
fn offer_update(h: &AppHandle) {
let h = h.clone();
tauri::async_runtime::spawn(async move {
let version = match check_update(&h).await {
Ok(Some(v)) => v,
Ok(None) => {
h.dialog()
.message(format!(
"LLeMbas {} is the newest release.",
env!("CARGO_PKG_VERSION")
))
.title("LLeMbas is up to date")
.show(|_| {});
return;
}
Err(e) => {
h.dialog()
.message(format!("Could not look for updates: {e}"))
.title("LLeMbas")
.kind(MessageDialogKind::Warning)
.show(|_| {});
return;
}
};
let again = h.clone();
h.dialog()
.message(format!(
"LLeMbas {version} is available (this is {}). Install it now? LLeMbas starts again afterwards; the agent stops for a moment.",
env!("CARGO_PKG_VERSION")
))
.title("Update LLeMbas")
.buttons(tauri_plugin_dialog::MessageDialogButtons::OkCancelCustom(
"Install".into(),
"Later".into(),
))
.show(move |yes| {
if yes {
tauri::async_runtime::spawn(async move {
if let Err(e) = install_update(again.clone()).await {
again
.dialog()
.message(e)
.title("LLeMbas")
.kind(MessageDialogKind::Error)
.show(|_| {});
}
});
}
});
});
}
#[tauri::command]
fn update_app(handle: AppHandle) {
offer_update(&handle);
}
/// A menu item chosen, from the tray or from the title bar's menu: the same ids in both. /// 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) { fn on_menu(h: &AppHandle, id: &str) {
match id { match id {
@@ -442,6 +589,7 @@ fn on_menu(h: &AppHandle, id: &str) {
.show(|_| {}); .show(|_| {});
} }
} }
"update" => offer_update(h),
"reload" => { "reload" => {
if let Some(c) = content(h) { if let Some(c) = content(h) {
if let Ok(url) = c.url() { if let Ok(url) = c.url() {
@@ -479,6 +627,10 @@ fn titlebar_menu(window: Window) -> Result<(), String> {
true, true,
)?; )?;
let link = m("link", "Link this computer…", 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 reload = m("reload", "Reload", true)?;
let server = m("server", "Change server…", true)?; let server = m("server", "Change server…", true)?;
let quit = m("quit", "Quit LLeMbas", true)?; let quit = m("quit", "Quit LLeMbas", true)?;
@@ -490,6 +642,7 @@ fn titlebar_menu(window: Window) -> Result<(), String> {
&link, &link,
&PredefinedMenuItem::separator(h)?, &PredefinedMenuItem::separator(h)?,
&reload, &reload,
&update,
&server, &server,
&PredefinedMenuItem::separator(h)?, &PredefinedMenuItem::separator(h)?,
&quit, &quit,
@@ -518,12 +671,14 @@ 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 titlebar_menu,
update_app
]) ])
.on_menu_event(|h, ev| on_menu(h, ev.id().as_ref())) .on_menu_event(|h, ev| on_menu(h, ev.id().as_ref()))
.setup(|app| { .setup(|app| {
@@ -547,10 +702,12 @@ 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),
}); });
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());
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);
} }
+9
View File
@@ -47,5 +47,14 @@
] ]
} }
} }
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgMzNBNUJCMkNCQTBCOTEwRgpSV1FQa1F1NkxMdWxNOCtnT2NMQU5QOTBHZ3hUaC9UNHVFZThUR2lqOGdWWUVObi9mbU00amoyago=",
"endpoints": [
"https://llembas.eu/desktop/latest.json"
],
"requireSignedVersion": true
}
} }
} }
+11 -2
View File
@@ -15,8 +15,8 @@ html, body {
.bar { .bar {
display: grid; display: grid;
/* leaf · name · host (takes the rest, and is the drag area) · agent · menu · sep · 3 controls */ /* 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 var(--titlebar-h) 1px repeat(3, 2.875rem); grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) 1px repeat(3, 2.875rem);
align-items: center; align-items: center;
height: var(--titlebar-h); height: var(--titlebar-h);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -66,6 +66,15 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.bar__agent[data-state="failed"] .dot, .bar__agent[data-state="failed"] .dot,
.bar__agent[data-state="not_linked"] .dot { background: var(--warning); } .bar__agent[data-state="not_linked"] .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__sep { width: 1px; height: 1.25rem; background: var(--border); }
.bar__btn { display: grid; place-items: center; } .bar__btn { display: grid; place-items: center; }
+1
View File
@@ -14,6 +14,7 @@
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span> <span class="bar__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> <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"> <button class="bar__agent" id="agent" type="button" title="Agent">
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span> <span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
</button> </button>
+4
View File
@@ -29,6 +29,9 @@ function render(s) {
$("agent").dataset.state = state $("agent").dataset.state = state
$("agent-text").textContent = WORDS[state] ?? state $("agent-text").textContent = WORDS[state] ?? state
$("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent" $("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) { function maximized(on) {
@@ -41,6 +44,7 @@ function maximized(on) {
$("minimize").addEventListener("click", () => win.minimize()) $("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize()) $("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close()) $("close").addEventListener("click", () => win.close())
$("update").addEventListener("click", () => invoke("update_app"))
$("menu").addEventListener("click", () => invoke("titlebar_menu")) $("menu").addEventListener("click", () => invoke("titlebar_menu"))
$("agent").addEventListener("click", () => invoke("titlebar_menu")) $("agent").addEventListener("click", () => invoke("titlebar_menu"))