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..76b7d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ 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. +- 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 @@ -38,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/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/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 6b285ac..8399fdb 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" @@ -4107,8 +4383,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tskdeck" -version = "1.0.1" +version = "1.1.0" 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..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" @@ -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..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", @@ -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 }; +}