diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b51de..e8167ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to LLeMbas Desktop. The format follows ## [Unreleased] +### 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 diff --git a/README.md b/README.md index 277d204..dd1624c 100644 --- a/README.md +++ b/README.md @@ -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 everything, and starting the app again brings a hidden window back. +## Updates + +LLeMbas Desktop looks for a new release when it starts and every six hours. When there is one, an +**update** button appears in the title bar (and *Install LLeMbas x.y.z…* in its menu; *Check for +updates…* otherwise). Installing asks for your password to update the `.deb` (an AppImage updates +itself), then the app starts again. Every package is checked against the release signing key +built into the app before anything is installed. + ## Use 1. Start LLeMbas and enter your server's address. Sign in there as you would in a browser. diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh new file mode 100755 index 0000000..7acdc78 --- /dev/null +++ b/scripts/publish-update.sh @@ -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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3c4b644..4b73f03 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -708,6 +717,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -976,6 +996,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1520,6 +1550,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -1539,9 +1584,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1844,6 +1891,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1987,8 +2064,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "llembas-desktop" -version = "0.2.0" +version = "0.3.0" dependencies = [ + "gtk", "libc", "serde", "serde_json", @@ -1997,6 +2075,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-single-instance", + "tauri-plugin-updater", "ureq", ] @@ -2047,6 +2126,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2312,6 +2397,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2392,6 +2489,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2408,6 +2511,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "pango" version = "0.18.3" @@ -2813,15 +2930,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2920,11 +3042,23 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", ] [[package]] @@ -2945,6 +3079,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs 0.8.5", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -3050,6 +3211,19 @@ dependencies = [ "security-framework-sys", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework-sys" version = "2.17.0" @@ -3281,6 +3455,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -3462,6 +3652,27 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3493,7 +3704,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3526,6 +3737,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3549,7 +3771,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3741,6 +3963,40 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "libc", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.61.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -3751,7 +4007,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3774,7 +4030,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3963,6 +4219,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -4227,7 +4493,7 @@ dependencies = [ "log", "once_cell", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.7.3", "rustls-pki-types", "serde", "serde_json", @@ -4489,6 +4755,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -4671,6 +4946,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4987,7 +5273,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5034,6 +5320,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5187,6 +5483,18 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 64fbb71..fc3a5e5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] 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" authors = ["Jaroslav Beneš"] license = "MIT" @@ -21,6 +21,7 @@ tauri = { version = "2", features = ["tray-icon", "unstable"] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] } @@ -28,6 +29,10 @@ ureq = { version = "2", default-features = false, features = ["tls", "native-cer [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +# The window's GTK box: Tauri packs every webview into it with an equal share on Linux. +gtk = "0.18" + [profile.release] codegen-units = 1 lto = true diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 98e9f89..96aa080 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,11 +26,12 @@ use tauri::tray::TrayIconBuilder; use tauri::webview::WebviewBuilder; use tauri::window::{Color, WindowBuilder}; use tauri::{ - AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize, - RunEvent, Url, Webview, WebviewUrl, Window, WindowEvent, Wry, + AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, Webview, WebviewUrl, + Window, WindowEvent, Wry, }; use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_opener::OpenerExt; +use tauri_plugin_updater::UpdaterExt; use agent::{Agent, State as AgentState}; use settings::Settings; @@ -50,6 +51,8 @@ struct App { agent: Agent, tray: Mutex>, linking: Mutex, + /// The newer release the last check found, if any. + update: Mutex>, } struct TrayItems { @@ -79,6 +82,8 @@ struct Status { linked: bool, agent: AgentState, agent_wanted: bool, + /// A newer release of the app, when the last check found one. + update: Option, } fn status(app: &App) -> Status { @@ -89,6 +94,7 @@ fn status(app: &App) -> Status { server: s.server, agent: app.agent.state(), agent_wanted: app.agent.wanted(), + update: app.update.lock().unwrap().clone(), } } @@ -215,6 +221,10 @@ fn build_main(handle: &AppHandle, target: Option) -> tauri::Result<()> { Some(u) => WebviewUrl::External(u), None => WebviewUrl::App("index.html".into()), }; + #[cfg(target_os = "linux")] + if let Some(bar) = handle.get_webview(TITLEBAR) { + pin_titlebar(&bar); + } let nav = handle.clone(); window.add_child( WebviewBuilder::new(CONTENT, url).on_navigation(move |url| allowed(&nav, url)), @@ -224,12 +234,45 @@ fn build_main(handle: &AppHandle, target: Option) -> tauri::Result<()> { (size.height - TITLE_H - 2.0 * FRAME).max(0.0), ), )?; + layout(&window); Ok(()) } +/// On Linux, Tauri packs every webview of a window into the window's GTK box, each with an equal +/// share of the height, and positions and sizes set on a webview are not applied there. So the +/// title bar is told directly: a fixed height, no share of what is left (the content takes it). +#[cfg(target_os = "linux")] +fn pin_titlebar(bar: &Webview) { + let _ = bar.with_webview(|pv| { + use gtk::prelude::*; + let view = pv.inner(); + view.set_size_request(-1, TITLE_H as i32); + if let Some(parent) = view.parent().and_then(|p| p.downcast::().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 { @@ -421,6 +464,110 @@ fn build_tray(handle: &AppHandle) -> tauri::Result { 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, 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::().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::().agent.stop(); + handle.restart() +} + +fn offer_update(h: &AppHandle) { + let h = h.clone(); + tauri::async_runtime::spawn(async move { + let version = match check_update(&h).await { + Ok(Some(v)) => v, + Ok(None) => { + h.dialog() + .message(format!( + "LLeMbas {} is the newest release.", + env!("CARGO_PKG_VERSION") + )) + .title("LLeMbas is up to date") + .show(|_| {}); + return; + } + Err(e) => { + h.dialog() + .message(format!("Could not look for updates: {e}")) + .title("LLeMbas") + .kind(MessageDialogKind::Warning) + .show(|_| {}); + return; + } + }; + let again = h.clone(); + h.dialog() + .message(format!( + "LLeMbas {version} is available (this is {}). Install it now? LLeMbas starts again afterwards; the agent stops for a moment.", + env!("CARGO_PKG_VERSION") + )) + .title("Update LLeMbas") + .buttons(tauri_plugin_dialog::MessageDialogButtons::OkCancelCustom( + "Install".into(), + "Later".into(), + )) + .show(move |yes| { + if yes { + tauri::async_runtime::spawn(async move { + if let Err(e) = install_update(again.clone()).await { + again + .dialog() + .message(e) + .title("LLeMbas") + .kind(MessageDialogKind::Error) + .show(|_| {}); + } + }); + } + }); + }); +} + +#[tauri::command] +fn update_app(handle: AppHandle) { + offer_update(&handle); +} + /// A menu item chosen, from the tray or from the title bar's menu: the same ids in both. fn on_menu(h: &AppHandle, id: &str) { match id { @@ -442,6 +589,7 @@ fn on_menu(h: &AppHandle, id: &str) { .show(|_| {}); } } + "update" => offer_update(h), "reload" => { if let Some(c) = content(h) { if let Ok(url) = c.url() { @@ -479,6 +627,10 @@ fn titlebar_menu(window: Window) -> Result<(), String> { true, )?; let link = m("link", "Link this computer…", true)?; + let update = match app.update.lock().unwrap().clone() { + Some(v) => m("update", &format!("Install LLeMbas {v}…"), true)?, + None => m("update", "Check for updates…", true)?, + }; let reload = m("reload", "Reload", true)?; let server = m("server", "Change server…", true)?; let quit = m("quit", "Quit LLeMbas", true)?; @@ -490,6 +642,7 @@ fn titlebar_menu(window: Window) -> Result<(), String> { &link, &PredefinedMenuItem::separator(h)?, &reload, + &update, &server, &PredefinedMenuItem::separator(h)?, &quit, @@ -518,12 +671,14 @@ pub fn run() { .plugin(tauri_plugin_single_instance::init(|h, _argv, _cwd| show(h))) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ get_status, set_server, set_agent, link_computer, - titlebar_menu + titlebar_menu, + update_app ]) .on_menu_event(|h, ev| on_menu(h, ev.id().as_ref())) .setup(|app| { @@ -547,10 +702,12 @@ pub fn run() { agent, tray: Mutex::new(None), linking: Mutex::new(false), + update: Mutex::new(None), }); let items = build_tray(&handle)?; *handle.state::().tray.lock().unwrap() = Some(items); open_main(&handle, loaded.server.as_deref())?; + watch_updates(handle.clone()); if let (true, Some(server)) = (loaded.agent, loaded.server) { handle.state::().agent.start(server); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b0d0688..707a08c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -47,5 +47,14 @@ ] } } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgMzNBNUJCMkNCQTBCOTEwRgpSV1FQa1F1NkxMdWxNOCtnT2NMQU5QOTBHZ3hUaC9UNHVFZThUR2lqOGdWWUVObi9mbU00amoyago=", + "endpoints": [ + "https://llembas.eu/desktop/latest.json" + ], + "requireSignedVersion": true + } } } diff --git a/ui/titlebar.css b/ui/titlebar.css index c1ad606..2f5bf64 100644 --- a/ui/titlebar.css +++ b/ui/titlebar.css @@ -15,8 +15,8 @@ html, body { .bar { display: grid; - /* leaf · name · host (takes the rest, and is the drag area) · agent · menu · sep · 3 controls */ - grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto var(--titlebar-h) 1px repeat(3, 2.875rem); + /* leaf · name · host (takes the rest, and is the drag area) · update · agent · menu · sep · 3 controls */ + grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) 1px repeat(3, 2.875rem); align-items: center; height: var(--titlebar-h); border-bottom: 1px solid var(--border); @@ -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="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__btn { display: grid; place-items: center; } diff --git a/ui/titlebar.html b/ui/titlebar.html index 5923777..233d482 100644 --- a/ui/titlebar.html +++ b/ui/titlebar.html @@ -14,6 +14,7 @@ LLeMbas + diff --git a/ui/titlebar.js b/ui/titlebar.js index d0deac2..46e07e3 100644 --- a/ui/titlebar.js +++ b/ui/titlebar.js @@ -29,6 +29,9 @@ function render(s) { $("agent").dataset.state = state $("agent-text").textContent = WORDS[state] ?? state $("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent" + $("update").hidden = !s.update + $("update").textContent = s.update ? `update ${s.update}` : "update" + $("update").title = s.update ? `LLeMbas ${s.update} is available (this is ${s.version})` : "" } function maximized(on) { @@ -41,6 +44,7 @@ function maximized(on) { $("minimize").addEventListener("click", () => win.minimize()) $("maximize").addEventListener("click", () => win.toggleMaximize()) $("close").addEventListener("click", () => win.close()) +$("update").addEventListener("click", () => invoke("update_app")) $("menu").addEventListener("click", () => invoke("titlebar_menu")) $("agent").addEventListener("click", () => invoke("titlebar_menu"))