From 31d6e5bd77deccef78888599cc8247124cb239bd Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 18 Apr 2026 14:27:44 +0200 Subject: [PATCH] feat(updater): macOS auto-update via Tauri Updater - tauri-plugin-updater wired into lib.rs with pubkey + GitHub Releases endpoint in tauri.conf.json; updater:default capability granted - AppUpdater.tsx: on macOS, the download button now invokes the updater plugin (check + downloadAndInstall) which downloads the signed .app.tar.gz, verifies the minisign signature against the bundled pubkey, replaces /Applications/Psysonic.app, and relaunches. Windows and Linux keep the existing "download DMG/EXE/AppImage via reqwest then point to the folder" flow - CI: pass TAURI_SIGNING_PRIVATE_KEY + _PASSWORD to tauri-action so the .sig files are produced alongside the update bundles - New generate-manifest job (after build-macos-windows) runs scripts/generate-update-manifest.js which downloads the .sig files from the release, assembles latest.json for darwin-aarch64 and darwin-x86_64, and uploads it back as a release asset Windows will be added to latest.json once the Certum cert is active. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 22 ++++ scripts/generate-update-manifest.js | 89 +++++++++++++ src-tauri/Cargo.lock | 193 +++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/lib.rs | 1 + src-tauri/tauri.conf.json | 11 ++ src/components/AppUpdater.tsx | 36 ++++++ 8 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 scripts/generate-update-manifest.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88653762..e873b42a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,10 +122,32 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} # APPLE_API_KEY_PATH comes from the previous step via $GITHUB_ENV + # Tauri Updater signing — produces .sig files alongside the update bundles + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} with: releaseId: ${{ needs.create-release.outputs.release_id }} args: ${{ matrix.settings.args }} + generate-manifest: + needs: [create-release, build-macos-windows] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + - name: generate latest.json + env: + VERSION: ${{ needs.create-release.outputs.package_version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/generate-update-manifest.js + - name: upload latest.json to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=${{ needs.create-release.outputs.package_version }} + gh release upload "app-v${VERSION}" latest.json --clobber + build-linux: needs: create-release permissions: diff --git a/scripts/generate-update-manifest.js b/scripts/generate-update-manifest.js new file mode 100644 index 00000000..471691e9 --- /dev/null +++ b/scripts/generate-update-manifest.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// Generates latest.json for the Tauri updater from a GitHub release. +// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json. +// +// macOS-only for now — Windows + Linux are added once their signing pipelines +// (Certum cert for Windows, native package managers for Linux) are wired up. +// +// Required env vars: VERSION, GITHUB_TOKEN +// Usage: node scripts/generate-update-manifest.js + +const { execSync } = require('child_process'); +const fs = require('fs'); + +const VERSION = process.env.VERSION; +const REPO = 'Psychotoxical/psysonic'; +const TAG = `app-v${VERSION}`; + +if (!VERSION) { + console.error('VERSION env var required'); + process.exit(1); +} + +// Platform → update bundle filename (produced by tauri-action with updater plugin) +const PLATFORM_FILES = { + 'darwin-aarch64': 'Psysonic_aarch64.app.tar.gz', + 'darwin-x86_64': 'Psysonic_x64.app.tar.gz', +}; + +const platforms = {}; + +// A real minisign .sig file is multi-line and ~200+ chars. +// A public key (RWTxxx... single line, ~56 chars) must never appear here. +function validateSignature(sig, platform, sigFile) { + if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) { + throw new Error( + `${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` + + ` Got: ${sig}\n` + + ` TAURI_SIGNING_PRIVATE_KEY must be the private key, not the public one.` + ); + } + if (sig.length < 80) { + throw new Error( + `${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.` + ); + } +} + +for (const [platform, filename] of Object.entries(PLATFORM_FILES)) { + const sigFile = `${filename}.sig`; + try { + execSync( + `gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`, + { stdio: 'pipe' } + ); + const signature = fs.readFileSync(sigFile, 'utf8').trim(); + validateSignature(signature, platform, sigFile); + const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`; + platforms[platform] = { signature, url }; + console.log(`✓ ${platform}`); + } catch (e) { + console.warn(`⚠ Skipping ${platform}: ${e.message}`); + } +} + +if (Object.keys(platforms).length === 0) { + console.error('No platforms found — aborting manifest generation'); + process.exit(1); +} + +let notes = ''; +try { + const raw = execSync( + `gh release view "${TAG}" --repo "${REPO}" --json body`, + { stdio: 'pipe' } + ).toString(); + notes = JSON.parse(raw).body ?? ''; +} catch { + console.warn('Could not fetch release notes'); +} + +const manifest = { + version: VERSION, + notes, + pub_date: new Date().toISOString(), + platforms, +}; + +fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2)); +console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ca2fe8b9..e212666f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -69,6 +69,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 = "arrayvec" version = "0.7.6" @@ -968,6 +977,17 @@ dependencies = [ "syn 1.0.109", ] +[[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.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1316,6 +1336,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2511,7 +2542,10 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ + "bitflags 2.11.1", "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -2703,6 +2737,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" @@ -2985,6 +3025,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.11.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3114,6 +3166,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" @@ -3163,7 +3229,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -3396,6 +3462,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -3608,6 +3680,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", + "tauri-plugin-updater", "tauri-plugin-window-state", "thread-priority", "tokio", @@ -3833,6 +3906,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3951,15 +4033,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", @@ -4120,6 +4207,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "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.12" @@ -4602,7 +4716,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -5031,6 +5145,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5288,6 +5413,39 @@ dependencies = [ "tracing", ] +[[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.2", + "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" @@ -6235,6 +6393,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -7139,6 +7306,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -7385,6 +7562,18 @@ dependencies = [ "syn 2.0.117", ] +[[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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 31514d40..3fd9e40b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ biquad = "0.4" ringbuf = "0.3" tauri-plugin-window-state = "2.4.1" tauri-plugin-process = "2" +tauri-plugin-updater = "2" souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } discord-rich-presence = "0.2" url = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f675bc74..6fc8b240 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -37,6 +37,7 @@ "core:window:allow-start-dragging", "core:window:allow-create", "core:webview:allow-create-webview-window", - "process:allow-restart" + "process:allow-restart", + "updater:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c31577c..32d35ace 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2425,6 +2425,7 @@ pub fn run() { .manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore) .manage(TrayState::default()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a3f78154..deb3295e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -31,6 +31,17 @@ "csp": null } }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUWFjOQo=", + "endpoints": [ + "https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json" + ], + "windows": { + "installMode": "passive" + } + } + }, "bundle": { "active": true, "targets": "all", diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx index a4f9008d..ab50bd70 100644 --- a/src/components/AppUpdater.tsx +++ b/src/components/AppUpdater.tsx @@ -166,6 +166,42 @@ export default function AppUpdater() { }; const handleDownload = async () => { + // On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies + // the minisign signature against the bundled pubkey, replaces the .app, and + // relaunches. No manual "open the DMG" step needed. + if (IS_MACOS) { + setDlState('downloading'); + setDlProgress({ bytes: 0, total: 0 }); + setDlError(''); + try { + const { check } = await import('@tauri-apps/plugin-updater'); + const update = await check(); + if (!update) { + setDlError(t('common.updaterErrorMsg')); + setDlState('error'); + return; + } + let downloaded = 0; + let total = 0; + await update.downloadAndInstall(event => { + if (event.event === 'Started') { + total = event.data.contentLength ?? 0; + setDlProgress({ bytes: 0, total }); + } else if (event.event === 'Progress') { + downloaded += event.data.chunkLength; + setDlProgress({ bytes: downloaded, total }); + } else if (event.event === 'Finished') { + setDlState('done'); + } + }); + // downloadAndInstall replaces the .app and relaunches automatically on macOS. + } catch (e) { + setDlError(String(e)); + setDlState('error'); + } + return; + } + if (!asset) return; setDlState('downloading'); setDlProgress({ bytes: 0, total: asset.size });