From 4b2248655b45a2a9b965a0a1bfe5173f9e520039 Mon Sep 17 00:00:00 2001 From: KanjiG Date: Tue, 28 Jul 2026 21:38:49 +0200 Subject: [PATCH] Add auto-update support (tauri-plugin-updater) and Gitea release pipeline Ed25519-signed updates, checked via a fixed-tag latest.json manifest on Gitea Releases; scripts/release.sh automates build+sign+publish. Adds a manual "Nach Updates suchen" trigger plus a silent startup check. --- package-lock.json | 20 ++ package.json | 4 +- scripts/release.sh | 173 ++++++++++++++++++ src-tauri/Cargo.lock | 272 ++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 4 +- src-tauri/src/lib.rs | 2 + src-tauri/tauri.conf.json | 9 + src/App.tsx | 26 +++ src/components/Ribbon.tsx | 3 + src/lib/updater.ts | 27 +++ 11 files changed, 540 insertions(+), 2 deletions(-) create mode 100755 scripts/release.sh create mode 100644 src/lib/updater.ts diff --git a/package-lock.json b/package-lock.json index db33f0b..1b50f40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", "lucide-react": "^1.27.0", @@ -1500,6 +1502,24 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/package.json b/package.json index 198b4da..084dc68 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mail-client", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", @@ -13,6 +13,8 @@ "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", "lucide-react": "^1.27.0", diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..4cac924 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Builds, signs, and publishes a release to Gitea (git.kanjiv.at/KanjiG/mail-client). +# +# Usage: scripts/release.sh [notes] +# scripts/release.sh 0.2.0 "Fixed X, added Y" +# +# Requires: +# - TAURI_SIGNING_PRIVATE_KEY_PATH pointing at the updater signing key +# (default: ~/.tauri/mail-client-update.key) +# - GITEA_TOKEN or GITEA_PASSWORD env var for authenticating to the Gitea API +# (GITEA_TOKEN preferred — a Gitea access token instead of the account password) +# +# Only builds for the platform it's run on. To publish Windows/macOS builds, +# run this same script on/from those platforms with the same version — it +# appends to the same release rather than overwriting it, and regenerates +# latest.json to include whichever platform artifacts are present so far. + +set -euo pipefail + +VERSION="${1:?Usage: scripts/release.sh [notes]}" +NOTES="${2:-Release $VERSION}" +REPO_OWNER="KanjiG" +REPO_NAME="mail-client" +GITEA_URL="https://git.kanjiv.at" +TAG="v$VERSION" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" + +export TAURI_SIGNING_PRIVATE_KEY_PATH="${TAURI_SIGNING_PRIVATE_KEY_PATH:-$HOME/.tauri/mail-client-update.key}" +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" + +if [ -z "${GITEA_TOKEN:-}" ] && [ -z "${GITEA_PASSWORD:-}" ]; then + echo "Set GITEA_TOKEN (preferred) or GITEA_PASSWORD before running this." >&2 + exit 1 +fi +AUTH_HEADER=() +if [ -n "${GITEA_TOKEN:-}" ]; then + AUTH_HEADER=(-H "Authorization: token $GITEA_TOKEN") +else + AUTH_HEADER=(-u "$REPO_OWNER:$GITEA_PASSWORD") +fi + +echo "==> Syncing version to $VERSION in package.json / tauri.conf.json" +node -e " + const fs = require('fs'); + for (const f of ['package.json', 'src-tauri/tauri.conf.json']) { + const j = JSON.parse(fs.readFileSync(f, 'utf8')); + j.version = '$VERSION'; + fs.writeFileSync(f, JSON.stringify(j, null, 2) + '\n'); + } +" +sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml + +echo "==> Building frontend" +npm run build + +echo "==> Building + signing Tauri bundle" +npx tauri build + +BUNDLE_DIR="src-tauri/target/release/bundle" + +# Figure out which platform we're on and where the updater-relevant artifact + +# signature landed. Tauri's updater only consumes specific bundle formats per +# platform (AppImage on Linux, NSIS on Windows, .app.tar.gz on macOS) — not +# .deb/.rpm, those are for manual installs only. +UNAME="$(uname -s)" +PLATFORM_KEY="" +ARTIFACT="" +case "$UNAME" in + Linux) + PLATFORM_KEY="linux-x86_64" + ARTIFACT="$(find "$BUNDLE_DIR/appimage" -name '*.AppImage' | head -1)" + ;; + Darwin) + ARCH="$(uname -m)" + PLATFORM_KEY="darwin-$([ "$ARCH" = "arm64" ] && echo aarch64 || echo x86_64)" + ARTIFACT="$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' | head -1)" + ;; + MINGW*|MSYS*|CYGWIN*) + PLATFORM_KEY="windows-x86_64" + ARTIFACT="$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' | head -1)" + ;; + *) + echo "Unrecognized platform: $UNAME" >&2 + exit 1 + ;; +esac + +if [ -z "$ARTIFACT" ] || [ ! -f "$ARTIFACT" ]; then + echo "Could not find the updater artifact for $PLATFORM_KEY under $BUNDLE_DIR" >&2 + exit 1 +fi +SIGNATURE="$(cat "$ARTIFACT.sig")" +FILENAME="$(basename "$ARTIFACT")" + +echo "==> Ensuring release $TAG exists on Gitea" +RELEASE_JSON="$(curl -s "${AUTH_HEADER[@]}" "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/tags/$TAG")" +RELEASE_ID="$(echo "$RELEASE_JSON" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id' 2>/dev/null || echo "")" +if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "undefined" ]; then + RELEASE_JSON="$(curl -s "${AUTH_HEADER[@]}" -X POST "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases" \ + -H "Content-Type: application/json" \ + -d "$(node -pe "JSON.stringify({tag_name: '$TAG', name: '$TAG', body: process.argv[1]})" "$NOTES")") + RELEASE_ID="$(echo "$RELEASE_JSON" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id')" +fi +echo " release id: $RELEASE_ID" + +echo "==> Uploading $FILENAME" +curl -s "${AUTH_HEADER[@]}" -X POST \ + "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/$RELEASE_ID/assets?name=$FILENAME" \ + -F "attachment=@$ARTIFACT" > /dev/null + +# latest.json lives on a SEPARATE, fixed-tag release ("latest-manifest"), +# independent of the versioned release tags above. The updater's endpoint in +# tauri.conf.json points at this fixed tag, not at "latest" — Gitea's support +# for a GitHub-style /releases/latest/download/ alias varies by version, so a +# fixed tag we fully control is more robust than depending on that. +MANIFEST_TAG="latest-manifest" +echo "==> Ensuring $MANIFEST_TAG release exists" +MANIFEST_RELEASE_JSON="$(curl -s "${AUTH_HEADER[@]}" "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/tags/$MANIFEST_TAG")" +MANIFEST_RELEASE_ID="$(echo "$MANIFEST_RELEASE_JSON" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id' 2>/dev/null || echo "")" +if [ -z "$MANIFEST_RELEASE_ID" ] || [ "$MANIFEST_RELEASE_ID" = "undefined" ]; then + MANIFEST_RELEASE_JSON="$(curl -s "${AUTH_HEADER[@]}" -X POST "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases" \ + -H "Content-Type: application/json" \ + -d "$(node -pe "JSON.stringify({tag_name: process.argv[1], name: 'Update manifest (do not download directly)', body: 'Holds latest.json for the in-app updater. Always points at the newest release.'})" "$MANIFEST_TAG")") + MANIFEST_RELEASE_ID="$(echo "$MANIFEST_RELEASE_JSON" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id')" +fi + +echo "==> Fetching existing latest.json (if any) to merge platforms" +EXISTING_LATEST="$(curl -sf "$GITEA_URL/$REPO_OWNER/$REPO_NAME/releases/download/$MANIFEST_TAG/latest.json" || echo '{}')" + +TMP_LATEST="$(mktemp)" +node -e " + const fs = require('fs'); + let existing = {}; + try { existing = JSON.parse(\`$EXISTING_LATEST\`); } catch { existing = {}; } + const platforms = existing.platforms || {}; + platforms['$PLATFORM_KEY'] = { + signature: \`$SIGNATURE\`, + url: '$GITEA_URL/$REPO_OWNER/$REPO_NAME/releases/download/$TAG/$FILENAME', + }; + const manifest = { + version: '$VERSION', + notes: $(node -pe "JSON.stringify(process.argv[1])" "$NOTES"), + pub_date: new Date().toISOString(), + platforms, + }; + fs.writeFileSync('$TMP_LATEST', JSON.stringify(manifest, null, 2)); +" + +# Remove any previous latest.json asset before re-uploading (Gitea won't +# overwrite an existing asset with the same name). +EXISTING_ASSET_ID="$(curl -s "${AUTH_HEADER[@]}" "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/$MANIFEST_RELEASE_ID" \ + | node -pe " + const rel = JSON.parse(require('fs').readFileSync(0)); + const a = (rel.assets || []).find(x => x.name === 'latest.json'); + a ? a.id : '' + " 2>/dev/null || echo "")" +if [ -n "$EXISTING_ASSET_ID" ] && [ "$EXISTING_ASSET_ID" != "undefined" ]; then + curl -s "${AUTH_HEADER[@]}" -X DELETE \ + "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/$MANIFEST_RELEASE_ID/assets/$EXISTING_ASSET_ID" > /dev/null +fi + +echo "==> Uploading latest.json to $MANIFEST_TAG" +curl -s "${AUTH_HEADER[@]}" -X POST \ + "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/$MANIFEST_RELEASE_ID/assets?name=latest.json" \ + -F "attachment=@$TMP_LATEST" > /dev/null +rm -f "$TMP_LATEST" + +echo "==> Done." +echo " Release: $GITEA_URL/$REPO_OWNER/$REPO_NAME/releases/tag/$TAG" +echo " Manifest URL: $GITEA_URL/$REPO_OWNER/$REPO_NAME/releases/download/$MANIFEST_TAG/latest.json" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0b66154..e76651b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -117,6 +117,8 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-notification", + "tauri-plugin-process", + "tauri-plugin-updater", "thiserror 1.0.69", "tokio", "uuid", @@ -134,6 +136,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -815,6 +826,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1155,6 +1177,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.9" @@ -1757,6 +1789,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -2372,6 +2419,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2748,6 +2801,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.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2870,6 +2935,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.19", +] + [[package]] name = "ouroboros" version = "0.18.5" @@ -3380,15 +3459,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3424,6 +3508,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.31.0" @@ -3466,6 +3564,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3475,6 +3599,44 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4103,6 +4265,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" @@ -4320,6 +4493,49 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.19", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4588,6 +4804,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -4901,6 +5127,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -5166,6 +5398,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5409,6 +5650,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5751,6 +6001,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yansi" version = "1.0.1" @@ -5932,6 +6192,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1966ffe..0a11e4d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,6 +25,8 @@ tauri = { version = "2.11.3", features = [] } tauri-plugin-log = "2" tauri-plugin-dialog = "2" tauri-plugin-notification = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" tokio = { version = "1", features = ["full"] } imap = "3.0.0-alpha.15" native-tls = "0.2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 876d0b2..8b8c37b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,8 @@ "notification:default", "core:webview:allow-create-webview-window", "core:window:allow-close", - "core:window:allow-set-focus" + "core:window:allow-set-focus", + "updater:default", + "process:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 473383e..07ba4d7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,6 +27,8 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7ea013b..eae92b3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,6 +28,7 @@ "bundle": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -38,5 +39,13 @@ "android": { "debugApplicationIdSuffix": ".debug" } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZCQTc0QThDNzI0RURGODAKUldTQTMwNXlqRXFuYXdsbm5nMUhBMVpzUVRxdTlzcDUvS3g1SGdHSjFPNkh3cW1rSUN5MVlyMkQK", + "endpoints": [ + "https://git.kanjiv.at/KanjiG/mail-client/releases/download/latest-manifest/latest.json" + ] + } } } diff --git a/src/App.tsx b/src/App.tsx index 34ba64e..fcc00e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,6 +45,7 @@ import { api } from './lib/api' import { parseAddressList } from './lib/addresses' import { escapeHtml } from './lib/richtext' import { loadAppSettings, saveAppSettings, type AppSettings } from './lib/settings' +import { checkForUpdate, installUpdate } from './lib/updater' import { applyTheme, loadConversationsEnabled, @@ -253,6 +254,11 @@ export default function App() { setTimeout(() => setToast(null), 3500) }, []) + useEffect(() => { + handleCheckForUpdates(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + useEffect(() => { applyTheme(theme) if (theme !== 'system') return @@ -443,6 +449,25 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [view, selectedMessage]) + async function handleCheckForUpdates(silent = false) { + const update = await checkForUpdate() + if (!update) { + if (!silent) showToast('Du hast schon die neueste Version') + return + } + if (!window.confirm(`Version ${update.version} ist verfügbar (aktuell: ${update.currentVersion}).\n\n${update.body ?? ''}\n\nJetzt installieren? Die App startet danach neu.`)) { + return + } + showToast('Update wird heruntergeladen…') + try { + await installUpdate(update, (pct) => { + if (pct !== null) showToast(`Update wird installiert… ${pct}%`) + }) + } catch (e) { + showToast(`Update fehlgeschlagen: ${e}`) + } + } + function handleRefresh() { if (offlineMode) { showToast('Offline-Modus aktiv') @@ -1818,6 +1843,7 @@ export default function App() { onSyncGoogleCalendar={handleSyncGoogleCalendar} onReconnectGoogleAccount={handleReconnectGoogleAccount} onOpenCaldav={() => setCaldavModalOpen(true)} + onCheckForUpdates={handleCheckForUpdates} theme={theme} onThemeChange={handleThemeChange} onOpenSettings={() => setSettingsModalOpen(true)} diff --git a/src/components/Ribbon.tsx b/src/components/Ribbon.tsx index e0e5fa7..e5a63af 100644 --- a/src/components/Ribbon.tsx +++ b/src/components/Ribbon.tsx @@ -273,6 +273,7 @@ export function Ribbon({ onSyncGoogleCalendar, onReconnectGoogleAccount, onOpenCaldav, + onCheckForUpdates, theme, onThemeChange, onOpenSettings, @@ -337,6 +338,7 @@ export function Ribbon({ onSyncGoogleCalendar?: (accountId: string) => Promise onReconnectGoogleAccount?: (accountId: string) => Promise onOpenCaldav?: () => void + onCheckForUpdates: () => void theme: Theme onThemeChange: (t: Theme) => void onOpenSettings: () => void @@ -556,6 +558,7 @@ export function Ribbon({ + )} diff --git a/src/lib/updater.ts b/src/lib/updater.ts new file mode 100644 index 0000000..4aacd79 --- /dev/null +++ b/src/lib/updater.ts @@ -0,0 +1,27 @@ +import { check, type Update } from '@tauri-apps/plugin-updater' +import { relaunch } from '@tauri-apps/plugin-process' + +export async function checkForUpdate(): Promise { + try { + return await check() + } catch { + return null + } +} + +export async function installUpdate(update: Update, onProgress: (pct: number | null) => void): Promise { + let total = 0 + let downloaded = 0 + await update.downloadAndInstall((event) => { + if (event.event === 'Started') { + total = event.data.contentLength ?? 0 + onProgress(total > 0 ? 0 : null) + } else if (event.event === 'Progress') { + downloaded += event.data.chunkLength + onProgress(total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null) + } else if (event.event === 'Finished') { + onProgress(100) + } + }) + await relaunch() +}