feat(desktop): Update in der App — Herunterladen, Installieren und Neustart per Klick im Infobereich (tauri-plugin-updater)

- tauri-plugin-updater 2.11 + semver eingebunden; pubkey, installMode passive
  und createUpdaterArtifacts in tauri.conf.json (kein endpoints, keine
  dangerous*-Schalter)
- Versionspruefung laeuft ueber updater_builder().check() gegen
  /api-proxy/desktop/update mit eigenem version_comparator (is_update_newer:
  hoehere Basis oder anderer Beta-Stempel beta.g<sha7>; gleiche Basis Live
  → kein Update); 15 s Pruefung, 600 s Download-Timeout
- Tray-Eintrag "Update installieren" laedt mit Fortschritt im Menuetext,
  prueft die minisign-Signatur, installiert (Windows NSIS passiv, Linux
  AppImage an Ort und Stelle) und startet neu; Fehler → Benachrichtigung
  und Rueckfall auf die Einstellungsseite im Browser
- http-Server im Release: Menuetext "Update nur über https möglich", gesperrt
- 15 neue Tests, 2 umgestellt (33 gesamt); gen/schemas vom Bau nachgezogen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 15:30:28 +02:00
parent 5a444ec8f2
commit 678ba51725
7 changed files with 806 additions and 68 deletions
+310 -6
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[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"
@@ -691,6 +700,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.118",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1004,6 +1024,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"
@@ -1884,6 +1914,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.118",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -2087,6 +2147,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[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"
@@ -2366,6 +2432,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.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2499,6 +2577,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.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2988,15 +3080,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",
@@ -3057,12 +3154,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
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.14.1"
@@ -3072,6 +3182,33 @@ 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 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -3425,6 +3562,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3647,7 +3800,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3680,6 +3833,17 @@ dependencies = [
"syn 2.0.118",
]
[[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"
@@ -3703,7 +3867,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3886,6 +4050,39 @@ dependencies = [
"tracing",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"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.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
@@ -3911,7 +4108,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3934,7 +4131,7 @@ checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -4041,6 +4238,7 @@ name = "tessera-desktop"
version = "1.1.0"
dependencies = [
"reqwest 0.12.28",
"semver",
"serde",
"serde_json",
"tauri",
@@ -4049,6 +4247,7 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
]
@@ -4767,6 +4966,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"
@@ -5026,6 +5234,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5059,13 +5276,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -5096,6 +5330,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5108,6 +5348,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5120,12 +5366,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5138,6 +5396,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5150,6 +5414,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5162,6 +5432,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5174,6 +5450,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -5247,7 +5529,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -5294,6 +5576,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5458,6 +5750,18 @@ dependencies = [
"syn 2.0.118",
]
[[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.21"
+2
View File
@@ -20,3 +20,5 @@ reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
semver = "1"
File diff suppressed because one or more lines are too long
@@ -2822,6 +2822,60 @@
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",
@@ -2822,6 +2822,60 @@
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",
+376 -61
View File
@@ -1,3 +1,5 @@
use semver::Version;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{
menu::{CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder},
@@ -8,24 +10,32 @@ use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_store::StoreExt;
use tauri_plugin_updater::{Update, UpdaterExt};
/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version
/// gefunden wurde. `spawn_version_check` setzt den Eintrag nach einem
/// Serverwechsel hierauf zurück, damit kein Hinweis des alten Servers stehen
/// bleibt.
const UPDATE_ITEM_DEFAULT: &str = "Update herunterladen";
/// gefunden wurde. Der Eintrag installiert das Update seit quick-260917-kgc
/// direkt in der App (Download, Signaturpruefung, Installation, Neustart);
/// den Browser oeffnet er nur noch als Rueckfall. `spawn_version_check`
/// setzt den Eintrag nach einem Serverwechsel hierauf zurück, damit kein
/// Hinweis des alten Servers stehen bleibt.
const UPDATE_ITEM_DEFAULT: &str = "Update installieren";
/// Menuetext, wenn die gespeicherte Server-Adresse mit `http://` beginnt:
/// das Updater-Plugin verweigert im Release-Bau unverschluesselte Endpunkte
/// (`Error::InsecureTransportProtocol`). Der Eintrag bleibt gesperrt, der
/// Text erklaert dauerhaft, warum -- keine Benachrichtigung bei jedem Start.
const UPDATE_ITEM_INSECURE: &str = "Update nur über https möglich";
#[derive(serde::Deserialize)]
struct VersionResponse {
version: String,
}
#[derive(serde::Deserialize)]
struct DesktopLatest {
version: String,
channel: String,
commit: String,
}
/// Gefundenes Update samt dem Menuetext, der zu ihm gehoert. Wird von
/// `spawn_version_check` befuellt und vom Tray-Klick "update" per `take()`
/// entnommen -- so kann ein Doppelklick keinen zweiten Download starten.
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
struct PendingUpdate(Mutex<Option<(Update, String)>>);
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
@@ -41,10 +51,10 @@ fn api_url(server: &str, path: &str) -> String {
/// NUR in die Navigation, niemals in den gespeicherten Server-Wert
/// (`server_url` im Store bleibt die reine Adresse); daraus liest die
/// Next.js-Middleware (apps/web/src/middleware.ts) das Cookie
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Die
/// Tray-URL "Update herunterladen" bekommt den Parameter bewusst NICHT --
/// sie oeffnet im System-Browser, dort muessen die Download-Links sichtbar
/// bleiben.
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Der
/// Browser-Rueckfall des Tray-Eintrags "Update installieren"
/// (`open_download_page`) bekommt den Parameter bewusst NICHT -- er oeffnet
/// im System-Browser, dort muessen die Download-Links sichtbar bleiben.
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
let mut marked = url.clone();
marked.query_pairs_mut().append_pair("desktop", "1");
@@ -54,29 +64,105 @@ fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
/// Liefert (Menuetext, Benachrichtigungstext) fuer den Update-Hinweis. Der
/// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version (D-07,
/// desktop-collect.sh) -- hat sich nur der Commit geaendert, waere der reine
/// Versionstext ("Version 1.1.0 herunterladen") verwirrend, weil der Client
/// bereits genau diese Version hat. Der zweite Zweig nennt darum den
/// Commit-Stempel statt der unveraenderten Version.
/// Versionstext ("Auf Version 1.1.0 aktualisieren") verwirrend, weil der
/// Client bereits genau diese Version hat. Der zweite Zweig nennt darum den
/// Commit-Stempel statt der unveraenderten Version. Die Texte sprechen vom
/// Aktualisieren, nicht vom Herunterladen: der Klick installiert das Update
/// in der App (quick-260917-kgc).
fn update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String) {
if version_changed {
(
format!("Version {} herunterladen", version),
format!("Auf Version {} aktualisieren", version),
format!(
"Neue Version {} verfügbar – Download über das Symbol im Infobereich.",
"Neue Version {} verfügbar – Aktualisieren über das Symbol im Infobereich.",
version
),
)
} else {
(
"Neuen Beta-Stand herunterladen".to_string(),
format!("Auf Beta-Stand {} aktualisieren", commit),
format!(
"Neuer Beta-Stand {} verfügbar – Download über das Symbol im Infobereich.",
"Neuer Beta-Stand {} verfügbar – Aktualisieren über das Symbol im Infobereich.",
commit
),
)
}
}
/// Liest den Beta-Stempel `beta.g<sha7>` aus dem Prerelease-Teil einer
/// Server-Version (`1.2.0-beta.gabc1234` → `abc1234`). Das `g` ist Pflicht:
/// ein rein numerischer SHA mit fuehrender Null (`0123456`) waere kein
/// gueltiger SemVer-Identifier, `check()` endete dann mit Err (Recherche
/// Pitfall 2). Live-Versionen ohne Prerelease und fremde Formate (`rc.1`)
/// liefern None.
fn beta_commit(release: &Version) -> Option<&str> {
release
.pre
.as_str()
.strip_prefix("beta.g")
.filter(|sha| !sha.is_empty())
}
/// Vergleichsregel des Updaters -- ersetzt den Standardvergleich des Plugins
/// vollstaendig (updater.rs Z. 576-579; SemVer allein saehe
/// `1.2.0-beta.gXXXX < 1.2.0` und ein Beta-Client bekaeme nie einen neueren
/// Beta-Bau). Regel:
/// - Basisversion X.Y.Z des Servers groesser → Update; kleiner → keins.
/// - Gleiche Basis mit Beta-Stempel `beta.g<sha7>` → Update genau dann,
/// wenn der Client einen Commit-Stempel kennt und er sich unterscheidet.
/// Der Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version
/// (D-07); ohne den Commit-Vergleich saehe ein Beta-Client zwischen zwei
/// Freigabe-Tags nie einen neueren Bau (WR-02, Code-Review Phase 18).
/// - Gleiche Basis ohne Prerelease (Live-Freigabe) → KEIN Update. Das ist die
/// bisherige Regel: ein Live-Client 1.2.0 bietet sich nie selbst 1.2.0 an
/// (keine Update-Schleife). Der Fall "Beta-Client wird auf einen Live-Server
/// derselben Basis umgestellt" ist client-seitig nicht entscheidbar (der
/// Client kennt nur CARGO_PKG_VERSION und APP_COMMIT, nicht den Tag-Commit
/// der Freigabe) und bekommt das Update mit der naechsten Freigabe.
/// - Leerer Client-Commit (Quell-Tarball ohne git) → nur Basisvergleich.
fn is_update_newer(current: &Version, current_commit: &str, release: &Version) -> bool {
let base = |v: &Version| (v.major, v.minor, v.patch);
if base(release) > base(current) {
return true;
}
if base(release) < base(current) {
return false;
}
match beta_commit(release) {
Some(sha) => !current_commit.is_empty() && sha != current_commit,
None => false,
}
}
/// Baut die Endpunkt-Adresse fuer das Updater-Plugin aus der gespeicherten
/// Server-Adresse. Die Platzhalter `{{target}}`, `{{arch}}` und
/// `{{current_version}}` stehen als Rohtext im Wert; `query_pairs_mut`
/// kodiert sie zu `%7B%7B…%7D%7D`, und das Plugin ersetzt beide Schreibweisen
/// (updater.rs Z. 476-486). `base` ist der Origin, aus dem die API die
/// absolute Download-Adresse bildet -- das Plugin verlangt eine `url::Url`,
/// relative Pfade scheitern. `api_url` bleibt die einzige Stelle mit dem
/// `/api-proxy`-Praefix.
fn update_endpoint(server: &str) -> Option<tauri::Url> {
let mut url = tauri::Url::parse(&api_url(server, "/desktop/update")).ok()?;
url.query_pairs_mut()
.append_pair("target", "{{target}}")
.append_pair("arch", "{{arch}}")
.append_pair("current", "{{current_version}}")
.append_pair("base", server.trim_end_matches('/'));
Some(url)
}
/// Leitet (Menuetext, Benachrichtigungstext) aus Client- und Server-Version
/// ab: eine neue Basis X.Y.Z wird als Version genannt (auch bei Beta), bei
/// gleicher Basis der Beta-Stempel.
fn release_labels(current: &Version, release: &Version) -> (String, String) {
let base = |v: &Version| (v.major, v.minor, v.patch);
let version_changed = base(release) != base(current);
let version = format!("{}.{}.{}", release.major, release.minor, release.patch);
let commit = beta_commit(release).unwrap_or("");
update_labels(version_changed, &version, commit)
}
/// Parst eine vom Nutzer eingegebene Server-Adresse und erlaubt nur http/https
/// -- dieselben Fehlertexte wie bisher inline in `check_server`. `check_server`,
/// `save_server_url` und `open_server` rufen ausschliesslich diesen Helfer;
@@ -174,43 +260,159 @@ fn apply_server(app: &AppHandle, url: Option<&str>) {
let _ = app.state::<TrayItems>().connected.set_text(line);
}
/// Fragt den Update-Endpunkt ueber das Updater-Plugin ab. Der eigene
/// `version_comparator` (`is_update_newer`) ersetzt den Standardvergleich
/// des Plugins. Die 15 s gelten fuer die Pruefung; das Plugin uebernimmt
/// denselben Wert als GESAMT-Timeout des Downloads (updater.rs Z. 698-700),
/// darum setzt `spawn_version_check` vor der Ablage `update.timeout` neu.
async fn check_for_update(
app: &AppHandle,
endpoint: tauri::Url,
) -> tauri_plugin_updater::Result<Option<Update>> {
let current_commit = env!("APP_COMMIT");
app.updater_builder()
.endpoints(vec![endpoint])?
.timeout(Duration::from_secs(15))
.version_comparator(move |current, release| {
is_update_newer(&current, current_commit, &release.version)
})
.build()?
.check()
.await
}
/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem
/// Serverwechsel erneut laufen kann (async, non-blocking wie bisher). Setzt
/// den Update-Eintrag zuerst auf den Standardtext zurück -- nach einem
/// Wechsel darf kein Hinweis des alten Servers stehen bleiben.
/// den Update-Eintrag zuerst auf den Standardtext zurück und leert den
/// abgelegten Update-Stand -- nach einem Wechsel darf kein Hinweis des alten
/// Servers stehen bleiben.
fn spawn_version_check(app: AppHandle, server_url: String) {
let update_item = app.state::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_DEFAULT);
let _ = update_item.set_enabled(false);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = None;
}
// Gespeicherte Adressen sind immer parsebar (parse_server_url) -- reine
// Absicherung.
let Some(endpoint) = update_endpoint(&server_url) else {
return;
};
// Cargo garantiert eine gueltige SemVer-Version; desktop-version.sh
// schreibt immer reines X.Y.Z (D-07).
let current = Version::parse(env!("CARGO_PKG_VERSION"))
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
let app_version = env!("CARGO_PKG_VERSION").to_string();
let app_commit = env!("APP_COMMIT").to_string();
tauri::async_runtime::spawn(async move {
let url = api_url(&server_url, "/desktop/latest");
if let Ok(resp) = reqwest::get(&url).await {
if let Ok(info) = resp.json::<DesktopLatest>().await {
// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version
// (D-07, desktop-collect.sh) -- ohne den Commit-Vergleich saehe
// ein Beta-Client zwischen zwei Freigabe-Tags nie einen neueren
// Bau (WR-02, Code-Review Phase 18). Fuer den Live-Kanal bleibt
// es beim reinen Versionsvergleich. Die Texte fuer Menue und
// Benachrichtigung liefert `update_labels`.
let version_changed = info.version != app_version;
let is_newer =
version_changed || (info.channel == "beta" && info.commit != app_commit);
if is_newer {
let (menu_text, body) =
update_labels(version_changed, &info.version, &info.commit);
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
let _ = update_item.set_text(menu_text);
let _ = update_item.set_enabled(true);
match check_for_update(&app, endpoint).await {
Ok(Some(mut update)) => {
let (menu_text, body) = Version::parse(&update.version)
.map(|release| release_labels(&current, &release))
.unwrap_or_else(|_| update_labels(true, &update.version, ""));
// 15 s reichen fuer die Pruefung, nicht fuer den ~100-MB-Download:
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
// Downloads an (updater.rs Z. 698-700).
update.timeout = Some(Duration::from_secs(600));
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
let _ = update_item.set_text(&menu_text);
let _ = update_item.set_enabled(true);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text));
}
}
Ok(None) => {}
// Der Release-Bau verweigert http-Endpunkte (config.rs
// `validate_endpoints`), der Debug-Bau warnt nur. Keine
// `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der
// Eintrag bleibt gesperrt und erklaert den Grund.
Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => {
let _ = update_item.set_text(UPDATE_ITEM_INSECURE);
}
Err(_) => {}
}
});
}
/// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der
/// bisherige Weg des Tray-Eintrags, jetzt Rueckfall, wenn kein Update
/// abgelegt ist oder die Installation in der App fehlschlaegt. Bewusst ohne
/// `desktop=1` (siehe `with_desktop_marker`).
fn open_download_page(app: &AppHandle) {
if let Some(server) = stored_server_url(app) {
let target = format!("{}/settings/general/desktop", server.trim_end_matches('/'));
let _ = app.opener().open_url(target, None::<&str>);
}
}
/// Laedt das abgelegte Update, prueft die Signatur, installiert es und
/// startet die App neu. Fortschritt und Zustand stehen im Menuetext des
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
/// Rueckfall.
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
let item = app.state::<TrayItems>().update.clone();
let _ = item.set_enabled(false);
let _ = item.set_text("Update wird heruntergeladen…");
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body("Update wird heruntergeladen…")
.show();
tauri::async_runtime::spawn(async move {
let item_progress = item.clone();
let item_finish = item.clone();
let mut received: u64 = 0;
let mut last_percent: Option<u64> = None;
let on_chunk = move |chunk: usize, total: Option<u64>| {
received += chunk as u64;
if let Some(total) = total.filter(|t| *t > 0) {
let percent = (received * 100 / total).min(100);
if last_percent != Some(percent) {
last_percent = Some(percent);
let _ = item_progress.set_text(format!("Lädt … {} %", percent));
}
}
};
let on_finish = move || {
let _ = item_finish.set_text("Wird installiert…");
};
match update.download_and_install(on_chunk, on_finish).await {
Ok(()) => {
// Windows: nie erreicht -- das Plugin startet den NSIS-Installer
// mit `/P /UPDATE /R /ARGS` und beendet den Prozess per
// `std::process::exit(0)`; der Run-Handler wird dabei nicht
// durchlaufen. Linux: die AppImage ist ersetzt; `restart()` aus
// dem Nebenthread laeuft ueber RESTART_EXIT_CODE
// (`code: Some(..)`) am `prevent_exit` des Run-Handlers vorbei
// (tauri app.rs Z. 588-611).
app.restart();
}
Err(e) => {
let _ = item.set_text(&menu_text);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text.clone()));
}
let _ = item.set_enabled(true);
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(format!(
"Update fehlgeschlagen: {}. Die Download-Seite wird im Browser geöffnet.",
e
))
.show();
open_download_page(&app);
}
}
});
}
@@ -298,6 +500,7 @@ pub fn run() {
None,
))
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
check_server,
save_server_url,
@@ -316,11 +519,13 @@ pub fn run() {
}
// Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen ·
// Server-Adresse ändern... · Update herunterladen · — ·
// Server-Adresse ändern... · Update installieren · — ·
// Autostart-Haken · — · Beenden.
// "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis
// die Versionspruefung eine neuere Version findet; "autostart"
// spiegelt den tatsaechlichen Systemzustand beim Start.
// die Versionspruefung eine neuere Version findet, und heisst dann
// "Auf Version X.Y.Z aktualisieren" bzw. "Auf Beta-Stand ...
// aktualisieren"; "autostart" spiegelt den tatsaechlichen
// Systemzustand beim Start.
let connected =
MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1)
.enabled(false)
@@ -357,6 +562,7 @@ pub fn run() {
connected: connected.clone(),
update: update.clone(),
});
app.manage(PendingUpdate(Mutex::new(None)));
let autostart_for_menu = autostart.clone();
@@ -383,12 +589,20 @@ pub fn run() {
}
}
"update" => {
if let Some(server) = stored_server_url(app) {
let target = format!(
"{}/settings/general/desktop",
server.trim_end_matches('/')
);
let _ = app.opener().open_url(target, None::<&str>);
// `take()` verhindert Doppelklick-Downloads, der
// gesperrte Eintrag ebenso; ohne abgelegten Stand
// bleibt der Browser-Weg (Einstellungen → Desktop-App).
let pending = app
.state::<PendingUpdate>()
.0
.lock()
.ok()
.and_then(|mut guard| guard.take());
match pending {
Some((update, menu_text)) => {
spawn_update_install(app.clone(), update, menu_text)
}
None => open_download_page(app),
}
}
"autostart" => {
@@ -489,23 +703,124 @@ mod tests {
#[test]
fn update_labels_bei_versionswechsel() {
let (menu_text, body) = update_labels(true, "1.2.0", "abc1234");
assert_eq!(menu_text, "Version 1.2.0 herunterladen");
assert_eq!(menu_text, "Auf Version 1.2.0 aktualisieren");
assert_eq!(
body,
"Neue Version 1.2.0 verfügbar – Download über das Symbol im Infobereich."
"Neue Version 1.2.0 verfügbar – Aktualisieren über das Symbol im Infobereich."
);
}
#[test]
fn update_labels_bei_gleicher_version_beta_commit() {
let (menu_text, body) = update_labels(false, "1.1.0", "abc1234");
assert_eq!(menu_text, "Neuen Beta-Stand herunterladen");
assert_eq!(menu_text, "Auf Beta-Stand abc1234 aktualisieren");
assert_eq!(
body,
"Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich."
"Neuer Beta-Stand abc1234 verfügbar – Aktualisieren über das Symbol im Infobereich."
);
}
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
#[test]
fn is_update_newer_hoehere_basis_live() {
assert!(is_update_newer(&v("1.2.0"), "abc1234", &v("1.3.0")));
}
#[test]
fn is_update_newer_hoehere_basis_beta() {
assert!(is_update_newer(
&v("1.2.0"),
"abc1234",
&v("1.3.0-beta.gdef5678")
));
}
#[test]
fn is_update_newer_niedrigere_basis() {
assert!(!is_update_newer(
&v("1.3.0"),
"abc1234",
&v("1.2.0-beta.g9999999")
));
}
#[test]
fn is_update_newer_gleiche_basis_anderer_beta_stempel() {
assert!(is_update_newer(
&v("1.2.0"),
"abc1234",
&v("1.2.0-beta.gdef5678")
));
}
#[test]
fn is_update_newer_gleiche_basis_gleicher_beta_stempel() {
assert!(!is_update_newer(
&v("1.2.0"),
"abc1234",
&v("1.2.0-beta.gabc1234")
));
}
#[test]
fn is_update_newer_gleiche_basis_live_ohne_prerelease() {
assert!(!is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0")));
}
#[test]
fn is_update_newer_leerer_client_commit_nur_basisvergleich() {
assert!(!is_update_newer(&v("1.2.0"), "", &v("1.2.0-beta.gdef5678")));
}
#[test]
fn is_update_newer_fremdes_prerelease_format() {
assert!(!is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0-rc.1")));
}
#[test]
fn beta_commit_mit_praefix_g_erlaubt_fuehrende_null() {
assert!(Version::parse("1.2.0-beta.g0123456").is_ok());
assert_eq!(beta_commit(&v("1.2.0-beta.g0123456")), Some("0123456"));
}
#[test]
fn beta_commit_none_ohne_prerelease() {
assert_eq!(beta_commit(&v("1.2.0")), None);
}
#[test]
fn beta_commit_none_bei_fremdem_prerelease() {
assert_eq!(beta_commit(&v("1.2.0-rc.1")), None);
}
#[test]
fn update_endpoint_traegt_platzhalter_und_base() {
assert_eq!(
update_endpoint("https://tessera.ctl.de/").unwrap().as_str(),
"https://tessera.ctl.de/api-proxy/desktop/update?target=%7B%7Btarget%7D%7D&arch=%7B%7Barch%7D%7D&current=%7B%7Bcurrent_version%7D%7D&base=https%3A%2F%2Ftessera.ctl.de"
);
}
#[test]
fn update_endpoint_none_bei_unparsbarer_adresse() {
assert!(update_endpoint("kein url").is_none());
}
#[test]
fn release_labels_neue_basis_nennt_version_auch_bei_beta() {
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.3.0-beta.gdef5678"));
assert_eq!(menu_text, "Auf Version 1.3.0 aktualisieren");
}
#[test]
fn release_labels_gleiche_basis_nennt_beta_stempel() {
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.2.0-beta.gdef5678"));
assert_eq!(menu_text, "Auf Beta-Stand def5678 aktualisieren");
}
#[test]
fn server_host_mit_pfad_und_ohne_port() {
assert_eq!(
+9
View File
@@ -27,6 +27,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": [
"appimage",
"nsis"
@@ -48,5 +49,13 @@
"installMode": "currentUser"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIxNDIxMTZDMUFGRkJCRjEKUldUeHUvOGFiQkZDc2V0cnJZcmJVSURraGJHcE5TODVDeUp3UEVpM0xOc3I5TUZrNW5qZUw0d2gK",
"windows": {
"installMode": "passive"
}
}
}
}