From f277cfaa6698457204955a8219c1153c81fee82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81rgio=20Santos?= Date: Thu, 5 Nov 2026 23:16:43 +0000 Subject: [PATCH 1/2] feat: check for updates and install them in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tskdeck looks for a newer release one minute after launch and then once a day, through tauri-plugin-updater. A card at the foot of the sidebar installs the release and relaunches the app, or hides it with Later until the app quits. Check for Updates… is in the app menu on macOS and in the command palette. Settings → General has the automatic check setting and a Check Now button. The release workflow signs the update files and uploads latest.json. It stops when the signing secrets are not set. --- .github/workflows/release.yml | 30 ++- CHANGELOG.md | 6 + README.md | 6 +- src-tauri/Cargo.lock | 322 ++++++++++++++++++++++++++- src-tauri/Cargo.toml | 4 + src-tauri/src/commands.rs | 36 ++- src-tauri/src/lib.rs | 23 +- src-tauri/src/menu.rs | 35 ++- src-tauri/src/settings.rs | 19 +- src-tauri/src/updates.rs | 280 +++++++++++++++++++++++ src-tauri/src/windows.rs | 10 +- src-tauri/tauri.conf.json | 11 +- src-tauri/tests/updates.rs | 171 ++++++++++++++ src/api/client.test.ts | 2 +- src/api/client.ts | 24 ++ src/api/types.ts | 21 ++ src/components/KeymapEditor.test.tsx | 6 +- src/components/Settings.test.tsx | 50 ++++- src/components/Settings.tsx | 13 +- src/components/Shell.test.tsx | 97 +++++++- src/components/Shell.tsx | 3 + src/components/Sidebar.tsx | 2 + src/dev/mockTauri.ts | 9 +- src/keys/actions.ts | 1 + src/keys/keymap.test.ts | 2 + src/keys/keymap.ts | 1 + src/state/search.test.ts | 2 +- src/state/store.test.ts | 4 +- src/state/store.ts | 2 +- src/state/updates.ts | 22 ++ src/styles.css | 34 +++ src/updates/UpdateCard.tsx | 31 +++ src/updates/UpdateCheck.tsx | 61 +++++ src/updates/checks.ts | 42 ++++ src/updates/useInstallUpdate.ts | 50 +++++ 35 files changed, 1388 insertions(+), 44 deletions(-) create mode 100644 src-tauri/src/updates.rs create mode 100644 src-tauri/tests/updates.rs create mode 100644 src/state/updates.ts create mode 100644 src/updates/UpdateCard.tsx create mode 100644 src/updates/UpdateCheck.tsx create mode 100644 src/updates/checks.ts create mode 100644 src/updates/useInstallUpdate.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2547b61..684aff9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,19 +9,37 @@ permissions: contents: write jobs: + preflight: + runs-on: ubuntu-latest + steps: + - name: Check that the updater signing secrets are set + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + missing=0 + for name in TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do + if [ -z "${!name}" ]; then + echo "::error::The repository secret $name is not set; the updater artifacts cannot be signed." + missing=1 + fi + done + exit "$missing" + release: + needs: preflight strategy: fail-fast: false matrix: include: - platform: macos-latest - args: --target aarch64-apple-darwin + args: --target aarch64-apple-darwin --config '{"bundle":{"createUpdaterArtifacts":true}}' - platform: macos-latest - args: --target x86_64-apple-darwin + args: --target x86_64-apple-darwin --config '{"bundle":{"createUpdaterArtifacts":true}}' - platform: ubuntu-22.04 - args: '' + args: --config '{"bundle":{"createUpdaterArtifacts":true}}' - platform: windows-latest - args: '' + args: --config '{"bundle":{"createUpdaterArtifacts":true}}' runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 @@ -37,12 +55,16 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - run: npm ci + # Also signs the update files and merges this platform's entries into the release's latest.json. - uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} with: tagName: __VERSION__ releaseName: 'tskdeck __VERSION__' releaseDraft: true prerelease: false + uploadUpdaterJson: true args: ${{ matrix.args }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a607dbc..4216bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Update checks: tskdeck looks for a newer release one minute after launch and then once a day, and a card at the foot of the sidebar installs it and relaunches the app, or hides it with Later until the app quits. +- Check for Updates… in the app menu on macOS and in the command palette, and an Updates setting with Check Now in Settings → General. +- Signed update files and a `latest.json` manifest on each GitHub release. + ## [1.0.1] - 2026-11-05 ### Changed diff --git a/README.md b/README.md index f1a590b..cd8cf3a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ Download the installer for your platform from the [Releases](https://github.com/ Releases are unsigned. +### Updates + +tskdeck looks for a newer release one minute after launch, and then once a day. When it finds one, a card at the foot of the sidebar shows **Install and Relaunch** and **Later**. To check at once, choose **Check for Updates…** in the app menu on macOS, or in the command palette. To stop the automatic checks, clear **Check for updates automatically** in Settings → General. + ### Opening the unsigned app on macOS Move `tskdeck.app` to `/Applications`, then either right-click it and choose Open, or remove the quarantine flag: @@ -56,7 +60,7 @@ xattr -dr com.apple.quarantine /Applications/tskdeck.app ## Settings -tskdeck keeps its settings (open-task mode, keymap, path to `tsk`) in `settings.json` inside the app config directory for the identifier `com.s3rgiosan.tskdeck`: +tskdeck keeps its settings (open-task mode, keymap, path to `tsk`, update checks) in `settings.json` inside the app config directory for the identifier `com.s3rgiosan.tskdeck`: - macOS: `~/Library/Application Support/com.s3rgiosan.tskdeck/settings.json` - Linux: `$XDG_CONFIG_HOME/com.s3rgiosan.tskdeck/settings.json`, or `~/.config/com.s3rgiosan.tskdeck/settings.json` when `XDG_CONFIG_HOME` is unset diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6b285ac..ecbf59e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -495,6 +495,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" @@ -518,9 +528,9 @@ 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", + "foreign-types 0.5.0", "libc", ] @@ -531,7 +541,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", ] @@ -960,6 +970,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.15" @@ -989,6 +1009,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -996,7 +1025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1010,6 +1039,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1493,6 +1528,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -1512,9 +1563,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2045,6 +2098,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" @@ -2098,6 +2157,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2361,6 +2437,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" @@ -2435,6 +2523,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "openssl-sys" +version = "0.9.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2451,6 +2582,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" @@ -2843,15 +2988,19 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-tls", "hyper-util", "js-sys", "log", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-util", "tower", "tower-http", @@ -2917,6 +3066,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2932,6 +3090,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" @@ -2989,6 +3156,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.38.0" @@ -3391,6 +3581,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" @@ -3412,7 +3623,7 @@ checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3455,6 +3666,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" @@ -3656,6 +3878,38 @@ dependencies = [ "zbus", ] +[[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", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.61.2", +] + [[package]] name = "tauri-plugin-window-state" version = "2.5.0" @@ -3889,9 +4143,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -4109,6 +4385,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" name = "tskdeck" version = "1.0.1" dependencies = [ + "async-trait", "dirs", "notify", "notify-debouncer-mini", @@ -4119,9 +4396,11 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-updater", "tauri-plugin-window-state", "tempfile", "thiserror 2.0.21", + "tokio", "which", ] @@ -4203,6 +4482,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4587,6 +4872,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" @@ -4802,6 +5098,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.4" @@ -4916,6 +5222,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6f93e3a..978d09c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,12 +29,16 @@ which = "8" dirs = "7" tauri-plugin-window-state = "2" tauri-plugin-dialog = "2" +tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls", "system-proxy"] } +async-trait = "0.1" +tokio = { version = "1", features = ["time"] } [target.'cfg(target_os = "macos")'.dependencies] objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSAccessibility", "NSWorkspace"] } [dev-dependencies] tempfile = "3" +tokio = { version = "1", features = ["macros", "rt", "test-util", "time"] } # Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 08fdde8..e4b70ef 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use std::path::PathBuf; -use std::sync::{Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State}; @@ -13,6 +13,7 @@ use crate::tsk::locate; use crate::tsk::model::{AddResult, TaskDetail, TaskRow}; use crate::tsk::runner::{Tsk, TskError}; use crate::tsk::version::{MIN_TSK, parse_version}; +use crate::updates::{UPDATE_PROGRESS_EVENT, UpdateInfo, UpdateManager}; use crate::watch::StoreWatcher; /// The event every window receives with the settings just saved. @@ -297,6 +298,39 @@ pub async fn relocate_tsk(app: AppHandle) -> Result<(), String> { .map_err(|e| e.to_string())? } +/// Looks for a newer release now; resolves to it, or to `None` when tskdeck is current. +#[tauri::command] +pub async fn check_for_updates( + updates: State<'_, Arc>, +) -> Result, String> { + updates.check_now().await.map_err(|e| e.to_string()) +} + +/// The release the last successful check found. +#[tauri::command] +pub fn pending_update(updates: State<'_, Arc>) -> Option { + updates.pending() +} + +/// Downloads and installs the pending update, emitting `update-progress` along the way, then +/// relaunches the app. +#[tauri::command] +pub async fn install_update( + app: AppHandle, + updates: State<'_, Arc>, +) -> Result<(), String> { + let emitter = app.clone(); + let progress = move |progress| { + let _ = emitter.emit(UPDATE_PROGRESS_EVENT, progress); + }; + updates + .install(&progress) + .await + .map_err(|e| e.to_string())?; + app.request_restart(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ad7548..fb5d736 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,21 +3,24 @@ pub mod commands; pub mod menu; pub mod settings; pub mod tsk; +pub mod updates; pub mod watch; pub mod windows; use std::path::PathBuf; -use std::sync::{Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use tauri::{Emitter, Manager}; use commands::AppState; +use updates::{UPDATE_AVAILABLE_EVENT, UpdateManager}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) // The main window reopens at its last size and position; settings always opens centred. .plugin( tauri_plugin_window_state::Builder::new() @@ -60,6 +63,21 @@ pub fn run() { settings_path, watcher, }); + + let emitter = app.handle().clone(); + let updates = Arc::new(UpdateManager::new( + Arc::new(updates::PluginSource::new(app.handle().clone())), + Arc::new(move |update| { + let _ = emitter.emit(UPDATE_AVAILABLE_EVENT, update); + }), + )); + let schedule_handle = app.handle().clone(); + tauri::async_runtime::spawn(updates::run_schedule(Arc::clone(&updates), move || { + let state = schedule_handle.state::(); + let settings = state.settings.lock().unwrap_or_else(|e| e.into_inner()); + settings.check_for_updates + })); + app.manage(updates); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -76,6 +94,9 @@ pub fn run() { commands::save_settings, commands::relocate_tsk, commands::open_settings, + commands::check_for_updates, + commands::pending_update, + commands::install_update, chrome::reduce_transparency, menu::menu_state, menu::show_task_menu, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 1712281..8ab9ed7 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -2,13 +2,15 @@ //! handler they all share. use std::collections::HashMap; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use serde::Deserialize; use tauri::menu::{Menu, MenuBuilder, MenuEvent, MenuItemBuilder}; use tauri::{AppHandle, Emitter, LogicalPosition, Manager, State, Window, WindowEvent, Wry}; use tauri_plugin_opener::OpenerExt; +use crate::updates::{UPDATE_CHECK_RESULT_EVENT, UpdateCheckResult, UpdateManager}; + /// The label of the main window. pub const MAIN: &str = "main"; @@ -17,6 +19,8 @@ pub const MAIN: &str = "main"; pub enum Route { Settings, Docs, + /// A check for updates runs now, and its result goes to the main window. + CheckUpdates, /// The focused window's page zoom changes. Zoom(ZoomStep), /// A `menu` event with the item id goes to this window. @@ -212,6 +216,7 @@ pub fn route(id: &str, focused: Option<&str>) -> Option { match id { "settings" => Some(Route::Settings), "docs" => Some(Route::Docs), + CHECK_UPDATES => Some(Route::CheckUpdates), "zoom-reset" => Some(Route::Zoom(ZoomStep::Actual)), "zoom-in" => Some(Route::Zoom(ZoomStep::In)), "zoom-out" => Some(Route::Zoom(ZoomStep::Out)), @@ -396,6 +401,24 @@ pub fn on_window_event(window: &Window, event: &WindowEvent) { } } +/// The app menu item that checks for updates. +pub const CHECK_UPDATES: &str = "check-updates"; + +/// Brings the main window to the front and sends it the result of a check for updates run now. +fn check_for_updates(app: &AppHandle) { + let _ = crate::windows::show_main(app); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let updates = app.state::>().inner().clone(); + let result = updates.check_now().await; + let _ = app.emit_to( + MAIN, + UPDATE_CHECK_RESULT_EVENT, + UpdateCheckResult::from(&result), + ); + }); +} + /// Opens settings or the docs, or sends the item id to the window that handles it. pub fn handle(app: &AppHandle, event: MenuEvent) { let focused = app @@ -414,6 +437,7 @@ pub fn handle(app: &AppHandle, event: MenuEvent) { Some(Route::Docs) => { let _ = app.opener().open_url(DOCS_URL, None::<&str>); } + Some(Route::CheckUpdates) => check_for_updates(app), Some(Route::Zoom(step)) => zoom_focused(app, step), Some(Route::Window(label)) => { // A check item toggles its own mark when chosen; the page's choice sets it again. @@ -441,8 +465,8 @@ mod macos { use tauri::{AppHandle, Manager, Wry}; use super::{ - GROUP_ITEMS, MenuState, SORT_ITEMS, TASK_ITEMS, default_accelerator, drawer_title, - group_id, redo_enabled, sidebar_title, sort_id, undo_item, + CHECK_UPDATES, GROUP_ITEMS, MenuState, SORT_ITEMS, TASK_ITEMS, default_accelerator, + drawer_title, group_id, redo_enabled, sidebar_title, sort_id, undo_item, }; /// The menu bar items whose title, check mark, key equivalent or enabled state follows the @@ -523,6 +547,7 @@ mod macos { let mut keyed_items = HashMap::new(); let app_menu = SubmenuBuilder::new(app, package.name.clone()) .about(Some(about)) + .item(&MenuItemBuilder::with_id(CHECK_UPDATES, "Check for Updates…").build(app)?) .separator() .item(&keyed(app, &mut keyed_items, "settings", "Settings…", true)?) .separator() @@ -713,6 +738,10 @@ mod tests { fn settings_and_docs_are_handled_here() { assert_eq!(route("settings", Some(MAIN)), Some(Route::Settings)); assert_eq!(route("docs", None), Some(Route::Docs)); + assert_eq!( + route(CHECK_UPDATES, Some("settings")), + Some(Route::CheckUpdates) + ); assert_eq!(route("copy", Some(MAIN)), None); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index db78b5a..e37900c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -17,7 +17,7 @@ pub enum OpenMode { Beside, } -#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(rename_all = "camelCase", default)] pub struct Settings { pub open_mode: OpenMode, @@ -26,6 +26,21 @@ pub struct Settings { pub sidebar_hidden: bool, /// Names given to projects in tskdeck, keyed by the project's full path. pub project_names: BTreeMap, + /// Whether tskdeck looks for a newer release after launch and once a day. + pub check_for_updates: bool, +} + +impl Default for Settings { + fn default() -> Settings { + Settings { + open_mode: OpenMode::default(), + keymap: BTreeMap::new(), + tsk_path: None, + sidebar_hidden: false, + project_names: BTreeMap::new(), + check_for_updates: true, + } + } } /// A missing file gives the defaults. An unreadable or invalid file is moved to @@ -94,6 +109,7 @@ mod tests { tsk_path: Some(PathBuf::from("/example/bin/tsk")), sidebar_hidden: true, project_names, + check_for_updates: false, }; save(&path, &settings).unwrap(); assert_eq!(load(&path), settings); @@ -164,6 +180,7 @@ mod tests { assert!(loaded.keymap.is_empty()); assert!(!loaded.sidebar_hidden); assert!(loaded.project_names.is_empty()); + assert!(loaded.check_for_updates); } #[test] diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 0000000..b18108c --- /dev/null +++ b/src-tauri/src/updates.rs @@ -0,0 +1,280 @@ +//! App updates: the scheduled check, the update found, and its download and install. +//! +//! The update manifest and the update archive come from the GitHub release assets named in +//! `plugins.updater.endpoints` in `tauri.conf.json`. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Emitted while an update downloads, with an `UpdateProgress` payload. +pub const UPDATE_PROGRESS_EVENT: &str = "update-progress"; +/// Emitted to every window after each successful check, with the `UpdateInfo` found or `null`. +pub const UPDATE_AVAILABLE_EVENT: &str = "update-available"; +/// Emitted to the main window after a check started from the app menu, with an `UpdateCheckResult`. +pub const UPDATE_CHECK_RESULT_EVENT: &str = "update-check-result"; + +/// The first scheduled check runs this long after launch. +pub const FIRST_CHECK_DELAY: Duration = Duration::from_secs(60); +/// Scheduled checks after the first are this far apart. +pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// How long a check may take before it fails. +const CHECK_TIMEOUT: Duration = Duration::from_secs(30); +/// How long the update download may take before it fails. +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +/// A newer release than the running app. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdateInfo { + pub version: String, + /// The release notes, when the release has any. + pub notes: Option, +} + +/// Download progress in bytes. `total` is `None` when the server sends no length. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct UpdateProgress { + pub downloaded: u64, + pub total: Option, +} + +/// The outcome of a check started from the app menu: the update found, or why the check failed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UpdateCheckResult { + pub update: Option, + pub error: Option, +} + +impl From<&Result, UpdateError>> for UpdateCheckResult { + fn from(result: &Result, UpdateError>) -> UpdateCheckResult { + match result { + Ok(update) => UpdateCheckResult { + update: update.clone(), + error: None, + }, + Err(e) => UpdateCheckResult { + update: None, + error: Some(e.to_string()), + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum UpdateError { + /// The update server could not be reached or answered with an error. + #[error("{0}")] + Network(String), + /// The manifest or the archive is unusable, or the install failed. + #[error("{0}")] + Internal(String), + #[error("no update is pending")] + NothingPending, + #[error("an update is already being installed")] + AlreadyInstalling, +} + +/// Receives download progress. +pub type ProgressSink<'a> = &'a (dyn Fn(UpdateProgress) + Send + Sync); + +/// Looks for a release newer than the running app. +#[async_trait::async_trait] +pub trait UpdateSource: Send + Sync { + async fn check(&self) -> Result>, UpdateError>; +} + +/// A release found by a check, ready to download and install. +#[async_trait::async_trait] +pub trait PendingUpdate: Send + Sync { + fn info(&self) -> UpdateInfo; + + /// Downloads, verifies and installs the update over the running app. The app must be + /// relaunched afterwards to run it. + async fn download_and_install(&self, progress: ProgressSink<'_>) -> Result<(), UpdateError>; +} + +/// Called with the update found by every successful check, or `None` when the app is current. +pub type UpdateSink = Arc) + Send + Sync>; + +/// Runs checks and keeps the update the last successful check found. +pub struct UpdateManager { + source: Arc, + sink: UpdateSink, + pending: Mutex>>, + installing: AtomicBool, +} + +impl UpdateManager { + pub fn new(source: Arc, sink: UpdateSink) -> UpdateManager { + UpdateManager { + source, + sink, + pending: Mutex::new(None), + installing: AtomicBool::new(false), + } + } + + /// Checks for an update and hands the result to the sink. A failed check keeps the update + /// found before. + pub async fn check_now(&self) -> Result, UpdateError> { + let found = self.source.check().await?; + let info = found.as_ref().map(|update| update.info()); + *self.pending.lock().unwrap() = found; + (self.sink)(info.clone()); + Ok(info) + } + + /// The update the last successful check found. + pub fn pending(&self) -> Option { + self.pending.lock().unwrap().as_ref().map(|u| u.info()) + } + + /// Downloads and installs the pending update, reporting progress to `progress`. + pub async fn install(&self, progress: ProgressSink<'_>) -> Result<(), UpdateError> { + let update = self + .pending + .lock() + .unwrap() + .clone() + .ok_or(UpdateError::NothingPending)?; + let already = self.installing.swap(true, Ordering::SeqCst); + if already { + return Err(UpdateError::AlreadyInstalling); + } + let result = update.download_and_install(progress).await; + self.installing.store(false, Ordering::SeqCst); + result + } +} + +/// Checks `FIRST_CHECK_DELAY` after it starts and every `CHECK_INTERVAL` after that, skipping +/// each check while `enabled` returns false. A failed check is ignored, since being offline is the +/// common cause; the next scheduled check tries again. +pub async fn run_schedule(manager: Arc, enabled: impl Fn() -> bool + Send + Sync) { + tokio::time::sleep(FIRST_CHECK_DELAY).await; + loop { + if enabled() { + let _ = manager.check_now().await; + } + tokio::time::sleep(CHECK_INTERVAL).await; + } +} + +/// Checks and installs through `tauri-plugin-updater`. +pub struct PluginSource { + app: tauri::AppHandle, +} + +impl PluginSource { + pub fn new(app: tauri::AppHandle) -> PluginSource { + PluginSource { app } + } +} + +#[async_trait::async_trait] +impl UpdateSource for PluginSource { + async fn check(&self) -> Result>, UpdateError> { + use tauri_plugin_updater::UpdaterExt; + let updater = self + .app + .updater_builder() + .timeout(CHECK_TIMEOUT) + .build() + .map_err(classify)?; + let found = updater.check().await.map_err(classify)?; + Ok(found.map(|update| Arc::new(PluginUpdate(update)) as Arc)) + } +} + +struct PluginUpdate(tauri_plugin_updater::Update); + +#[async_trait::async_trait] +impl PendingUpdate for PluginUpdate { + fn info(&self) -> UpdateInfo { + let notes = self + .0 + .body + .as_deref() + .map(str::trim) + .filter(|notes| !notes.is_empty()) + .map(str::to_string); + UpdateInfo { + version: self.0.version.clone(), + notes, + } + } + + async fn download_and_install(&self, progress: ProgressSink<'_>) -> Result<(), UpdateError> { + let mut update = self.0.clone(); + update.timeout = Some(DOWNLOAD_TIMEOUT); + let mut downloaded: u64 = 0; + update + .download_and_install( + |chunk, total| { + downloaded += chunk as u64; + progress(UpdateProgress { downloaded, total }); + }, + || {}, + ) + .await + .map_err(classify) + } +} + +/// Sorts updater failures into the server being unreachable and everything else. +fn classify(error: tauri_plugin_updater::Error) -> UpdateError { + use tauri_plugin_updater::Error; + let message = error.to_string(); + match error { + Error::Reqwest(_) | Error::Network(_) | Error::ReleaseNotFound => { + UpdateError::Network(message) + } + _ => UpdateError::Internal(message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unreachable_servers_are_network_errors_and_the_rest_internal() { + use tauri_plugin_updater::Error; + assert!(matches!( + classify(Error::Network("status 503".into())), + UpdateError::Network(_) + )); + assert!(matches!( + classify(Error::ReleaseNotFound), + UpdateError::Network(_) + )); + assert!(matches!( + classify(Error::TargetNotFound("darwin-aarch64".into())), + UpdateError::Internal(_) + )); + assert!(matches!( + classify(Error::EmptyEndpoints), + UpdateError::Internal(_) + )); + } + + #[test] + fn check_results_carry_the_update_or_the_error() { + let found = UpdateInfo { + version: "9.9.9".into(), + notes: None, + }; + assert_eq!( + serde_json::to_value(UpdateCheckResult::from(&Ok(Some(found)))).unwrap(), + serde_json::json!({ "update": { "version": "9.9.9", "notes": null }, "error": null }) + ); + let failed = Err(UpdateError::Network("offline".into())); + assert_eq!( + serde_json::to_value(UpdateCheckResult::from(&failed)).unwrap(), + serde_json::json!({ "update": null, "error": "offline" }) + ); + } +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 097ea61..c67b328 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -1,4 +1,4 @@ -//! Windows other than the main one. +//! Windows other than the main one, and bringing the main window to the front. use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; @@ -38,6 +38,14 @@ pub fn open_settings(app: &AppHandle) -> tauri::Result<()> { } } +/// Shows the main window and brings it to the front. +pub fn show_main(app: &AppHandle) -> tauri::Result<()> { + match app.get_webview_window(crate::menu::MAIN) { + Some(window) => focus(&window), + None => Ok(()), + } +} + fn focus(window: &tauri::WebviewWindow) -> tauri::Result<()> { window.unminimize()?; window.show()?; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6421af1..6bf7263 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,6 +32,15 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "createUpdaterArtifacts": false + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc1OENBRDI1OTVBRDlBNEMKUldSTW1xMlZKYTJNZFl1YzdIVmd3RGNWWGYycHA4a1luNVFZcSt1dW42QjU5eHMwcDdWU2pvZEYK", + "endpoints": [ + "https://github.com/s3rgiosan/tskdeck/releases/latest/download/latest.json" + ] + } } } diff --git a/src-tauri/tests/updates.rs b/src-tauri/tests/updates.rs new file mode 100644 index 0000000..32de7d3 --- /dev/null +++ b/src-tauri/tests/updates.rs @@ -0,0 +1,171 @@ +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tskdeck_lib::updates::{ + CHECK_INTERVAL, FIRST_CHECK_DELAY, PendingUpdate, ProgressSink, UpdateError, UpdateInfo, + UpdateManager, UpdateProgress, UpdateSource, run_schedule, +}; + +fn release(version: &str) -> UpdateInfo { + UpdateInfo { + version: version.into(), + notes: Some("Example notes".into()), + } +} + +struct FakeUpdate { + info: UpdateInfo, + installs: Arc, +} + +#[async_trait::async_trait] +impl PendingUpdate for FakeUpdate { + fn info(&self) -> UpdateInfo { + self.info.clone() + } + + async fn download_and_install(&self, progress: ProgressSink<'_>) -> Result<(), UpdateError> { + progress(UpdateProgress { + downloaded: 50, + total: Some(100), + }); + progress(UpdateProgress { + downloaded: 100, + total: Some(100), + }); + self.installs.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +/// Answers each check with the next scripted result and counts the checks. +#[derive(Default)] +struct FakeSource { + answers: Mutex, UpdateError>>>, + checks: AtomicUsize, + installs: Arc, +} + +impl FakeSource { + fn answering(answers: Vec, UpdateError>>) -> Arc { + let mut answers = answers; + answers.reverse(); + Arc::new(FakeSource { + answers: Mutex::new(answers), + ..Default::default() + }) + } +} + +#[async_trait::async_trait] +impl UpdateSource for FakeSource { + async fn check(&self) -> Result>, UpdateError> { + self.checks.fetch_add(1, Ordering::SeqCst); + let answer = self.answers.lock().unwrap().pop().unwrap_or(Ok(None)); + let found = answer?; + Ok(found.map(|info| { + Arc::new(FakeUpdate { + info, + installs: Arc::clone(&self.installs), + }) as Arc + })) + } +} + +type Published = Arc>>>; + +fn manager(source: &Arc) -> (Arc, Published) { + let published: Published = Arc::default(); + let sink = Arc::clone(&published); + let manager = UpdateManager::new( + Arc::clone(source) as Arc, + Arc::new(move |update| sink.lock().unwrap().push(update)), + ); + (Arc::new(manager), published) +} + +#[tokio::test] +async fn a_check_hands_its_result_to_the_sink_and_a_failure_keeps_the_pending_update() { + let source = FakeSource::answering(vec![ + Ok(Some(release("9.9.9"))), + Err(UpdateError::Network("offline".into())), + Ok(None), + ]); + let (manager, published) = manager(&source); + + assert_eq!(manager.check_now().await, Ok(Some(release("9.9.9")))); + assert_eq!(manager.pending(), Some(release("9.9.9"))); + + let failed = manager.check_now().await; + assert_eq!(failed, Err(UpdateError::Network("offline".into()))); + assert_eq!(manager.pending(), Some(release("9.9.9"))); + + assert_eq!(manager.check_now().await, Ok(None)); + assert_eq!(manager.pending(), None); + assert_eq!( + *published.lock().unwrap(), + vec![Some(release("9.9.9")), None] + ); +} + +#[tokio::test] +async fn installing_needs_a_pending_update() { + let source = FakeSource::answering(Vec::new()); + let (manager, _) = manager(&source); + let error = manager.install(&|_| {}).await.unwrap_err(); + assert_eq!(error, UpdateError::NothingPending); + assert_eq!(source.installs.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn installing_the_pending_update_reports_progress() { + let source = FakeSource::answering(vec![Ok(Some(release("9.9.9")))]); + let (manager, _) = manager(&source); + manager.check_now().await.unwrap(); + let progress = Mutex::new(Vec::new()); + let record = |p: UpdateProgress| progress.lock().unwrap().push(p.downloaded); + manager.install(&record).await.unwrap(); + assert_eq!(*progress.lock().unwrap(), vec![50, 100]); + assert_eq!(source.installs.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn scheduled_checks_run_a_minute_after_launch_then_daily_while_enabled() { + let source = FakeSource::answering(Vec::new()); + let (manager, _) = manager(&source); + let enabled = Arc::new(AtomicBool::new(true)); + let flag = Arc::clone(&enabled); + let schedule = tokio::spawn(run_schedule(manager, move || flag.load(Ordering::SeqCst))); + let checks = || source.checks.load(Ordering::SeqCst); + let second = Duration::from_secs(1); + + tokio::time::sleep(FIRST_CHECK_DELAY - second).await; + assert_eq!(checks(), 0); + tokio::time::sleep(second * 2).await; + assert_eq!(checks(), 1); + + tokio::time::sleep(CHECK_INTERVAL - second * 2).await; + assert_eq!(checks(), 1); + tokio::time::sleep(second * 2).await; + assert_eq!(checks(), 2); + + // Turned off, the daily tick checks nothing; turned back on, the next tick checks again. + enabled.store(false, Ordering::SeqCst); + tokio::time::sleep(CHECK_INTERVAL).await; + assert_eq!(checks(), 2); + enabled.store(true, Ordering::SeqCst); + tokio::time::sleep(CHECK_INTERVAL).await; + assert_eq!(checks(), 3); + schedule.abort(); +} + +#[tokio::test(start_paused = true)] +async fn scheduled_checks_never_run_while_disabled() { + let source = FakeSource::answering(Vec::new()); + let (manager, _) = manager(&source); + let schedule = tokio::spawn(run_schedule(manager, || false)); + tokio::time::sleep(FIRST_CHECK_DELAY + CHECK_INTERVAL * 3).await; + assert_eq!(source.checks.load(Ordering::SeqCst), 0); + schedule.abort(); +} diff --git a/src/api/client.test.ts b/src/api/client.test.ts index f14631e..9377d12 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -47,7 +47,7 @@ describe("api", () => { it("invokes set_archived and save_settings", async () => { await api.setArchived(3, true); expect(invoke).toHaveBeenCalledWith("set_archived", { number: 3, archived: true }); - const settings = { openMode: "overlay", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} } as const; + const settings = { openMode: "overlay", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true } as const; await api.saveSettings(settings); expect(invoke).toHaveBeenCalledWith("save_settings", { settings }); }); diff --git a/src/api/client.ts b/src/api/client.ts index 5139b68..bd789ea 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -17,6 +17,9 @@ import type { TaskRow, TskError, TskInfo, + UpdateCheckResult, + UpdateInfo, + UpdateProgress, } from "./types"; export const api = { @@ -41,6 +44,12 @@ export const api = { relocateTsk: () => invoke("relocate_tsk"), /** Opens the settings window, or brings it to the front. */ openSettings: () => invoke("open_settings"), + /** Looks for a newer release now. Rejects with a plain string, not a TskError. */ + checkForUpdates: () => invoke("check_for_updates"), + /** The release the last successful check found. */ + pendingUpdate: () => invoke("pending_update"), + /** Downloads and installs the pending update, then relaunches. Rejects with a plain string, not a TskError. */ + installUpdate: () => invoke("install_update"), }; export type Api = typeof api; @@ -55,6 +64,21 @@ export function onSettingsChanged(cb: (settings: Settings) => void): Promise<() return listen("settings-changed", (e) => cb(e.payload)); } +/** Calls `cb` after each successful check for updates, with the release found or null. */ +export function onUpdateAvailable(cb: (update: UpdateInfo | null) => void): Promise<() => void> { + return listen("update-available", (e) => cb(e.payload)); +} + +/** Calls `cb` with the result of a check started from the app menu. */ +export function onUpdateCheckResult(cb: (result: UpdateCheckResult) => void): Promise<() => void> { + return listen("update-check-result", (e) => cb(e.payload)); +} + +/** Calls `cb` with the download progress while an update downloads. */ +export function onUpdateProgress(cb: (progress: UpdateProgress) => void): Promise<() => void> { + return listen("update-progress", (e) => cb(e.payload)); +} + /** Asks the main window to look for tsk again. */ export function requestTskCheck(): Promise { return emitTo("main", "tsk-check"); diff --git a/src/api/types.ts b/src/api/types.ts index 608cb55..a0e7260 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -186,6 +186,27 @@ export interface Settings { sidebarHidden: boolean; /** Names given to projects in tskdeck, keyed by the project's full path. */ projectNames: Record; + /** Whether tskdeck looks for a newer release after launch and once a day. */ + checkForUpdates: boolean; +} + +/** A newer release than the running app. */ +export interface UpdateInfo { + version: string; + /** The release notes, when the release has any. */ + notes: string | null; +} + +/** Download progress in bytes. `total` is null when the server sends no length. */ +export interface UpdateProgress { + downloaded: number; + total: number | null; +} + +/** The outcome of a check started from the app menu: the update found, or why the check failed. */ +export interface UpdateCheckResult { + update: UpdateInfo | null; + error: string | null; } export interface TskInfo { diff --git a/src/components/KeymapEditor.test.tsx b/src/components/KeymapEditor.test.tsx index fd1f680..b8426c9 100644 --- a/src/components/KeymapEditor.test.tsx +++ b/src/components/KeymapEditor.test.tsx @@ -28,7 +28,7 @@ describe("KeymapEditor", () => { await userEvent.click(screen.getByRole("button", { name: "Record Mark Done" })); await userEvent.keyboard("{Control>}{Enter}{/Control}"); await vi.waitFor(() => expect(saved()).toEqual([{ "status.done": ["ctrl+enter"] }])); - expect(api.saveSettings).toHaveBeenCalledWith({ openMode: "beside", keymap: { "status.done": ["ctrl+enter"] }, tskPath: null, sidebarHidden: false, projectNames: {} }); + expect(api.saveSettings).toHaveBeenCalledWith({ openMode: "beside", keymap: { "status.done": ["ctrl+enter"] }, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }); }); it("ignores a lone modifier and cancels on Escape", async () => { @@ -77,7 +77,7 @@ describe("KeymapEditor", () => { }); it("resets one action and all actions", async () => { - useDeck.setState({ settings: { openMode: "beside", keymap: { undo: ["ctrl+z"], sort: ["x"] }, tskPath: null, sidebarHidden: false, projectNames: {} } }); + useDeck.setState({ settings: { openMode: "beside", keymap: { undo: ["ctrl+z"], sort: ["x"] }, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true } }); render(); await userEvent.click(screen.getByRole("button", { name: "Reset Undo" })); await vi.waitFor(() => expect(saved()).toEqual([{ sort: ["x"] }])); @@ -87,7 +87,7 @@ describe("KeymapEditor", () => { }); it("asks before resetting all shortcuts and keeps them on Cancel", async () => { - useDeck.setState({ settings: { openMode: "beside", keymap: { sort: ["x"] }, tskPath: null, sidebarHidden: false, projectNames: {} } }); + useDeck.setState({ settings: { openMode: "beside", keymap: { sort: ["x"] }, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true } }); render(); await userEvent.click(screen.getByRole("button", { name: "Reset All" })); const sheet = screen.getByRole("dialog", { name: "Reset All Shortcuts" }); diff --git a/src/components/Settings.test.tsx b/src/components/Settings.test.tsx index f728a62..623daed 100644 --- a/src/components/Settings.test.tsx +++ b/src/components/Settings.test.tsx @@ -1,7 +1,7 @@ import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { api, onMenu, onSettingsChanged, requestTskCheck, setWindowHeight, setWindowTitle, showAlert } from "../api/client"; +import { api, onMenu, onSettingsChanged, onUpdateProgress, requestTskCheck, setWindowHeight, setWindowTitle, showAlert } from "../api/client"; import type { Settings as SettingsValue } from "../api/types"; import { useDeck } from "../state/store"; import { GeneralSettings } from "./Settings"; @@ -11,10 +11,19 @@ vi.mock("../api/client", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - api: { saveSettings: vi.fn(), getSettings: vi.fn(), tskInfo: vi.fn(), listScope: vi.fn(), relocateTsk: vi.fn() }, + api: { + saveSettings: vi.fn(), + getSettings: vi.fn(), + tskInfo: vi.fn(), + listScope: vi.fn(), + relocateTsk: vi.fn(), + checkForUpdates: vi.fn(), + installUpdate: vi.fn(), + }, onBoardChanged: vi.fn(), onSettingsChanged: vi.fn(), onMenu: vi.fn(), + onUpdateProgress: vi.fn(), requestTskCheck: vi.fn(), setWindowTitle: vi.fn(), setWindowHeight: vi.fn(), @@ -22,7 +31,7 @@ vi.mock("../api/client", async (importOriginal) => { }; }); -const DEFAULTS: SettingsValue = { openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} }; +const DEFAULTS: SettingsValue = { openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }; beforeEach(() => { vi.resetAllMocks(); @@ -35,6 +44,7 @@ beforeEach(() => { vi.mocked(requestTskCheck).mockResolvedValue(); vi.mocked(setWindowTitle).mockResolvedValue(); vi.mocked(setWindowHeight).mockResolvedValue(); + vi.mocked(onUpdateProgress).mockResolvedValue(() => {}); localStorage.clear(); }); afterEach(cleanup); @@ -72,6 +82,38 @@ describe("GeneralSettings", () => { await vi.waitFor(() => expect(api.saveSettings).toHaveBeenCalledWith({ ...DEFAULTS, tskPath: "/opt/tsk" })); expect(api.relocateTsk).not.toHaveBeenCalled(); }); + + it("saves the automatic update checks setting", async () => { + render(); + await userEvent.click(screen.getByLabelText("Check for updates automatically")); + await vi.waitFor(() => expect(api.saveSettings).toHaveBeenCalledWith({ ...DEFAULTS, checkForUpdates: false })); + }); + + it("says that tskdeck is up to date when Check Now finds nothing", async () => { + vi.mocked(api.checkForUpdates).mockResolvedValue(null); + render(); + await userEvent.click(screen.getByRole("button", { name: "Check Now" })); + expect(await screen.findByText("Up to date")).toBeInTheDocument(); + }); + + it("offers to install the release that Check Now finds", async () => { + vi.mocked(api.checkForUpdates).mockResolvedValue({ version: "9.9.9", notes: null }); + vi.mocked(api.installUpdate).mockReturnValue(new Promise(() => {})); + render(); + await userEvent.click(screen.getByRole("button", { name: "Check Now" })); + expect(await screen.findByText("Version 9.9.9 available")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Install and Relaunch" })); + expect(api.installUpdate).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Downloading…" })).toBeDisabled(); + }); + + it("shows why Check Now failed", async () => { + vi.mocked(api.checkForUpdates).mockRejectedValue("offline"); + render(); + await userEvent.click(screen.getByRole("button", { name: "Check Now" })); + expect(await screen.findByText("Couldn't check for updates: offline")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Check Now" })).toBeEnabled(); + }); }); describe("SettingsWindow", () => { @@ -85,7 +127,7 @@ describe("SettingsWindow", () => { it("takes settings saved from the main window", async () => { render(); await vi.waitFor(() => expect(onSettingsChanged).toHaveBeenCalled()); - vi.mocked(onSettingsChanged).mock.calls[0][0]({ ...DEFAULTS, sidebarHidden: true, projectNames: {} }); + vi.mocked(onSettingsChanged).mock.calls[0][0]({ ...DEFAULTS, sidebarHidden: true, projectNames: {}, checkForUpdates: true }); expect(useDeck.getState().settings.sidebarHidden).toBe(true); }); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 066562c..fccdfa6 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -2,8 +2,9 @@ import { useEffect, useState } from "react"; import { requestTskCheck } from "../api/client"; import type { OpenMode } from "../api/types"; import { useDeck } from "../state/store"; +import { UpdateCheck } from "../updates/UpdateCheck"; -/** The General pane: where a task opens and where tsk lives. Each change is saved as it is made. */ +/** The General pane: where a task opens, where tsk lives and update checks. Each change is saved as it is made. */ export function GeneralSettings() { const settings = useDeck((s) => s.settings); const saveSettings = useDeck((s) => s.saveSettings); @@ -36,6 +37,10 @@ export function GeneralSettings() { void saveSettings({ ...useDeck.getState().settings, openMode }); } + function setUpdateChecks(checkForUpdates: boolean) { + void saveSettings({ ...useDeck.getState().settings, checkForUpdates }); + } + return (
@@ -76,6 +81,12 @@ export function GeneralSettings() { {detectError}

)} + Updates: + +
); } diff --git a/src/components/Shell.test.tsx b/src/components/Shell.test.tsx index 9b4ecf6..02588ca 100644 --- a/src/components/Shell.test.tsx +++ b/src/components/Shell.test.tsx @@ -7,6 +7,9 @@ import { onMenu, onSettingsChanged, onTskCheckRequested, + onUpdateAvailable, + onUpdateCheckResult, + onUpdateProgress, openDocs, reportMenuState, setWindowTitle, @@ -17,6 +20,7 @@ import { } from "../api/client"; import type { TaskRow } from "../api/types"; import { useDeck } from "../state/store"; +import { useUpdates } from "../state/updates"; import { blockOn, detail, row } from "../test/fixtures"; import { Shell } from "./Shell"; @@ -38,12 +42,18 @@ vi.mock("../api/client", async (importOriginal) => { saveSettings: vi.fn(), relocateTsk: vi.fn(), openSettings: vi.fn(), + checkForUpdates: vi.fn(), + pendingUpdate: vi.fn(), + installUpdate: vi.fn(), }, zoom: vi.fn(), openDocs: vi.fn(), onBoardChanged: vi.fn(), onSettingsChanged: vi.fn(), onTskCheckRequested: vi.fn(), + onUpdateAvailable: vi.fn(), + onUpdateCheckResult: vi.fn(), + onUpdateProgress: vi.fn(), onMenu: vi.fn(), setWindowTitle: vi.fn(), reportMenuState: vi.fn(), @@ -64,7 +74,7 @@ function subscriber void>(subscribe: (cb: T) => async function mountWith(rows: TaskRow[]) { vi.mocked(api.tskInfo).mockResolvedValue({ path: "/bin/tsk", version: "0.13.0", supported: true, minimum: "0.13.0" }); - vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} }); + vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }); vi.mocked(api.listScope).mockResolvedValue(rows); vi.mocked(api.setStatus).mockResolvedValue(); vi.mocked(api.setArchived).mockResolvedValue(); @@ -90,6 +100,11 @@ beforeEach(() => { vi.mocked(onMenu).mockResolvedValue(() => {}); vi.mocked(setWindowTitle).mockResolvedValue(); vi.mocked(showAlert).mockResolvedValue(); + vi.mocked(api.pendingUpdate).mockResolvedValue(null); + vi.mocked(onUpdateAvailable).mockResolvedValue(() => {}); + vi.mocked(onUpdateCheckResult).mockResolvedValue(() => {}); + vi.mocked(onUpdateProgress).mockResolvedValue(() => {}); + useUpdates.setState(useUpdates.getInitialState(), true); localStorage.clear(); }); afterEach(cleanup); @@ -150,7 +165,7 @@ describe("palette and settings keys", () => { describe("loading", () => { it("shows a spinner on the board until the first listing arrives", async () => { vi.mocked(api.tskInfo).mockResolvedValue({ path: "/bin/tsk", version: "0.13.0", supported: true, minimum: "0.13.0" }); - vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} }); + vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }); vi.mocked(api.listScope).mockReturnValue(new Promise(() => {})); vi.mocked(onBoardChanged).mockResolvedValue(() => {}); render(); @@ -252,7 +267,7 @@ describe("menu bar", () => { describe("settings saved in the settings window", () => { it("applies the keymap, open mode and sidebar choice", async () => { await mountWith([row({ number: 3, status: "started" })]); - subscriber(onSettingsChanged)({ openMode: "overlay", keymap: { "status.done": ["x"] }, tskPath: null, sidebarHidden: true, projectNames: {} }); + subscriber(onSettingsChanged)({ openMode: "overlay", keymap: { "status.done": ["x"] }, tskPath: null, sidebarHidden: true, projectNames: {}, checkForUpdates: true }); await vi.waitFor(() => expect(screen.queryByRole("navigation", { name: "Views" })).not.toBeInTheDocument()); expect(useDeck.getState().settings.openMode).toBe("overlay"); await userEvent.keyboard("x"); @@ -262,7 +277,7 @@ describe("settings saved in the settings window", () => { it("looks for tsk again when the tsk path changed", async () => { await mountWith([row({ number: 3 })]); - subscriber(onSettingsChanged)({ openMode: "beside", keymap: {}, tskPath: "/opt/test/tsk", sidebarHidden: false, projectNames: {} }); + subscriber(onSettingsChanged)({ openMode: "beside", keymap: {}, tskPath: "/opt/test/tsk", sidebarHidden: false, projectNames: {}, checkForUpdates: true }); await vi.waitFor(() => expect(api.relocateTsk).toHaveBeenCalledTimes(1)); }); @@ -276,7 +291,7 @@ describe("settings saved in the settings window", () => { describe("tsk missing screen", () => { async function mountMissing() { vi.mocked(api.tskInfo).mockRejectedValue({ kind: "notFound" }); - vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} }); + vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }); vi.mocked(onBoardChanged).mockResolvedValue(() => {}); render(); await screen.findByText("tsk Not Found"); @@ -486,7 +501,7 @@ describe("window chrome", () => { await mountWith([row({ number: 3 })]); vi.mocked(api.saveSettings).mockResolvedValue(); await userEvent.click(screen.getByRole("button", { name: "Hide Sidebar" })); - await vi.waitFor(() => expect(api.saveSettings).toHaveBeenCalledWith(expect.objectContaining({ sidebarHidden: true, projectNames: {} }))); + await vi.waitFor(() => expect(api.saveSettings).toHaveBeenCalledWith(expect.objectContaining({ sidebarHidden: true, projectNames: {}, checkForUpdates: true }))); }); }); @@ -555,14 +570,14 @@ describe("sidebar", () => { expect(screen.getByRole("navigation", { name: "Views" })).toBeInTheDocument(); await userEvent.keyboard("{Control>}{Alt>}s{/Alt}{/Control}"); await vi.waitFor(() => expect(screen.queryByRole("navigation", { name: "Views" })).not.toBeInTheDocument()); - expect(api.saveSettings).toHaveBeenLastCalledWith(expect.objectContaining({ sidebarHidden: true, projectNames: {} })); + expect(api.saveSettings).toHaveBeenLastCalledWith(expect.objectContaining({ sidebarHidden: true, projectNames: {}, checkForUpdates: true })); await userEvent.keyboard("{Control>}{Alt>}s{/Alt}{/Control}"); await vi.waitFor(() => expect(screen.getByRole("navigation", { name: "Views" })).toBeInTheDocument()); - expect(api.saveSettings).toHaveBeenLastCalledWith(expect.objectContaining({ sidebarHidden: false, projectNames: {} })); + expect(api.saveSettings).toHaveBeenLastCalledWith(expect.objectContaining({ sidebarHidden: false, projectNames: {}, checkForUpdates: true })); }); it("starts hidden when the saved settings say so", async () => { - vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: true, projectNames: {} }); + vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: true, projectNames: {}, checkForUpdates: true }); vi.mocked(api.tskInfo).mockResolvedValue({ path: "/bin/tsk", version: "0.13.0", supported: true, minimum: "0.13.0" }); vi.mocked(api.listScope).mockResolvedValue([row({ number: 3 })]); vi.mocked(onBoardChanged).mockResolvedValue(() => {}); @@ -611,12 +626,12 @@ describe("sidebar", () => { setWidth(900); await mountWith([row({ number: 3, status: "started" })]); vi.mocked(api.saveSettings).mockResolvedValue(); - await useDeck.getState().saveSettings({ ...useDeck.getState().settings, sidebarHidden: true, projectNames: {} }); + await useDeck.getState().saveSettings({ ...useDeck.getState().settings, sidebarHidden: true, projectNames: {}, checkForUpdates: true }); await userEvent.keyboard("{Enter}"); await screen.findByRole("complementary", { name: "Task" }); subscriber(onMenu)("sidebar"); await vi.waitFor(() => expect(screen.getByRole("navigation", { name: "Views" })).toBeInTheDocument()); - expect(api.saveSettings).toHaveBeenLastCalledWith(expect.objectContaining({ sidebarHidden: false, projectNames: {} })); + expect(api.saveSettings).toHaveBeenLastCalledWith(expect.objectContaining({ sidebarHidden: false, projectNames: {}, checkForUpdates: true })); }); }); @@ -700,7 +715,7 @@ describe("tasks over the board", () => { describe("board empty state", () => { it("offers New Task when the board has no tasks", async () => { vi.mocked(api.tskInfo).mockResolvedValue({ path: "/bin/tsk", version: "0.13.0", supported: true, minimum: "0.13.0" }); - vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} }); + vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }); vi.mocked(api.listScope).mockResolvedValue([]); vi.mocked(onBoardChanged).mockResolvedValue(() => {}); render(); @@ -1067,6 +1082,7 @@ describe("project names", () => { tskPath: null, sidebarHidden: false, projectNames: { [path]: "Alpha" }, + checkForUpdates: true, }); vi.mocked(api.listScope).mockResolvedValue([row({ number: 3, status: "started", project: path })]); vi.mocked(onBoardChanged).mockResolvedValue(() => {}); @@ -1085,3 +1101,60 @@ describe("project names", () => { await vi.waitFor(() => expect(setWindowTitle).toHaveBeenCalledWith("Alpha")); }); }); + +describe("update card", () => { + const release = { version: "9.9.9", notes: null }; + + it("shows a release that a check found before the window loaded", async () => { + vi.mocked(api.pendingUpdate).mockResolvedValue(release); + await mountWith([row({ number: 3, status: "started" })]); + expect(await screen.findByText("tskdeck 9.9.9 is available.")).toBeInTheDocument(); + }); + + it("shows a release that a scheduled check finds later", async () => { + await mountWith([row({ number: 3, status: "started" })]); + expect(screen.queryByText("tskdeck 9.9.9 is available.")).not.toBeInTheDocument(); + act(() => subscriber(onUpdateAvailable)(release)); + expect(await screen.findByText("tskdeck 9.9.9 is available.")).toBeInTheDocument(); + }); + + it("hides the card with Later and shows it again when a check from the menu finds the release", async () => { + vi.mocked(api.pendingUpdate).mockResolvedValue(release); + await mountWith([row({ number: 3, status: "started" })]); + await userEvent.click(await screen.findByRole("button", { name: "Later" })); + expect(screen.queryByText("tskdeck 9.9.9 is available.")).not.toBeInTheDocument(); + act(() => subscriber(onUpdateCheckResult)({ update: release, error: null })); + expect(await screen.findByText("tskdeck 9.9.9 is available.")).toBeInTheDocument(); + }); + + it("installs the release and reports a failed install", async () => { + vi.mocked(api.pendingUpdate).mockResolvedValue(release); + vi.mocked(api.installUpdate).mockRejectedValue("signature mismatch"); + await mountWith([row({ number: 3, status: "started" })]); + await userEvent.click(await screen.findByRole("button", { name: "Install and Relaunch" })); + expect(api.installUpdate).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(showAlert).toHaveBeenCalledWith("The update couldn't be installed", "signature mismatch")); + expect(screen.getByRole("button", { name: "Install and Relaunch" })).toBeEnabled(); + }); + + it("says that tskdeck is up to date after a check from the menu finds nothing", async () => { + await mountWith([row({ number: 3, status: "started" })]); + act(() => subscriber(onUpdateCheckResult)({ update: null, error: null })); + expect(showAlert).toHaveBeenCalledWith("tskdeck is up to date", "No newer version is available."); + }); + + it("reports a check from the menu that failed", async () => { + await mountWith([row({ number: 3, status: "started" })]); + act(() => subscriber(onUpdateCheckResult)({ update: null, error: "offline" })); + await vi.waitFor(() => expect(showAlert).toHaveBeenCalledWith("Couldn't check for updates", "offline")); + }); + + it("checks for updates from the command palette", async () => { + vi.mocked(api.checkForUpdates).mockResolvedValue(release); + await mountWith([row({ number: 3, status: "started" })]); + await userEvent.keyboard(":"); + await userEvent.type(screen.getByRole("combobox", { name: "Search actions" }), "check for updates{Enter}"); + expect(api.checkForUpdates).toHaveBeenCalledTimes(1); + expect(await screen.findByText("tskdeck 9.9.9 is available.")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index bffe994..0c42a9c 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -22,6 +22,7 @@ import { useKeymap } from "../keys/useKeymap"; import { menuAccelerators, runMenuItem } from "../menu"; import type { DeckState } from "../state/store"; import { failure, useDeck } from "../state/store"; +import { checkForUpdatesNow, subscribeToUpdates } from "../updates/checks"; import { AssignDialog } from "./AssignDialog"; import { RenameProjectDialog } from "./RenameProjectDialog"; import { Board } from "./Board"; @@ -236,6 +237,7 @@ export function Shell() { }, [init]); useSubscription(subscribeToSettings); useSubscription(subscribeToTskChecks); + useSubscription(subscribeToUpdates); useEffect(() => { if (sidebarHidden && useDeck.getState().focus === "sidebar") useDeck.setState({ focus: "board" }); @@ -340,6 +342,7 @@ export function Shell() { "zoom.out": () => zoomWindow("out"), "zoom.reset": () => zoomWindow("reset"), "help.docs": showDocs, + "updates.check": checkForUpdatesNow, search: () => setSearchFocus((n) => n + 1), undo: () => void state().undoLast(), // The drawer takes the task pane's place, so opening it closes the open task. diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cb41e41..b8e1d69 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { resolveKeymap } from "../keys/keymap"; import type { Handler, Handlers } from "../keys/useKeymap"; import { useKeymap } from "../keys/useKeymap"; import { useDeck } from "../state/store"; +import { UpdateCard } from "../updates/UpdateCard"; import { usePullDownChoice } from "./PullDown"; import type { SymbolName } from "./SymbolIcon"; import { SymbolIcon } from "./SymbolIcon"; @@ -283,6 +284,7 @@ export function Sidebar(props: { onRename: (path: string) => void }) { {items.length === 0 &&
{filter?.section === name ? "No match" : "None"}
} ))} + ); } diff --git a/src/dev/mockTauri.ts b/src/dev/mockTauri.ts index 7268853..aed57d2 100644 --- a/src/dev/mockTauri.ts +++ b/src/dev/mockTauri.ts @@ -2,7 +2,8 @@ // URL controls: `?open=` opens that task's pane once the board has loaded; `#settings` shows Settings; // `?fullscreen` and `?reduceTransparency` report those window states as on; `?overlay` opens tasks over the board; // `?rename` chooses Rename… from the first project's context menu; `?marks` starts multi-select with T3, T4 and T5 -// marked; `?unknown` gives T10 a status the app does not recognise; `?longnotes` gives T3 notes taller than six lines. +// marked; `?unknown` gives T10 a status the app does not recognise; `?longnotes` gives T3 notes taller than six lines; +// `?update` reports a newer release, which the sidebar card and Check Now show. import { emit } from "@tauri-apps/api/event"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; import type { Block, ListOptions, Review, Settings, Status, TaskDetail, TaskRow, Timestamp } from "../api/types"; @@ -103,6 +104,7 @@ const SETTINGS: Settings = { tskPath: "/usr/local/bin/tsk", sidebarHidden: false, projectNames: {}, + checkForUpdates: true, }; function listScope(scope: { kind: string; path?: string; name?: string }, options: ListOptions): TaskRow[] { @@ -149,6 +151,11 @@ function handle(cmd: string, payload: Record = {}): unknown { case "show_task_menu": case "show_pull_down": return null; + case "check_for_updates": + case "pending_update": + return params.has("update") ? { version: "9.9.9", notes: null } : null; + case "install_update": + return null; case "reduce_transparency": return params.has("reduceTransparency"); case "plugin:window|is_fullscreen": diff --git a/src/keys/actions.ts b/src/keys/actions.ts index 6614039..04c523b 100644 --- a/src/keys/actions.ts +++ b/src/keys/actions.ts @@ -52,6 +52,7 @@ export const ACTIONS = [ { id: "zoom.out", label: "Zoom Out" }, { id: "zoom.reset", label: "Actual Size" }, { id: "help.docs", label: "tsk Documentation" }, + { id: "updates.check", label: "Check for Updates…" }, ] as const; export type ActionId = (typeof ACTIONS)[number]["id"]; diff --git a/src/keys/keymap.test.ts b/src/keys/keymap.test.ts index 440ff3c..237945c 100644 --- a/src/keys/keymap.test.ts +++ b/src/keys/keymap.test.ts @@ -94,6 +94,8 @@ describe("keymap", () => { const keyless: [ActionId, boolean][] = [ ["help.docs", true], ["help.docs", false], + ["updates.check", true], + ["updates.check", false], ["zoom.in", true], ["zoom.out", true], ["zoom.reset", true], diff --git a/src/keys/keymap.ts b/src/keys/keymap.ts index fbb09c2..5f20249 100644 --- a/src/keys/keymap.ts +++ b/src/keys/keymap.ts @@ -58,6 +58,7 @@ export const DEFAULT_KEYMAP: Keymap = { "zoom.out": [], "zoom.reset": [], "help.docs": [], + "updates.check": [], }; /** diff --git a/src/state/search.test.ts b/src/state/search.test.ts index a3e8318..75d2eda 100644 --- a/src/state/search.test.ts +++ b/src/state/search.test.ts @@ -80,7 +80,7 @@ describe("search", () => { return () => {}; }); vi.mocked(api.tskInfo).mockResolvedValue({ path: "/bin/tsk", version: "0.13.0", supported: true, minimum: "0.13.0" }); - vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} }); + vi.mocked(api.getSettings).mockResolvedValue({ openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }); vi.mocked(api.listScope).mockResolvedValue([row({ number: 1 })]); await useDeck.getState().init(); useDeck.setState({ searchIndex: new Map([[1, { number: 1, title: "x", thread: null, assignee: null }]]) }); diff --git a/src/state/store.test.ts b/src/state/store.test.ts index c7c2a31..dd1e189 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -27,7 +27,7 @@ vi.mock("../api/client", async (importOriginal) => { }; }); -const settings = { openMode: "beside" as const, keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} }; +const settings = { openMode: "beside" as const, keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }; beforeEach(() => { resetBoardSubscription(); @@ -305,7 +305,7 @@ describe("marking the open task done", () => { describe("applySettings", () => { it("takes settings saved elsewhere and reports whether the tsk path changed", () => { - const next = { openMode: "overlay" as const, keymap: { undo: ["x"] }, tskPath: null, sidebarHidden: true, projectNames: {} }; + const next = { openMode: "overlay" as const, keymap: { undo: ["x"] }, tskPath: null, sidebarHidden: true, projectNames: {}, checkForUpdates: true }; expect(useDeck.getState().applySettings(next)).toBe(false); expect(useDeck.getState().settings).toEqual(next); expect(useDeck.getState().applySettings({ ...next, tskPath: "/opt/test/tsk" })).toBe(true); diff --git a/src/state/store.ts b/src/state/store.ts index 1ebdb28..21fa72e 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -359,7 +359,7 @@ async function fillIndex(set: Set_, get: Get_): Promise { export const useDeck = create()((set, get) => ({ tsk: null, tskError: null, - settings: { openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {} }, + settings: { openMode: "beside", keymap: {}, tskPath: null, sidebarHidden: false, projectNames: {}, checkForUpdates: true }, settingsLoaded: false, scope: { kind: "desk" }, lastProject: null, diff --git a/src/state/updates.ts b/src/state/updates.ts new file mode 100644 index 0000000..3f8ce94 --- /dev/null +++ b/src/state/updates.ts @@ -0,0 +1,22 @@ +import { create } from "zustand"; +import type { UpdateInfo } from "../api/types"; + +interface UpdateState { + /** The newer release the last successful check found. */ + update: UpdateInfo | null; + /** The version that Later hid; its card stays hidden until the app quits. */ + postponed: string | null; + setUpdate: (update: UpdateInfo | null) => void; + /** Hides the card for the current release until the app quits. */ + postpone: () => void; + /** Shows the card again, for a release that a check started by the user found. */ + reveal: () => void; +} + +export const useUpdates = create()((set, get) => ({ + update: null, + postponed: null, + setUpdate: (update) => set({ update }), + postpone: () => set({ postponed: get().update?.version ?? null }), + reveal: () => set({ postponed: null }), +})); diff --git a/src/styles.css b/src/styles.css index eaf6aba..332d6b4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -358,12 +358,46 @@ input[type="search"]::-webkit-search-results-decoration { .sidebar { background: var(--panel); + display: flex; + flex-direction: column; grid-area: side; min-height: 0; overflow-y: auto; padding: var(--toolbar-row) 8px 10px; } +/* A newer release, announced at the foot of the sidebar; it stays in view while the lists scroll. */ +.update-card { + background: var(--surface); + border-radius: 8px; + bottom: 0; + box-shadow: 0 0 0 0.5px var(--separator); + display: flex; + flex-direction: column; + flex-shrink: 0; + font-size: 12px; + gap: 8px; + line-height: 1.35; + margin-top: auto; + padding: 10px; + position: sticky; +} + +.update-card-text { + margin: 0; +} + +/* The buttons stack at full width, so the card keeps one layout at every sidebar width. */ +.update-card-actions { + display: flex; + flex-direction: column; + gap: 6px; +} + +.update-status { + color: var(--secondary-label); +} + .side-group { margin-bottom: 14px; } diff --git a/src/updates/UpdateCard.tsx b/src/updates/UpdateCard.tsx new file mode 100644 index 0000000..02956ad --- /dev/null +++ b/src/updates/UpdateCard.tsx @@ -0,0 +1,31 @@ +import { errorMessage } from "../api/client"; +import { failure, useDeck } from "../state/store"; +import { useUpdates } from "../state/updates"; +import { useInstallUpdate } from "./useInstallUpdate"; + +function reportInstallFailure(e: string) { + useDeck.setState(failure("The update couldn't be installed", errorMessage(e))); +} + +/** Announces a newer release at the foot of the sidebar, and installs it or hides it until the app quits. */ +export function UpdateCard() { + const update = useUpdates((s) => s.update); + const postponed = useUpdates((s) => s.postponed); + const postpone = useUpdates((s) => s.postpone); + const { busy, label, install } = useInstallUpdate(reportInstallFailure); + + if (update === null || postponed === update.version) return null; + return ( +
+

tskdeck {update.version} is available.

+
+ + +
+
+ ); +} diff --git a/src/updates/UpdateCheck.tsx b/src/updates/UpdateCheck.tsx new file mode 100644 index 0000000..171c373 --- /dev/null +++ b/src/updates/UpdateCheck.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { api, errorMessage } from "../api/client"; +import type { UpdateInfo } from "../api/types"; +import { useInstallUpdate } from "./useInstallUpdate"; + +type CheckState = + | { kind: "idle" } + | { kind: "checking" } + | { kind: "current" } + | { kind: "available"; update: UpdateInfo } + | { kind: "failed"; error: string } + | { kind: "installFailed"; error: string }; + +function statusText(state: CheckState): string { + switch (state.kind) { + case "checking": + return "Checking…"; + case "current": + return "Up to date"; + case "available": + return `Version ${state.update.version} available`; + case "failed": + return `Couldn't check for updates: ${state.error}`; + case "installFailed": + return `Couldn't install the update: ${state.error}`; + default: + return ""; + } +} + +/** The Check Now button with its one-line result, and the install button once a release is found. */ +export function UpdateCheck() { + const [state, setState] = useState({ kind: "idle" }); + const { busy, label, install } = useInstallUpdate((e) => setState({ kind: "installFailed", error: errorMessage(e) })); + + function check() { + setState({ kind: "checking" }); + api.checkForUpdates().then( + (update) => setState(update ? { kind: "available", update } : { kind: "current" }), + (e: string) => setState({ kind: "failed", error: errorMessage(e) }), + ); + } + + const text = statusText(state); + return ( +
+ {state.kind === "available" ? ( + + ) : ( + + )} + + {text} + +
+ ); +} diff --git a/src/updates/checks.ts b/src/updates/checks.ts new file mode 100644 index 0000000..a323a26 --- /dev/null +++ b/src/updates/checks.ts @@ -0,0 +1,42 @@ +import { api, errorMessage, onUpdateAvailable, onUpdateCheckResult, showAlert } from "../api/client"; +import type { UpdateCheckResult } from "../api/types"; +import { failure, useDeck } from "../state/store"; +import { useUpdates } from "../state/updates"; + +/** + * Shows the result of a check that the user started: the card for a release found, an alert when + * tskdeck is current, or the failure. + */ +export function reportUpdateCheck(result: UpdateCheckResult) { + if (result.error !== null) { + useDeck.setState(failure("Couldn't check for updates", result.error)); + return; + } + if (result.update !== null) { + useUpdates.getState().setUpdate(result.update); + useUpdates.getState().reveal(); + return; + } + showAlert("tskdeck is up to date", "No newer version is available.").catch(() => null); +} + +/** Checks for updates now and shows the result. */ +export function checkForUpdatesNow() { + api.checkForUpdates().then( + (update) => reportUpdateCheck({ update, error: null }), + (e: string) => reportUpdateCheck({ update: null, error: errorMessage(e) }), + ); +} + +/** Takes the release a check found before this window loaded, and each one found later. */ +export function subscribeToUpdates(): Promise<() => void> { + api.pendingUpdate().then( + (update) => useUpdates.getState().setUpdate(update), + () => null, + ); + const available = onUpdateAvailable((update) => useUpdates.getState().setUpdate(update)); + const results = onUpdateCheckResult(reportUpdateCheck); + return Promise.all([available, results]).then((stops) => () => { + for (const stop of stops) stop(); + }); +} diff --git a/src/updates/useInstallUpdate.ts b/src/updates/useInstallUpdate.ts new file mode 100644 index 0000000..13ba79a --- /dev/null +++ b/src/updates/useInstallUpdate.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api, onUpdateProgress } from "../api/client"; +import type { UpdateProgress } from "../api/types"; + +/** Shown on the install button while nothing is installing. */ +export const INSTALL_LABEL = "Install and Relaunch"; + +/** Download progress, or "installing" once every byte has arrived. Null while idle. */ +type Phase = UpdateProgress | "installing" | null; + +function labelFor(phase: Phase): string { + if (phase === null) return INSTALL_LABEL; + if (phase === "installing") return "Installing…"; + if (phase.total === null || phase.total <= 0) return "Downloading…"; + const percent = Math.min(100, Math.floor((phase.downloaded / phase.total) * 100)); + return `Downloading… ${percent}%`; +} + +/** + * Drives an install button: starts the install, follows the download progress and reports a + * failure through `onError`. The app relaunches on success, so a finished install needs no state. + */ +export function useInstallUpdate(onError: (e: string) => void): { busy: boolean; label: string; install: () => void } { + const [phase, setPhase] = useState(null); + const report = useRef(onError); + report.current = onError; + + useEffect(() => { + const stop = onUpdateProgress((progress) => { + setPhase((prev) => { + if (prev === null) return prev; + const done = progress.total !== null && progress.total > 0 && progress.downloaded >= progress.total; + return done ? "installing" : progress; + }); + }).catch(() => null); + return () => { + void stop.then((unlisten) => unlisten?.()); + }; + }, []); + + const install = useCallback(() => { + setPhase({ downloaded: 0, total: null }); + api.installUpdate().catch((e: string) => { + setPhase(null); + report.current(e); + }); + }, []); + + return { busy: phase !== null, label: labelFor(phase), install }; +} From 7c798411a58a943bdc673b8f5d2526cc21f1d7a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81rgio=20Santos?= Date: Fri, 6 Nov 2026 10:00:03 +0000 Subject: [PATCH 2/2] chore: release 1.1.0 --- CHANGELOG.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4216bf9..76b7d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-11-06 + ### Added - Update checks: tskdeck looks for a newer release one minute after launch and then once a day, and a card at the foot of the sidebar installs it and relaunches the app, or hides it with Later until the app quits. @@ -44,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A content security policy for the app's windows, and an absolute path required for the `tsk` binary in Settings. -[Unreleased]: https://github.com/s3rgiosan/tskdeck/compare/1.0.1...HEAD +[Unreleased]: https://github.com/s3rgiosan/tskdeck/compare/1.1.0...HEAD +[1.1.0]: https://github.com/s3rgiosan/tskdeck/compare/1.0.1...1.1.0 [1.0.1]: https://github.com/s3rgiosan/tskdeck/compare/1.0.0...1.0.1 [1.0.0]: https://github.com/s3rgiosan/tskdeck/releases/tag/1.0.0 diff --git a/package-lock.json b/package-lock.json index 7c47a52..ef008d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tskdeck", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tskdeck", - "version": "1.0.1", + "version": "1.1.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.8.4", diff --git a/package.json b/package.json index e0de6d5..811df18 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tskdeck", "private": true, - "version": "1.0.1", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ecbf59e..8399fdb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4383,7 +4383,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tskdeck" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 978d09c..3db2c33 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tskdeck" -version = "1.0.1" +version = "1.1.0" description = "Unofficial desktop client for the tsk task board" authors = ["Sérgio Santos"] edition = "2024" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6bf7263..b6893c6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "tskdeck", - "version": "1.0.1", + "version": "1.1.0", "identifier": "com.s3rgiosan.tskdeck", "build": { "beforeDevCommand": "npm run dev",