diff --git a/README.md b/README.md index 3142984..58d9881 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,17 @@ XWIN_ACCEPT_LICENSE=1 scripts/windows.sh `src-tauri/Cargo.toml`, tag the commit (`git tag v && git push origin v`), then: ```bash -GITEA_TOKEN= scripts/gitea-release.sh +GITEA_TOKEN= TAURI_SIGNING_PRIVATE_KEY_PATH= scripts/gitea-release.sh ``` It creates the release for the tag and uploads the installers, the browser helpers and a `SHA256SUMS` file. +**Offer it as an update (Windows):** the script also signs the installer with the updater key, uploads the +signature, and writes `updater/latest.json`. Sign the installer with the code-signing certificate first, because +the updater signature covers the final file. Installed copies are offered the update once that file is committed +and pushed to main; the script prints the commands. Keep the updater key and its password offline and backed up: +the app only accepts updates signed with it, so losing it means existing installs can't update. + ## How the browser connection works ``` @@ -84,6 +90,14 @@ browser extension ─(native messaging)─▶ kestrove-nmh ─(local connection) - The same code runs inside the Mac app; it lives in `kestrove-core` (`kv-ffi`'s browser bridge, `kv-ipc`, `kv-nmh`). +## Updates + +On Windows, Kestrove checks for a new version a minute after it starts and once a day. The check is a plain request +for `updater/latest.json` on the Kestrove Gitea: it reveals this computer's IP address and app version, and nothing +from your vault. A new version appears as a banner and installs only when you choose **Update and restart**. The app +verifies the download's signature before running it. Turn checks off in **Settings → Updates**. On Linux, updates +come through your package manager. + ## Where things are kept The vault and the app's files are in your user's app-data folder (`vault.sqlite`, `pairings.json`, diff --git a/scripts/gitea-release.sh b/scripts/gitea-release.sh index d9240cf..d493744 100755 --- a/scripts/gitea-release.sh +++ b/scripts/gitea-release.sh @@ -1,10 +1,17 @@ #!/bin/sh -# Creates (or reuses) the Gitea release for this version and uploads the desktop bundles to it. +# Creates (or reuses) the Gitea release for this version, uploads the desktop bundles to it, and writes +# updater/latest.json, which the Windows app checks for updates (src-tauri/src/updates.rs). # -# GITEA_TOKEN=… scripts/gitea-release.sh [dir-with-files] (default ../release/desktop) +# GITEA_TOKEN=… TAURI_SIGNING_PRIVATE_KEY_PATH=… scripts/gitea-release.sh [dir-with-files] (default ../release/desktop) # # GITEA_TOKEN: a Gitea access token with the write:repository scope (Settings → Applications). The tag # v must already be pushed. Files: *.AppImage, *.deb, *.rpm, *-setup.exe, kestrove-nmh-*, SHA256SUMS. +# +# Updates: the Windows installer needs an updater signature (.sig) from the Kestrove updater key, whose +# public half is in src-tauri/tauri.conf.json. Sign after Authenticode signing: the signature covers the final +# file. With TAURI_SIGNING_PRIVATE_KEY_PATH set, a missing .sig is made here (the key's password is asked for, or +# read from TAURI_SIGNING_PRIVATE_KEY_PASSWORD). Optional NOTES: the text shown with the update. +# Users get the update only once updater/latest.json is committed and pushed to main (printed at the end). set -eu cd "$(dirname "$0")/.." : "${GITEA_TOKEN:?set GITEA_TOKEN (Gitea → Settings → Applications → token with write:repository)}" @@ -14,6 +21,15 @@ DIR=${1:-../release/desktop} V=$(node -p 'require("./package.json").version') TAG="v$V" AUTH="Authorization: token $GITEA_TOKEN" +SETUP="$DIR/Kestrove_${V}_x64-setup.exe" + +if [ -f "$SETUP" ] && [ ! -f "$SETUP.sig" ]; then + : "${TAURI_SIGNING_PRIVATE_KEY_PATH:?$SETUP.sig is missing: set TAURI_SIGNING_PRIVATE_KEY_PATH to the updater key to sign it}" + # --app-version binds the signature to this version (the app requires it), so an older signed installer can't + # be offered again under a newer version number. + npx tauri signer sign -f "$TAURI_SIGNING_PRIVATE_KEY_PATH" ${TAURI_SIGNING_PRIVATE_KEY_PASSWORD+-p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD"} \ + --app-version "$V" "$SETUP" +fi (cd "$DIR" && rm -f SHA256SUMS && shasum -a 256 -- *.AppImage *.deb *.rpm *-setup.exe kestrove-nmh-* 2>/dev/null > SHA256SUMS; cat SHA256SUMS) @@ -22,9 +38,23 @@ if [ -z "$ID" ]; then BODY=$(node -e 'console.log(JSON.stringify({tag_name: process.argv[1], name: "Kestrove " + process.argv[2], body: "Kestrove desktop " + process.argv[2] + " for Windows and Linux. On a Mac, use the Kestrove app from the Mac App Store instead.\n\nVerify downloads with SHA256SUMS.", draft: false, prerelease: false}))' "$TAG" "$V") ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" -d "$BODY" "$API/repos/$REPO/releases" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).id))') fi -for f in "$DIR"/*.AppImage "$DIR"/*.deb "$DIR"/*.rpm "$DIR"/*-setup.exe "$DIR"/kestrove-nmh-* "$DIR"/SHA256SUMS; do +for f in "$DIR"/*.AppImage "$DIR"/*.deb "$DIR"/*.rpm "$DIR"/*-setup.exe "$DIR"/*-setup.exe.sig "$DIR"/kestrove-nmh-* "$DIR"/SHA256SUMS; do [ -f "$f" ] || continue echo "uploading $(basename "$f")" curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/repos/$REPO/releases/$ID/assets?name=$(basename "$f")" >/dev/null done echo "Release: ${API%/api/v1}/$REPO/releases/tag/$TAG" + +# The update manifest, written only after the installer it points to is uploaded. +if [ -f "$SETUP.sig" ]; then + mkdir -p updater + node -e ' + const [v, url, sigFile, notes] = process.argv.slice(1); + const signature = require("fs").readFileSync(sigFile, "utf8").trim(); + const doc = { version: v, notes, pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"), + platforms: { "windows-x86_64": { signature, url } } }; + require("fs").writeFileSync("updater/latest.json", JSON.stringify(doc, null, 2) + "\n"); + ' "$V" "${API%/api/v1}/$REPO/releases/download/$TAG/$(basename "$SETUP")" "$SETUP.sig" "${NOTES:-Kestrove $V}" + echo "Wrote updater/latest.json. To offer the update, commit it and push to main:" + echo " git add updater/latest.json && git commit -m \"Offer $V as an update\" && git push origin main" +fi diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d64c2aa..0374606 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -963,6 +963,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -986,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -999,7 +1009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1698,6 +1708,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" @@ -2628,9 +2648,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2949,6 +2971,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" @@ -3017,7 +3069,7 @@ dependencies = [ "arboard", "base64 0.22.1", "chacha20poly1305", - "core-foundation", + "core-foundation 0.10.1", "getrandom 0.2.17", "hayro", "hkdf 0.12.4", @@ -3035,6 +3087,7 @@ dependencies = [ "sha2 0.10.9", "tauri", "tauri-build", + "tauri-plugin-updater", "tempfile", "tokio", "url", @@ -3388,6 +3441,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[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" @@ -3743,6 +3802,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3758,6 +3818,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" @@ -3849,6 +3921,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + [[package]] name = "option-ext" version = "0.2.0" @@ -3865,6 +3943,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 = "p256" version = "0.13.2" @@ -4513,15 +4605,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", @@ -4624,6 +4721,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -4633,6 +4742,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", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "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.16" @@ -4665,6 +4801,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -4782,7 +4927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5063,6 +5208,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" @@ -5292,6 +5453,27 @@ dependencies = [ "syn 3.0.9", ] +[[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" @@ -5313,7 +5495,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -5323,7 +5505,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -5356,6 +5538,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" @@ -5379,7 +5572,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -5477,6 +5670,56 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b" +dependencies = [ + "base64 0.23.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "libc", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.5", + "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.2" @@ -5487,7 +5730,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -5510,7 +5753,7 @@ checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -5584,7 +5827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -6461,6 +6704,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.10" @@ -6662,6 +6914,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" @@ -6927,7 +7190,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -7003,6 +7266,16 @@ dependencies = [ "zeroize", ] +[[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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3915e75..70e33d3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,6 +73,8 @@ windows-sys = { version = "0.61", features = [ "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", ] } +# In-app updates (updates.rs): signed installers listed in latest.json on the Kestrove Gitea. +tauri-plugin-updater = "2" # Windows Hello (KeyCredentialManager); the same version Tauri already pulls in. windows = { version = "0.62", features = ["Foundation", "Security_Credentials", "Security_Cryptography", "Storage_Streams"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1fd74b9..89c807e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ pub mod hello; pub mod qr; pub mod sync; pub mod title_bar; +pub mod updates; pub use kv_ffi::bridge::{broker, channel, passkey, words}; pub use kv_ipc::{framing, ipc, private_file}; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5a1bcfa..fecf2e5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -17,6 +17,7 @@ use kestrove_desktop::sync::{ self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService, SyncStatus, }; +use kestrove_desktop::updates::{UpdateInfo, Updates}; use kestrove_desktop::{ipc, private_file}; use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore}; use serde::{Deserialize, Serialize}; @@ -37,6 +38,7 @@ struct App { idle_minutes: Arc, /// Windows Hello is set up on this computer (checked off the main thread; see `refresh_hello`). hello_available: Arc, + updates: Arc, // Kept alive: on X11 the clipboard owner must outlive the copy. clipboard: Mutex>, } @@ -46,6 +48,15 @@ impl App { self.data_dir.join("settings.json") } + fn save_settings(&self) -> CmdResult<()> { + let s = Settings { + idle_minutes: self.idle_minutes.load(Ordering::Relaxed), + check_updates: self.updates.automatic(), + }; + let bytes = serde_json::to_vec(&s).map_err(|e| e.to_string())?; + private_file::write(&self.settings_path(), &bytes).map_err(|e| e.to_string()) + } + /// Re-checks Windows Hello in the background (it can be set up or removed while Kestrove runs). fn refresh_hello(&self) { let available = self.hello_available.clone(); @@ -58,6 +69,13 @@ impl App { #[derive(Serialize, Deserialize)] struct Settings { idle_minutes: u64, + /// Check for a new version once a day (Windows; see `updates`). + #[serde(default = "yes")] + check_updates: bool, +} + +fn yes() -> bool { + true } #[derive(Serialize)] @@ -280,8 +298,32 @@ fn touch(app: State<'_, App>) { fn set_idle_minutes(app: State<'_, App>, minutes: u64) -> CmdResult<()> { let minutes = minutes.clamp(1, 240); app.idle_minutes.store(minutes, Ordering::Relaxed); - let bytes = serde_json::to_vec(&Settings { idle_minutes: minutes }).map_err(|e| e.to_string())?; - private_file::write(&app.settings_path(), &bytes).map_err(|e| e.to_string()) + app.save_settings() +} + +// ---------------------------------------------------------------- updates (Windows; see `updates`) + +#[tauri::command] +fn update_info(app: State<'_, App>, handle: AppHandle) -> UpdateInfo { + app.updates.info(&handle) +} + +#[tauri::command] +async fn check_update(app: State<'_, App>, handle: AppHandle) -> CmdResult { + Ok(app.updates.check(&handle).await) +} + +#[tauri::command] +fn set_update_checks(app: State<'_, App>, enabled: bool) -> CmdResult<()> { + app.updates.set_automatic(enabled); + app.save_settings() +} + +/// "Update and restart": returns only if it failed; on success the installer replaces and restarts the app. +#[tauri::command] +async fn install_update(app: State<'_, App>, handle: AppHandle) -> CmdResult<()> { + let core = app.core.clone(); + app.updates.install(&handle, move || core.lock()).await } #[tauri::command] @@ -687,12 +729,14 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() }); let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?); - let idle_minutes = private_file::read(&data_dir.join("settings.json")) + let settings = private_file::read(&data_dir.join("settings.json")) .ok() .flatten() - .and_then(|b| serde_json::from_slice::(&b).ok()) - .map_or(autolock::DEFAULT_IDLE.as_secs() / 60, |s| s.idle_minutes); + .and_then(|b| serde_json::from_slice::(&b).ok()); + let idle_minutes = settings.as_ref().map_or(autolock::DEFAULT_IDLE.as_secs() / 60, |s| s.idle_minutes); let idle_minutes = Arc::new(AtomicU64::new(idle_minutes)); + let updates = Updates::new(settings.as_ref().is_none_or(|s| s.check_updates)); + updates.clone().spawn(app.handle().clone()); let ipc_error = start_ipc(broker.clone()).err().map(|e| { eprintln!("kestrove: browser integration disabled: {e}"); @@ -762,6 +806,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { ipc_error: Mutex::new(ipc_error), idle_minutes, hello_available: Arc::new(AtomicBool::new(false)), + updates, clipboard: Mutex::new(None), }); app.state::().refresh_hello(); @@ -769,7 +814,10 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { } fn main() { - tauri::Builder::default() + let builder = tauri::Builder::default(); + #[cfg(windows)] + let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + builder .setup(setup) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { @@ -826,7 +874,11 @@ fn main() { cancel_add_device, approve_join, send_join, - kit_qr + kit_qr, + update_info, + check_update, + set_update_checks, + install_update ]) .run(tauri::generate_context!()) .expect("error while running Kestrove"); diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 0000000..142db44 --- /dev/null +++ b/src-tauri/src/updates.rs @@ -0,0 +1,169 @@ +//! In-app updates on Windows. The Microsoft Store doesn't update EXE installs, so the app does it itself. +//! +//! Tauri's updater fetches `latest.json` from the first address in tauri.conf.json → plugins.updater.endpoints +//! that answers (the Kestrove Gitea; a mirror can be added after it), and offers the newer version. Nothing is +//! downloaded until the user chooses "Update and restart". The installer must carry a signature from the +//! Kestrove updater key, whose public half is in tauri.conf.json, so a compromised download server can hold +//! updates back but can't ship one. +//! +//! The check is a plain HTTPS request for that file: it tells the server this computer's IP address and the +//! app version, nothing from the vault. Settings can turn automatic checks off. Linux packages are updated by +//! their package manager, so there `supported` is false and nothing is checked. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +/// After startup, so the check never competes with unlocking and the first sync. +const FIRST_CHECK: Duration = Duration::from_secs(60); +const EVERY: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(Debug, Clone, Default, Serialize)] +pub struct UpdateInfo { + /// This build can update itself (Windows). + pub supported: bool, + pub current: String, + /// A newer version, ready to install. + pub available: Option, + pub notes: Option, + /// Checks once a day on its own. + pub automatic: bool, + pub checked_at: Option, + pub last_error: Option, +} + +#[derive(Default)] +struct State { + #[cfg(windows)] + pending: Option, + available: Option<(String, Option)>, + checked_at: Option, + last_error: Option, +} + +pub struct Updates { + automatic: AtomicBool, + state: Mutex, +} + +impl Updates { + pub fn new(automatic: bool) -> Arc { + Arc::new(Self { automatic: AtomicBool::new(automatic), state: Mutex::default() }) + } + + pub fn automatic(&self) -> bool { + self.automatic.load(Ordering::Relaxed) + } + + pub fn set_automatic(&self, on: bool) { + self.automatic.store(on, Ordering::Relaxed); + } + + pub fn info(&self, app: &AppHandle) -> UpdateInfo { + let s = self.state.lock().unwrap_or_else(|e| e.into_inner()); + UpdateInfo { + supported: cfg!(windows), + current: app.package_info().version.to_string(), + available: s.available.as_ref().map(|(v, _)| v.clone()), + notes: s.available.as_ref().and_then(|(_, n)| n.clone()), + automatic: self.automatic(), + checked_at: s.checked_at, + last_error: s.last_error.clone(), + } + } + + /// Asks the update server now. Emits `update-available` when there is a newer version. + pub async fn check(&self, app: &AppHandle) -> UpdateInfo { + #[cfg(windows)] + { + use tauri_plugin_updater::UpdaterExt; + let result = match app.updater() { + Ok(u) => u.check().await, + Err(e) => Err(e), + }; + let found = { + let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner()); + s.checked_at = Some(now()); + match result { + Ok(Some(update)) => { + s.available = Some((update.version.clone(), update.body.clone())); + s.pending = Some(update); + s.last_error = None; + true + } + Ok(None) => { + (s.available, s.pending, s.last_error) = (None, None, None); + false + } + // Offline or the server is down: keep what an earlier check found. + Err(e) => { + s.last_error = Some(format!("Couldn't check for updates: {e}")); + false + } + } + }; + let info = self.info(app); + if found { + let _ = app.emit("update-available", &info); + } + info + } + #[cfg(not(windows))] + self.info(app) + } + + /// Downloads the update the last check found, checks its signature and starts the installer. On success the + /// app exits (after `before_exit`) and the installer starts the new version, so this returns only on failure. + pub async fn install(&self, app: &AppHandle, before_exit: impl Fn() + Send + Sync + 'static) -> Result<(), String> { + #[cfg(windows)] + { + use tauri_plugin_updater::UpdaterExt; + let pending = self.state.lock().unwrap_or_else(|e| e.into_inner()).pending.clone(); + let Some(update) = pending else { return Err("No update to install. Check again.".into()) }; + // The installer must be signed by the updater key; the builder reads the key and endpoints from + // tauri.conf.json, plus the hook that locks the vault before the installer takes over. + let updater = app.updater_builder().on_before_exit(before_exit).build().map_err(|e| e.to_string())?; + let update = updater.check().await.map_err(|e| e.to_string())?.filter(|u| u.version == update.version); + let update = update.ok_or("That update is no longer offered. Check again.")?; + let (handle, mut done) = (app.clone(), 0u64); + update + .download_and_install( + move |chunk, total| { + done += chunk as u64; + let _ = handle.emit("update-progress", (done, total)); + }, + || {}, + ) + .await + .map_err(|e| format!("The update couldn't be installed: {e}")) + } + #[cfg(not(windows))] + { + let _ = (app, before_exit); + Err("Install updates with your package manager.".into()) + } + } + + /// Checks a minute after startup, then once a day, while automatic checks are on. + pub fn spawn(self: Arc, app: AppHandle) { + if !cfg!(windows) { + return; + } + tauri::async_runtime::spawn(async move { + tokio::time::sleep(FIRST_CHECK).await; + loop { + if self.automatic() { + self.check(&app).await; + } + tokio::time::sleep(EVERY).await; + } + }); + } +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2fe44df..c3aa004 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -47,5 +47,17 @@ "installMode": "currentUser" } } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZFRUM4RDQ2Mzk0Rjg5Q0IKUldUTGlVODVSbzNzYml1ZTN3d0pBN1IwWkRoZXNROTRmbk5WOE5CR2VJQVYrTUJLN21PNzI4eHEK", + "requireSignedVersion": true, + "endpoints": [ + "https://gitea.samialabri.com/kestrove/kestrove-desktop/raw/branch/main/updater/latest.json" + ], + "windows": { + "installMode": "passive" + } + } } } diff --git a/src/main.ts b/src/main.ts index 717c331..db09ca6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { } from "./ui/kv"; interface HelloStatus { available: boolean; enabled: boolean; offered: boolean } +interface UpdateInfo { supported: boolean; current: string; available: string | null; notes: string | null; automatic: boolean; checked_at: number | null; last_error: string | null } interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number; hello: HelloStatus } interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean; tags: string[] } interface SignInView { provider: string; provider_name: string; account: string | null } @@ -686,6 +687,7 @@ function showSync(s: SyncStatus, detail?: HTMLElement, state?: AppState) { if (s.restored_items > 0) { parts.push(banner("info", `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} came back`, "They were deleted on another device but edited here.")); } + if (update?.available) parts.unshift(updateBanner(update.available)); const now = iconButton("sync", "Sync now", async () => { now.disabled = true; await invoke("sync_now").catch(() => undefined); @@ -920,6 +922,7 @@ async function renderSettings(target: HTMLElement, state: AppState) { section("Devices", devicesSection), section("Emergency Kit", kitSection()), section("Paired browsers", list), + updatesSection(await invoke("update_info"), () => renderSettings(target, state)), )); const status = await invoke("sync_status"); renderSyncSettings(syncSection, status, () => renderSettings(target, state)); @@ -1271,8 +1274,64 @@ function saveCard(s: SaveView): HTMLElement { h("div", "actions end", no, yes)); } +// ------------------------------------------------------------------ updates (Windows; src-tauri/src/updates.rs) + +let update: UpdateInfo | null = null; +/// "Downloading… 40%" while an update downloads; the installer then closes the app. +let updateStatus: HTMLElement | null = null; + +function updateBanner(version: string): HTMLElement { + updateStatus = h("span", "kv-caption kv-muted"); + const go = button("Update and restart", "text", async () => { + go.disabled = true; + updateStatus?.replaceChildren("Downloading…"); + try { + await invoke("install_update"); + } catch (e) { + updateStatus?.replaceChildren(String(e)); + go.disabled = false; + } + }); + return banner("info", `Kestrove ${version} is available`, updateStatus, go); +} + +async function refreshUpdate(info?: UpdateInfo) { + update = info ?? (await invoke("update_info").catch(() => null)); + if (syncBox?.isConnected) showSync(await invoke("sync_status")); +} + +function updatesSection(info: UpdateInfo, reload: () => Promise): HTMLElement | null { + if (!info.supported) return null; + const auto = input({ type: "checkbox", checked: info.automatic }); + auto.className = "switch"; + auto.setAttribute("aria-label", "Check for updates automatically"); + auto.onchange = () => void invoke("set_update_checks", { enabled: auto.checked }); + const status = info.available + ? `Kestrove ${info.available} is available.` + : info.last_error ?? (info.checked_at ? `Up to date. Checked ${ago(info.checked_at)}.` : "Not checked yet."); + const check = button("Check now", "quiet", async () => { + check.disabled = true; + await refreshUpdate(await invoke("check_update")); + await reload(); + }); + const rows = h("div", "kv-card rows", + h("div", "kv-frow", h("div", "val", h("span", "", `Kestrove ${info.current}`), + h("span", `kv-hint ${info.last_error && !info.available ? "kv-error-text" : ""}`, status)), check), + h("div", "kv-frow", h("div", "val", h("span", "", "Check for updates automatically"), + h("span", "kv-hint", "Once a day, Kestrove asks its update server for the latest version. Nothing from your vault is sent. Updates install only when you choose.")), auto)); + if (info.available) rows.append(h("div", "kv-frow", updateBanner(info.available))); + return section("Updates", rows); +} + // ------------------------------------------------------------------ wiring +void refreshUpdate(); +void listen("update-available", (e) => void refreshUpdate(e.payload)); +void listen<[number, number | null]>("update-progress", (e) => { + const [done, total] = e.payload; + updateStatus?.replaceChildren(total ? `Downloading… ${Math.floor((done / total) * 100)}%` : "Downloading…"); +}); + void listen("pairing-request", () => render()); void listen("save-request", () => render()); void listen("passkey-request", () => render());