diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f481581..3e415b3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,31 +48,60 @@ jobs: - name: Build Tauri app working-directory: Client/tauri-client + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npm run tauri build - - name: Locate installer - id: installer + - name: Locate artifacts + id: artifacts shell: bash run: | - INSTALLER=$(find Client/tauri-client/src-tauri/target/release/bundle/nsis -name "*.exe" | head -1) - echo "path=$INSTALLER" >> "$GITHUB_OUTPUT" - echo "name=$(basename $INSTALLER)" >> "$GITHUB_OUTPUT" + NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis" + INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1) + echo "installer_path=$INSTALLER" >> "$GITHUB_OUTPUT" + echo "installer_name=$(basename $INSTALLER)" >> "$GITHUB_OUTPUT" + # Updater artifacts (produced when TAURI_SIGNING_PRIVATE_KEY is set) + NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1) + NSIS_SIG=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip.sig" | head -1) + echo "nsis_zip=${NSIS_ZIP:-}" >> "$GITHUB_OUTPUT" + echo "nsis_sig=${NSIS_SIG:-}" >> "$GITHUB_OUTPUT" - name: Generate SHA256 checksums shell: pwsh run: | + $lines = @() $serverHash = (Get-FileHash -Path Server/chatserver.exe -Algorithm SHA256).Hash.ToLower() - $installerPath = "${{ steps.installer.outputs.path }}" - $installerName = "${{ steps.installer.outputs.name }}" + $lines += "$serverHash chatserver.exe" + $installerPath = "${{ steps.artifacts.outputs.installer_path }}" + $installerName = "${{ steps.artifacts.outputs.installer_name }}" $clientHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLower() - "$serverHash chatserver.exe`n$clientHash $installerName" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline + $lines += "$clientHash $installerName" + $nsisZip = "${{ steps.artifacts.outputs.nsis_zip }}" + if ($nsisZip -and (Test-Path $nsisZip)) { + $zipName = Split-Path $nsisZip -Leaf + $zipHash = (Get-FileHash -Path $nsisZip -Algorithm SHA256).Hash.ToLower() + $lines += "$zipHash $zipName" + } + $lines -join "`n" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: >- - gh release create ${{ github.ref_name }} - --generate-notes - Server/chatserver.exe - "${{ steps.installer.outputs.path }}" - checksums.sha256 + shell: bash + run: | + ASSETS=( + Server/chatserver.exe + "${{ steps.artifacts.outputs.installer_path }}" + checksums.sha256 + ) + # Include updater artifacts if signing key was available + if [ -n "${{ steps.artifacts.outputs.nsis_zip }}" ]; then + ASSETS+=("${{ steps.artifacts.outputs.nsis_zip }}") + fi + if [ -n "${{ steps.artifacts.outputs.nsis_sig }}" ]; then + ASSETS+=("${{ steps.artifacts.outputs.nsis_sig }}") + fi + gh release create ${{ github.ref_name }} \ + --generate-notes \ + "${ASSETS[@]}" diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json index aabf3d08..0efcc9cf 100644 --- a/Client/tauri-client/package-lock.json +++ b/Client/tauri-client/package-lock.json @@ -15,7 +15,9 @@ "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-opener": "^2.5.3", - "@tauri-apps/plugin-store": "^2" + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-store": "^2", + "@tauri-apps/plugin-updater": "^2.10.0" }, "devDependencies": { "@playwright/test": "^1", @@ -1500,6 +1502,15 @@ "@tauri-apps/api": "^2.8.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-store": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz", @@ -1509,6 +1520,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz", + "integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json index b95d6ec1..1ae127bd 100644 --- a/Client/tauri-client/package.json +++ b/Client/tauri-client/package.json @@ -35,6 +35,8 @@ "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-opener": "^2.5.3", - "@tauri-apps/plugin-store": "^2" + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-store": "^2", + "@tauri-apps/plugin-updater": "^2.10.0" } } diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index 1f0d32c8..f68f959d 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -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" @@ -733,6 +742,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.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1033,6 +1053,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" @@ -2104,7 +2135,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -2227,6 +2261,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" @@ -2448,6 +2488,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.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2504,6 +2556,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2520,6 +2578,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 = "owncord-client" version = "0.1.0" @@ -2537,9 +2609,12 @@ dependencies = [ "tauri-plugin-http", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-process", "tauri-plugin-store", + "tauri-plugin-updater", "tokio", "tokio-tungstenite", + "url", "windows 0.58.0", ] @@ -2592,7 +2667,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2825,6 +2900,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[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" @@ -3208,6 +3289,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3325,15 +3415,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", @@ -3425,6 +3520,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3435,6 +3542,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.9" @@ -3467,6 +3601,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3524,6 +3667,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.11.0", + "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.24.0" @@ -3843,7 +4009,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -4079,6 +4245,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4336,6 +4513,16 @@ dependencies = [ "zbus", ] +[[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-store" version = "2.4.2" @@ -4352,6 +4539,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +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-runtime" version = "2.10.1" @@ -5277,6 +5497,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -6044,6 +6273,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", +] + [[package]] name = "xkeysym" version = "0.2.1" @@ -6214,6 +6453,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.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 3412aab5..55581d4f 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -22,6 +22,9 @@ tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls", "dangerous-se tauri-plugin-opener = "2" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" +url = "2" tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3.32" tokio = { version = "1", features = ["sync"] } diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json index 864bf8ca..e1ce373c 100644 --- a/Client/tauri-client/src-tauri/capabilities/default.json +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -65,6 +65,8 @@ "http:allow-fetch-cancel", "opener:default", "dialog:default", + "updater:default", + "process:allow-restart", "fs:default", { "identifier": "fs:allow-write-file", diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 2709b4fe..1e446051 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod credentials; mod hotkeys; mod tray; +mod update_commands; mod ws_proxy; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -14,6 +15,8 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .manage(ws_proxy::WsState::new()) .invoke_handler(tauri::generate_handler![ commands::get_settings, @@ -27,6 +30,8 @@ pub fn run() { credentials::save_credential, credentials::load_credential, credentials::delete_credential, + update_commands::check_client_update, + update_commands::download_and_install_update, ]) .setup(|app| { tray::create_tray(app.handle())?; diff --git a/Client/tauri-client/src-tauri/src/update_commands.rs b/Client/tauri-client/src-tauri/src/update_commands.rs new file mode 100644 index 00000000..e404ab6d --- /dev/null +++ b/Client/tauri-client/src-tauri/src/update_commands.rs @@ -0,0 +1,107 @@ +use serde::Serialize; +use tauri::AppHandle; +use tauri_plugin_updater::UpdaterExt; + +#[derive(Serialize)] +pub struct UpdateCheckResult { + pub available: bool, + pub version: Option, + pub body: Option, +} + +/// Check for a client update using the given server URL to build the endpoint +/// dynamically. This is required because OwnCord is self-hosted and the +/// server address varies per user. +#[tauri::command] +pub async fn check_client_update( + app: AppHandle, + server_url: String, +) -> Result { + let current_version = app + .config() + .version + .clone() + .unwrap_or_else(|| "0.0.0".to_string()); + + let endpoint = format!( + "{}/api/v1/client-update/{{{{target}}}}/{}", + server_url.trim_end_matches('/'), + current_version, + ); + + let url: url::Url = endpoint + .parse() + .map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?; + + let updater = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| format!("failed to set endpoints: {e}"))? + .build() + .map_err(|e| format!("failed to build updater: {e}"))?; + + let update = updater + .check() + .await + .map_err(|e| format!("update check failed: {e}"))?; + + match update { + Some(u) => Ok(UpdateCheckResult { + available: true, + version: Some(u.version.clone()), + body: Some(u.body.clone().unwrap_or_default()), + }), + None => Ok(UpdateCheckResult { + available: false, + version: None, + body: None, + }), + } +} + +/// Download and install a pending update, then signal the frontend. +/// The frontend should call `relaunch()` from @tauri-apps/plugin-process +/// after this completes. +#[tauri::command] +pub async fn download_and_install_update( + app: AppHandle, + server_url: String, +) -> Result<(), String> { + let current_version = app + .config() + .version + .clone() + .unwrap_or_else(|| "0.0.0".to_string()); + + let endpoint = format!( + "{}/api/v1/client-update/{{{{target}}}}/{}", + server_url.trim_end_matches('/'), + current_version, + ); + + let url: url::Url = endpoint + .parse() + .map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?; + + let updater = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| format!("failed to set endpoints: {e}"))? + .build() + .map_err(|e| format!("failed to build updater: {e}"))?; + + let update = updater + .check() + .await + .map_err(|e| format!("update check failed: {e}"))?; + + match update { + Some(u) => { + u.download_and_install(|_chunk_len, _total| {}, || {}) + .await + .map_err(|e| format!("download/install failed: {e}"))?; + Ok(()) + } + None => Err("no update available".into()), + } +} diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index 5b1c74e3..e958459f 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -42,5 +42,15 @@ } } }, - "plugins": {} + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMkZGMTUzMDBBNkFCNDAKUldSQXE2WUFVL0V2Z2NjekFXaVI1elpQYVBmOUdKNmZrTzZwRC80RHlBMkQyYzZWYXdTK00xK0wK", + "endpoints": [], + "dangerousAcceptInvalidCerts": true, + "dangerousAcceptInvalidHostnames": true, + "windows": { + "installMode": "passive" + } + } + } } diff --git a/Client/tauri-client/src/components/UpdateNotifier.ts b/Client/tauri-client/src/components/UpdateNotifier.ts new file mode 100644 index 00000000..37b4232f --- /dev/null +++ b/Client/tauri-client/src/components/UpdateNotifier.ts @@ -0,0 +1,101 @@ +// UpdateNotifier — shows a non-modal banner when a client update is available. +// Mounts at the top of the main page and allows the user to update or dismiss. + +import { createElement, appendChildren } from "@lib/dom"; +import { createLogger } from "@lib/logger"; +import { checkForUpdate, downloadAndInstallUpdate } from "@lib/updater"; +import type { MountableComponent } from "@lib/safe-render"; + +const log = createLogger("update-notifier"); + +export interface UpdateNotifierOptions { + readonly serverUrl: string; +} + +export function createUpdateNotifier(options: UpdateNotifierOptions): MountableComponent { + const { serverUrl } = options; + let container: Element | null = null; + let banner: HTMLDivElement | null = null; + let dismissed = false; + + async function performCheck(): Promise { + if (dismissed) return; + + const result = await checkForUpdate(serverUrl); + if (!result.available || result.version === null) return; + + showBanner(result.version, result.body ?? ""); + } + + function showBanner(version: string, notes: string): void { + if (container === null || banner !== null) return; + + banner = createElement("div", { class: "update-banner" }); + + const text = createElement("span", { class: "update-banner-text" }, + `Update v${version} available`); + + const updateBtn = createElement("button", { class: "update-banner-btn update-banner-install" }, + "Update Now"); + updateBtn.addEventListener("click", () => { + void installUpdate(); + }); + + const laterBtn = createElement("button", { class: "update-banner-btn update-banner-later" }, + "Later"); + laterBtn.addEventListener("click", () => { + dismissed = true; + removeBanner(); + }); + + appendChildren(banner, text, updateBtn, laterBtn); + container.prepend(banner); + } + + async function installUpdate(): Promise { + if (banner === null) return; + + // Replace banner content with progress indicator + while (banner.firstChild) banner.removeChild(banner.firstChild); + const progress = createElement("span", { class: "update-banner-text" }, + "Downloading update..."); + banner.appendChild(progress); + + try { + await downloadAndInstallUpdate(serverUrl); + // App will relaunch — this code won't execute after relaunch() + } catch (err) { + log.error("Update install failed", { error: String(err) }); + while (banner.firstChild) banner.removeChild(banner.firstChild); + const errorText = createElement("span", { class: "update-banner-text" }, + "Update failed. Please try again later."); + const dismissBtn = createElement("button", { class: "update-banner-btn update-banner-later" }, + "Dismiss"); + dismissBtn.addEventListener("click", () => { + dismissed = true; + removeBanner(); + }); + appendChildren(banner, errorText, dismissBtn); + } + } + + function removeBanner(): void { + if (banner !== null) { + banner.remove(); + banner = null; + } + } + + function mount(target: Element): void { + container = target; + // Delay the check slightly so the main UI renders first + setTimeout(() => { void performCheck(); }, 3000); + } + + function destroy(): void { + removeBanner(); + container = null; + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/lib/updater.ts b/Client/tauri-client/src/lib/updater.ts new file mode 100644 index 00000000..17c46f64 --- /dev/null +++ b/Client/tauri-client/src/lib/updater.ts @@ -0,0 +1,41 @@ +// updater.ts — Client auto-update service. +// Uses custom Tauri commands that build the updater with a dynamic server URL +// at runtime (required because OwnCord is self-hosted). + +import { invoke } from "@tauri-apps/api/core"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { createLogger } from "@lib/logger"; + +const log = createLogger("updater"); + +export interface UpdateCheckResult { + readonly available: boolean; + readonly version: string | null; + readonly body: string | null; +} + +/** Check if a newer client version is available on the connected server. */ +export async function checkForUpdate(serverUrl: string): Promise { + try { + const result = await invoke("check_client_update", { + serverUrl, + }); + if (result.available) { + log.info("Update available", { version: result.version }); + } else { + log.debug("No update available"); + } + return result; + } catch (err) { + log.error("Update check failed", { error: String(err) }); + return { available: false, version: null, body: null }; + } +} + +/** Download and install a pending update, then relaunch the app. */ +export async function downloadAndInstallUpdate(serverUrl: string): Promise { + log.info("Downloading and installing update..."); + await invoke("download_and_install_update", { serverUrl }); + log.info("Update installed, relaunching..."); + await relaunch(); +} diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 7767394c..ec5840af 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -57,6 +57,7 @@ import { createInviteManagerController, createPinnedPanelController, } from "./main-page/OverlayManagers"; +import { createUpdateNotifier } from "@components/UpdateNotifier"; const log = createLogger("main-page"); @@ -685,6 +686,14 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // Wire voice error callback to toast setVoiceOnError((msg) => toast?.show(msg, "error")); + // Auto-update notifier — checks server for newer client version + if (apiConfig.host) { + const serverUrl = `https://${apiConfig.host}`; + const updateNotifier = createUpdateNotifier({ serverUrl }); + updateNotifier.mount(root); + children.push(updateNotifier); + } + container.appendChild(root); // --- Subscribe to channel changes --- diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index c1c67912..4516fe66 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -468,6 +468,23 @@ } .msg-file-download:hover { background: var(--bg-hover); color: var(--text-normal); } +/* ── Update Banner ── */ +.update-banner { + display: flex; align-items: center; gap: 12px; + padding: 8px 16px; background: var(--accent); + color: #fff; font-size: 13px; font-weight: 500; + flex-shrink: 0; z-index: 100; +} +.update-banner-text { flex: 1; } +.update-banner-btn { + padding: 4px 12px; border-radius: var(--radius-sm); + font-size: 12px; font-weight: 600; cursor: pointer; + border: none; transition: opacity .15s; +} +.update-banner-btn:hover { opacity: 0.85; } +.update-banner-install { background: #fff; color: var(--accent); } +.update-banner-later { background: rgba(255,255,255,0.2); color: #fff; } + /* System message */ .system-msg { display: flex; align-items: center; gap: 8px; diff --git a/Server/api/client_update.go b/Server/api/client_update.go new file mode 100644 index 00000000..6264d5b3 --- /dev/null +++ b/Server/api/client_update.go @@ -0,0 +1,96 @@ +// client_update.go serves Tauri-compatible update metadata so the desktop +// client can check for new versions and self-update. +package api + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/updater" + "golang.org/x/mod/semver" +) + +// tauriPlatformResponse is the per-platform entry in the Tauri updater JSON. +type tauriPlatformResponse struct { + Signature string `json:"signature"` + URL string `json:"url"` +} + +// tauriUpdateResponse is the JSON shape the Tauri updater plugin expects. +type tauriUpdateResponse struct { + Version string `json:"version"` + Notes string `json:"notes,omitempty"` + PubDate string `json:"pub_date,omitempty"` + Platforms map[string]tauriPlatformResponse `json:"platforms"` +} + +// MountClientUpdateRoute adds the unauthenticated client-update endpoint. +// The route is outside the auth middleware because the client needs to check +// for updates before (or without) logging in. +func MountClientUpdateRoute(r chi.Router, u *updater.Updater) { + r.Get("/api/v1/client-update/{target}/{current_version}", handleClientUpdate(u)) +} + +func handleClientUpdate(u *updater.Updater) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + target := chi.URLParam(r, "target") + currentVersion := chi.URLParam(r, "current_version") + + if target == "" || currentVersion == "" { + http.Error(w, "missing target or current_version", http.StatusBadRequest) + return + } + + info, err := u.CheckForUpdate(r.Context()) + if err != nil { + http.Error(w, "failed to check for updates", http.StatusBadGateway) + return + } + + // Compare versions — return 204 if no update available. + cv := ensureV(currentVersion) + lv := ensureV(info.Latest) + if semver.Compare(cv, lv) >= 0 { + w.WriteHeader(http.StatusNoContent) + return + } + + // Find the .nsis.zip and .nsis.zip.sig assets from the release. + clientAssets := u.FindClientAssets() + nsisURL := clientAssets.InstallerURL + sigURL := clientAssets.SignatureURL + if nsisURL == "" || sigURL == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + // Fetch the signature file content (small text file). + sigContent, err := u.FetchTextAsset(r.Context(), sigURL) + if err != nil { + http.Error(w, "failed to fetch signature", http.StatusBadGateway) + return + } + + resp := tauriUpdateResponse{ + Version: strings.TrimPrefix(info.Latest, "v"), + Notes: info.ReleaseNotes, + Platforms: map[string]tauriPlatformResponse{ + target: { + Signature: strings.TrimSpace(sigContent), + URL: nsisURL, + }, + }, + } + + writeJSON(w, http.StatusOK, resp) + } +} + +// ensureV returns a version string with a "v" prefix for semver comparison. +func ensureV(v string) string { + if strings.HasPrefix(v, "v") { + return v + } + return "v" + v +} diff --git a/Server/api/router.go b/Server/api/router.go index a03e5df3..9ca92374 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -83,6 +83,9 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler { u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord") r.Mount("/admin", admin.NewHandler(database, ver, hub, u)) + // Client auto-update endpoint (unauthenticated). + MountClientUpdateRoute(r, u) + return r } diff --git a/Server/updater/updater.go b/Server/updater/updater.go index f022fecc..7d724ef9 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -35,6 +35,19 @@ type UpdateInfo struct { DownloadURL string `json:"download_url"` ChecksumURL string `json:"checksum_url"` ReleaseNotes string `json:"release_notes"` + Assets []Asset `json:"assets,omitempty"` +} + +// Asset is a simplified release asset with name and download URL. +type Asset struct { + Name string `json:"name"` + DownloadURL string `json:"download_url"` +} + +// ClientAssets holds the URLs for Tauri client update artifacts. +type ClientAssets struct { + InstallerURL string + SignatureURL string } // releaseResponse mirrors the subset of GitHub's release API we need. @@ -142,7 +155,12 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) { updateAvailable := semver.Compare(currentV, latestV) < 0 var downloadURL, checksumURL string + assets := make([]Asset, 0, len(release.Assets)) for _, asset := range release.Assets { + assets = append(assets, Asset{ + Name: asset.Name, + DownloadURL: asset.BrowserDownloadURL, + }) switch asset.Name { case binaryAsset: downloadURL = asset.BrowserDownloadURL @@ -159,6 +177,7 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) { DownloadURL: downloadURL, ChecksumURL: checksumURL, ReleaseNotes: release.Body, + Assets: assets, } u.mu.Lock() @@ -280,6 +299,38 @@ func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) { return io.ReadAll(resp.Body) } +// FindClientAssets scans the cached release assets for the Tauri NSIS +// installer zip and its Ed25519 signature file. +func (u *Updater) FindClientAssets() ClientAssets { + u.mu.Lock() + defer u.mu.Unlock() + + if u.cache == nil { + return ClientAssets{} + } + + var ca ClientAssets + for _, a := range u.cache.Assets { + switch { + case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip.sig"): + ca.SignatureURL = a.DownloadURL + case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip"): + ca.InstallerURL = a.DownloadURL + } + } + return ca +} + +// FetchTextAsset downloads a small text asset (e.g. a .sig file) and returns +// its content as a string. +func (u *Updater) FetchTextAsset(ctx context.Context, url string) (string, error) { + data, err := u.fetchBody(ctx, url) + if err != nil { + return "", err + } + return string(data), nil +} + // downloadFile downloads the content at url and writes it to destPath. func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)