From f3a89e0e09bcbbf9cd54a708ce65d187b430c698 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:38:22 +0200 Subject: [PATCH] fix(updater): make client auto-update work end-to-end and Linux server self-update verifiable - client: update endpoint now sends {{target}}-{{arch}}-{{bundle_type}} so the server-echoed platforms key matches the updater plugin's {os}-{arch}-{installer} lookup (previously bare {{target}} produced a key the plugin never matches, so no update was ever surfaced) - client: TOFU cert pin is scoped to the OwnCord server host via HostScopedVerifier; the GitHub installer download validates against web PKI instead of failing the pinned-fingerprint check on every install - client: check/install share one build_updater helper so the two paths cannot diverge; tauri-plugin-updater minor-pinned per its configure_client guidance - server: client-update endpoint serves target-specific artifacts (NSIS, per-arch AppImage) and returns 204 for targets without a published updater artifact (deb, darwin) instead of always serving the Windows NSIS installer - release: server-update-manifest.json now binds both OS assets (legacy top-level pair kept pointing at the Windows binary so deployed servers still verify); VerifyReleaseManifest resolves the entry matching the downloaded asset, fixing Linux server self-update - release: ARM64 staging renames installer, tar.gz and .sig consistently so signatures keep pairing and arch-less names cannot collide with x86_64 assets - ci: run cargo test --lib (Rust #[cfg(test)] code was never compiled in CI); merge the two ptt tests that raced on the global PTT_VKEY atomic Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 34 +-- Client/tauri-client/src-tauri/Cargo.lock | 1 + Client/tauri-client/src-tauri/Cargo.toml | 9 +- Client/tauri-client/src-tauri/src/ptt.rs | 19 +- Client/tauri-client/src-tauri/src/tofu.rs | 218 ++++++++++++++++++ .../src-tauri/src/update_commands.rs | 163 +++++++------ Server/api/client_update.go | 13 +- Server/api/client_update_test.go | 172 ++++++++++++-- Server/updater/coverage_boost_test.go | 35 ++- Server/updater/release_manifest_test.go | 88 +++++++ Server/updater/updater.go | 81 +++++-- docs/api.md | 6 +- 13 files changed, 693 insertions(+), 152 deletions(-) create mode 100644 Server/updater/release_manifest_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c73d9cd..279b1b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,6 +270,12 @@ jobs: working-directory: Client/tauri-client/src-tauri/ run: cargo clippy -- -D warnings + # Without this, #[cfg(test)] code is never compiled or run in CI + # (clippy above skips test targets), so Rust unit tests would rot. + - name: Rust unit tests + working-directory: Client/tauri-client/src-tauri/ + run: cargo test --lib + - name: Security audit (Rust dependencies) working-directory: Client/tauri-client/src-tauri/ run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 238f469a..9e6918f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,20 +237,18 @@ jobs: run: | mkdir -p linux-arm64-staging BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle" - # AppImage (ensure arch is in filename) - for f in "$BUNDLE_DIR"/appimage/*.AppImage; do + # AppImage + updater artifact (.tar.gz) + signatures. Every filename + # must carry the arch: FindClientAssets matches on the + # _aarch64.AppImage.tar.gz suffix, and arch-less names would collide + # with the x86_64 assets when both artifact sets are downloaded into + # the same linux/ directory at publish time. Inserting _aarch64 + # before ".AppImage" renames installer, tar.gz, and .sig + # consistently, so signatures keep pairing with their artifacts. + for f in "$BUNDLE_DIR"/appimage/*.AppImage "$BUNDLE_DIR"/appimage/*.AppImage.tar.gz "$BUNDLE_DIR"/appimage/*.sig; do [ -f "$f" ] || continue - [[ "$f" == *.sig ]] && continue - dest="linux-arm64-staging/$(basename "$f")" - # Append _aarch64 if bundler omits arch from filename - [[ "$(basename "$f")" == *aarch64* ]] || dest="${dest%.AppImage}_aarch64.AppImage" - cp "$f" "$dest" - done - for f in "$BUNDLE_DIR"/appimage/*.AppImage.tar.gz; do - [ -f "$f" ] && cp "$f" linux-arm64-staging/ - done - for f in "$BUNDLE_DIR"/appimage/*.sig; do - [ -f "$f" ] && cp "$f" linux-arm64-staging/ + base="$(basename "$f")" + [[ "$base" == *aarch64* ]] || base="${base/.AppImage/_aarch64.AppImage}" + cp "$f" "linux-arm64-staging/$base" done # .deb for f in "$BUNDLE_DIR"/deb/*.deb; do @@ -376,11 +374,17 @@ jobs: (cd linux && sha256sum *) >> checksums.sha256 sha256sum owncord-src-*.tar.gz >> checksums.sha256 + # The legacy top-level asset/sha256 pair stays bound to the Windows + # binary so already-deployed servers (which only understand the + # single-asset schema) can still verify and update; the assets list + # binds every OS. Server-side schema: updater.releaseManifest. - name: Generate server update manifest shell: bash run: | - SERVER_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}') - printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s"}' "$VERSION" "$SERVER_HASH" > windows/server-update-manifest.json + WIN_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}') + LINUX_HASH=$(sha256sum linux/chatserver-linux-amd64.tar.gz | awk '{print $1}') + printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}]}' \ + "$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" > windows/server-update-manifest.json - name: Sign server update assets working-directory: Client/tauri-client diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index aafd2450..1a4b1f54 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -2955,6 +2955,7 @@ dependencies = [ "tokio-rustls", "tokio-tungstenite", "url", + "webpki-roots 1.0.6", "windows 0.58.0", ] diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index b6dc29c3..85f49942 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -31,7 +31,10 @@ tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" -tauri-plugin-updater = "2" +# Minor-pinned per the plugin's own guidance: configure_client hands it a +# preconfigured rustls ClientConfig, and a 2.x minor bump can change the +# plugin's bundled reqwest/rustls and break that seam at runtime. +tauri-plugin-updater = "2.10" tauri-plugin-process = "2" url = "2" tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] } @@ -39,6 +42,10 @@ futures-util = "0.3.32" tokio = { version = "1", features = ["sync", "net", "io-util", "rt", "macros"] } tokio-rustls = { version = "0.26", default-features = false } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +# Mozilla root bundle for the updater's HostScopedVerifier: non-pinned hosts +# (the GitHub installer download) get web-PKI validation. Already in the +# dependency tree via tokio-tungstenite's rustls-tls-webpki-roots feature. +webpki-roots = "1" ring = "0.17" log = "0.4" env_logger = "0.11" diff --git a/Client/tauri-client/src-tauri/src/ptt.rs b/Client/tauri-client/src-tauri/src/ptt.rs index e0f8ba9d..23fd3b40 100644 --- a/Client/tauri-client/src-tauri/src/ptt.rs +++ b/Client/tauri-client/src-tauri/src/ptt.rs @@ -479,13 +479,20 @@ pub async fn ptt_listen_for_key() -> i32 { mod tests { use super::*; + // PTT_VKEY is a process-global AtomicI32 and cargo runs tests on parallel + // threads, so every mutating assertion lives in this ONE test — splitting + // them across tests makes the get/set assertions racy. #[test] - fn ptt_set_key_accepts_valid_codes() { + fn ptt_set_key_accepts_valid_codes_and_get_reflects_them() { assert!(ptt_set_key(0).is_ok()); assert!(ptt_set_key(1).is_ok()); assert!(ptt_set_key(0x20).is_ok()); // Space - assert!(ptt_set_key(0x41).is_ok()); // A assert!(ptt_set_key(254).is_ok()); + + ptt_set_key(0x41).unwrap(); // A + assert_eq!(ptt_get_key(), 0x41); + ptt_set_key(0).unwrap(); + assert_eq!(ptt_get_key(), 0); } #[test] @@ -517,14 +524,6 @@ mod tests { assert!(!is_allowed_ptt_capture_vk(0x5B)); // Meta } - #[test] - fn ptt_get_key_reflects_set_key() { - ptt_set_key(0x41).unwrap(); - assert_eq!(ptt_get_key(), 0x41); - ptt_set_key(0).unwrap(); - assert_eq!(ptt_get_key(), 0); - } - #[cfg(target_os = "linux")] #[test] fn linux_keycode_round_trips_for_common_keys() { diff --git a/Client/tauri-client/src-tauri/src/tofu.rs b/Client/tauri-client/src-tauri/src/tofu.rs index f78bbf25..b561220d 100644 --- a/Client/tauri-client/src-tauri/src/tofu.rs +++ b/Client/tauri-client/src-tauri/src/tofu.rs @@ -181,6 +181,103 @@ impl rustls::client::danger::ServerCertVerifier for PinnedVerifier { } } +/// A rustls verifier that applies the pinned-fingerprint check ONLY to the +/// named host and normal web-PKI validation to every other host. Used by the +/// updater, whose single HTTP client talks both to the (possibly self-signed, +/// TOFU-pinned) OwnCord server for update metadata and to GitHub for the +/// installer download — a client-wide pin would reject GitHub's certificate. +#[derive(Debug)] +pub(crate) struct HostScopedVerifier { + pinned_host: String, + pinned: PinnedVerifier, + default: Arc, +} + +impl HostScopedVerifier { + pub(crate) fn new(pinned_host: String, expected_fingerprint: String) -> Result { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let default = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + Arc::new(rustls::crypto::ring::default_provider()), + ) + .build() + .map_err(|e| format!("failed to build web-PKI verifier: {e}"))?; + Ok(Self::with_default(pinned_host, expected_fingerprint, default)) + } + + /// Seam for tests: inject the verifier used for non-pinned hosts. + fn with_default( + pinned_host: String, + expected_fingerprint: String, + default: Arc, + ) -> Self { + // url::Url wraps IPv6 hosts in brackets; ServerName renders them bare. + let pinned_host = pinned_host + .trim_start_matches('[') + .trim_end_matches(']') + .to_ascii_lowercase(); + Self { + pinned_host, + pinned: PinnedVerifier::new(expected_fingerprint), + default, + } + } + + fn is_pinned_host(&self, server_name: &rustls::pki_types::ServerName<'_>) -> bool { + match server_name { + rustls::pki_types::ServerName::DnsName(d) => { + d.as_ref().eq_ignore_ascii_case(&self.pinned_host) + } + rustls::pki_types::ServerName::IpAddress(ip) => { + std::net::IpAddr::from(*ip).to_string() == self.pinned_host + } + _ => false, + } + } +} + +impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + if self.is_pinned_host(server_name) { + self.pinned + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } else { + self.default + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + verify_tls12(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + verify_tls13(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + default_verify_schemes() + } +} + // ── store keys ────────────────────────────────────────────────────────────── /// Cert-store key for a host. Strips a default `:443` so the ws proxy (which @@ -311,4 +408,125 @@ mod tests { "e3:b0:c4:42:98:fc:1c:14:9a:fb:f4:c8:99:6f:b9:24:27:ae:41:e4:64:9b:93:4c:a4:95:99:1b:78:52:b8:55" ); } + + // ── HostScopedVerifier ────────────────────────────────────────────────── + + /// Stub for the non-pinned-host verifier: records nothing, just returns a + /// fixed verdict so tests can prove which path a connection was routed to. + #[derive(Debug)] + struct StubVerifier { + accept: bool, + } + + impl rustls::client::danger::ServerCertVerifier for StubVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + if self.accept { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } else { + Err(rustls::Error::General("stub rejected".into())) + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + verify_tls12(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + verify_tls13(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + default_verify_schemes() + } + } + + fn host_scoped(pinned_host: &str, cert_bytes: &[u8], stub_accepts: bool) -> HostScopedVerifier { + HostScopedVerifier::with_default( + pinned_host.to_string(), + fingerprint_hex(cert_bytes), + Arc::new(StubVerifier { accept: stub_accepts }), + ) + } + + fn verify( + v: &HostScopedVerifier, + host: &str, + cert_bytes: &[u8], + ) -> Result { + use rustls::client::danger::ServerCertVerifier; + let cert = rustls::pki_types::CertificateDer::from(cert_bytes.to_vec()); + let name = rustls::pki_types::ServerName::try_from(host.to_string()).unwrap(); + v.verify_server_cert( + &cert, + &[], + &name, + &[], + rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)), + ) + } + + #[test] + fn host_scoped_pins_matching_host() { + // Stub rejects, so success proves the PINNED path handled it. + let v = host_scoped("chat.example.com", b"server-cert", false); + assert!(verify(&v, "chat.example.com", b"server-cert").is_ok()); + } + + #[test] + fn host_scoped_rejects_wrong_cert_on_pinned_host() { + let err = verify( + &host_scoped("chat.example.com", b"server-cert", true), + "chat.example.com", + b"mitm-cert", + ) + .unwrap_err(); + assert!(err.to_string().contains("fingerprint mismatch"), "{err}"); + } + + #[test] + fn host_scoped_delegates_other_hosts_to_default() { + // Cert does NOT match the pin; success proves the DEFAULT path handled it. + let v = host_scoped("chat.example.com", b"server-cert", true); + assert!(verify(&v, "github.com", b"github-cert").is_ok()); + } + + #[test] + fn host_scoped_default_rejection_propagates() { + let err = verify( + &host_scoped("chat.example.com", b"server-cert", false), + "github.com", + b"github-cert", + ) + .unwrap_err(); + assert!(err.to_string().contains("stub rejected"), "{err}"); + } + + #[test] + fn host_scoped_host_match_is_case_insensitive() { + let v = host_scoped("Chat.Example.COM", b"server-cert", false); + assert!(verify(&v, "chat.example.com", b"server-cert").is_ok()); + } + + #[test] + fn host_scoped_matches_ip_pinned_host() { + let v = host_scoped("192.168.1.10", b"server-cert", false); + assert!(verify(&v, "192.168.1.10", b"server-cert").is_ok()); + } } diff --git a/Client/tauri-client/src-tauri/src/update_commands.rs b/Client/tauri-client/src-tauri/src/update_commands.rs index 5d7904d2..8d5b650e 100644 --- a/Client/tauri-client/src-tauri/src/update_commands.rs +++ b/Client/tauri-client/src-tauri/src/update_commands.rs @@ -3,7 +3,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; use tauri_plugin_updater::UpdaterExt; -use crate::livekit_proxy::{cert_store_key, load_stored_fingerprint, PinnedVerifier}; +use crate::tofu::{cert_store_key, load_stored_fingerprint, HostScopedVerifier}; #[derive(Serialize)] pub struct UpdateCheckResult { @@ -36,17 +36,25 @@ fn extract_host_for_cert_store(server_url: &str) -> Result { Ok(cert_store_key(&raw)) } -/// Build a rustls ClientConfig that validates the server cert against the -/// TOFU-pinned fingerprint. Falls back to system certs if no fingerprint -/// is stored (server uses a real CA cert). +/// Build a rustls ClientConfig for the updater. When a TOFU fingerprint is +/// stored, the pin is enforced for the OwnCord server's host ONLY — the same +/// HTTP client also downloads the installer from GitHub, whose certificate +/// must pass normal web-PKI validation instead (a client-wide pin would +/// reject it and every install would fail). fn build_tls_config(app: &AppHandle, server_url: &str) -> Result, String> { let store_key = extract_host_for_cert_store(server_url)?; let fingerprint = load_stored_fingerprint(app, &store_key)?; match fingerprint { Some(fp) => { + let parsed = url::Url::parse(server_url) + .map_err(|e| format!("failed to parse server URL: {e}"))?; + let host = parsed + .host_str() + .ok_or_else(|| "server URL has no host".to_string())?; + let verifier = HostScopedVerifier::new(host.to_string(), fp)?; let config = rustls::ClientConfig::builder() .dangerous() - .with_custom_certificate_verifier(Arc::new(PinnedVerifier::new(fp))) + .with_custom_certificate_verifier(Arc::new(verifier)) .with_no_client_auth(); Ok(Some(config)) } @@ -57,6 +65,57 @@ fn build_tls_config(app: &AppHandle, server_url: &str) -> Result String { + format!( + "{}/api/v1/client-update/{{{{target}}}}-{{{{arch}}}}-{{{{bundle_type}}}}/{}", + server_url.trim_end_matches('/'), + current_version, + ) +} + +/// Build an updater wired to the given OwnCord server: dynamic endpoint plus +/// host-scoped TOFU TLS. Shared by check and install so the two paths can +/// never diverge on endpoint format or trust configuration. +fn build_updater( + app: &AppHandle, + server_url: &str, +) -> Result { + validate_server_url(server_url)?; + + let current_version = app + .config() + .version + .clone() + .unwrap_or_else(|| "0.0.0".to_string()); + + let endpoint = build_update_endpoint(server_url, ¤t_version); + let url: url::Url = endpoint + .parse() + .map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?; + + // Use TOFU-pinned certificate for self-signed servers, or system certs + // for CA-signed servers. Never blindly accept invalid certs (BUG-134). + let tls_config = build_tls_config(app, server_url)?; + let mut builder = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| format!("failed to set endpoints: {e}"))?; + if let Some(config) = tls_config { + let config = Arc::new(config); + builder = + builder.configure_client(move |client| client.use_preconfigured_tls((*config).clone())); + } + builder + .build() + .map_err(|e| format!("failed to build updater: {e}")) +} + /// Validate that a server URL is safe for the updater to connect to. fn validate_server_url(server_url: &str) -> Result<(), String> { let trimmed = server_url.trim_end_matches('/'); @@ -80,40 +139,7 @@ pub async fn check_client_update( app: AppHandle, server_url: String, ) -> Result { - validate_server_url(&server_url)?; - - 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}"))?; - - // Use TOFU-pinned certificate for self-signed servers, or system certs - // for CA-signed servers. Never blindly accept invalid certs (BUG-134). - let tls_config = build_tls_config(&app, &server_url)?; - let mut builder = app - .updater_builder() - .endpoints(vec![url]) - .map_err(|e| format!("failed to set endpoints: {e}"))?; - if let Some(config) = tls_config { - let config = Arc::new(config); - builder = builder.configure_client(move |client| { - client.use_preconfigured_tls((*config).clone()) - }); - } - let updater = builder - .build() - .map_err(|e| format!("failed to build updater: {e}"))?; + let updater = build_updater(&app, &server_url)?; let update = updater .check() @@ -142,39 +168,7 @@ pub async fn download_and_install_update( app: AppHandle, server_url: String, ) -> Result<(), String> { - validate_server_url(&server_url)?; - - 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}"))?; - - // Use TOFU-pinned certificate for self-signed servers (BUG-134). - let tls_config = build_tls_config(&app, &server_url)?; - let mut builder = app - .updater_builder() - .endpoints(vec![url]) - .map_err(|e| format!("failed to set endpoints: {e}"))?; - if let Some(config) = tls_config { - let config = Arc::new(config); - builder = builder.configure_client(move |client| { - client.use_preconfigured_tls((*config).clone()) - }); - } - let updater = builder - .build() - .map_err(|e| format!("failed to build updater: {e}"))?; + let updater = build_updater(&app, &server_url)?; let update = updater .check() @@ -202,3 +196,28 @@ pub async fn download_and_install_update( None => Err("no update available".into()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_includes_target_arch_and_bundle_type_variables() { + // "{{target}}-{{arch}}-{{bundle_type}}" (e.g. "windows-x86_64-nsis") + // must match the "{os}-{arch}-{installer}" key the updater plugin + // looks up first in the response platforms map — the server echoes + // this path segment back as that key. + assert_eq!( + build_update_endpoint("https://chat.example.com/", "1.2.3"), + "https://chat.example.com/api/v1/client-update/{{target}}-{{arch}}-{{bundle_type}}/1.2.3" + ); + } + + #[test] + fn endpoint_keeps_non_default_port() { + assert_eq!( + build_update_endpoint("https://chat.example.com:8443", "0.0.0"), + "https://chat.example.com:8443/api/v1/client-update/{{target}}-{{arch}}-{{bundle_type}}/0.0.0" + ); + } +} diff --git a/Server/api/client_update.go b/Server/api/client_update.go index 2700b17b..ff1472c1 100644 --- a/Server/api/client_update.go +++ b/Server/api/client_update.go @@ -58,11 +58,14 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc { return } - // Find the .nsis.zip and .nsis.zip.sig assets from the release. - clientAssets := u.FindClientAssets() - nsisURL := clientAssets.InstallerURL + // Find the updater artifact and its signature for the requested + // target ("{os}-{arch}-{installer}", e.g. "windows-x86_64-nsis"). + // Targets without a published updater artifact get 204 — never a + // foreign OS's or foreign installer's artifact. + clientAssets := u.FindClientAssets(target) + installerURL := clientAssets.InstallerURL sigURL := clientAssets.SignatureURL - if nsisURL == "" || sigURL == "" { + if installerURL == "" || sigURL == "" { w.WriteHeader(http.StatusNoContent) return } @@ -82,7 +85,7 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc { Platforms: map[string]tauriPlatformResponse{ target: { Signature: strings.TrimSpace(sigContent), - URL: nsisURL, + URL: installerURL, }, }, } diff --git a/Server/api/client_update_test.go b/Server/api/client_update_test.go index 81c088fe..644befb4 100644 --- a/Server/api/client_update_test.go +++ b/Server/api/client_update_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/go-chi/chi/v5" @@ -12,7 +13,8 @@ import ( ) // fakeGitHubRelease returns a test HTTP server that mimics the GitHub -// Releases API, serving a release with the given tag and NSIS assets. +// Releases API, serving a release with the given tag and the client update +// assets for every platform the release workflow publishes. // Asset download URLs point back to the test server so FetchTextAsset works. func fakeGitHubRelease(t *testing.T, tag string) *httptest.Server { t.Helper() @@ -20,29 +22,40 @@ func fakeGitHubRelease(t *testing.T, tag string) *httptest.Server { var srv *httptest.Server mux := http.NewServeMux() + assetNames := []string{ + "OwnCord_1.0.0_x64-setup.nsis.zip", + "OwnCord_1.0.0_x64-setup.nsis.zip.sig", + "OwnCord_1.0.0_amd64.AppImage.tar.gz", + "OwnCord_1.0.0_amd64.AppImage.tar.gz.sig", + "OwnCord_1.0.0_aarch64.AppImage.tar.gz", + "OwnCord_1.0.0_aarch64.AppImage.tar.gz.sig", + } + mux.HandleFunc("/repos/test/repo/releases/latest", func(w http.ResponseWriter, r *http.Request) { + assets := make([]map[string]any, 0, len(assetNames)) + for _, name := range assetNames { + assets = append(assets, map[string]any{ + "name": name, + "browser_download_url": srv.URL + "/download/" + name, + }) + } resp := map[string]any{ "tag_name": tag, "body": "Release notes here", "html_url": "https://github.com/test/repo/releases/" + tag, - "assets": []map[string]any{ - { - "name": "OwnCord_1.0.0_x64-setup.nsis.zip", - "browser_download_url": srv.URL + "/download/OwnCord_1.0.0_x64-setup.nsis.zip", - }, - { - "name": "OwnCord_1.0.0_x64-setup.nsis.zip.sig", - "browser_download_url": srv.URL + "/download/OwnCord_1.0.0_x64-setup.nsis.zip.sig", - }, - }, + "assets": assets, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) }) - // Serve the signature file content. - mux.HandleFunc("/download/OwnCord_1.0.0_x64-setup.nsis.zip.sig", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ=")) + // Serve signature file content for any .sig asset. + mux.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".sig") { + _, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ=")) + return + } + http.NotFound(w, r) }) srv = httptest.NewServer(mux) @@ -50,6 +63,25 @@ func fakeGitHubRelease(t *testing.T, tag string) *httptest.Server { return srv } +// platformEntry decodes the response body and returns the platforms entry for +// the given target, failing the test if it is missing. +func platformEntry(t *testing.T, rr *httptest.ResponseRecorder, target string) map[string]any { + t.Helper() + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + platforms, ok := resp["platforms"].(map[string]any) + if !ok { + t.Fatalf("response missing platforms map: %v", resp) + } + entry, ok := platforms[target].(map[string]any) + if !ok { + t.Fatalf("platforms missing key %q: %v", target, platforms) + } + return entry +} + func buildClientUpdateRouter(u *updater.Updater) http.Handler { r := chi.NewRouter() api.MountClientUpdateRoute(r, u) @@ -63,7 +95,7 @@ func TestClientUpdate_NewVersionAvailable(t *testing.T) { router := buildClientUpdateRouter(u) - req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64/1.0.0", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) @@ -91,7 +123,7 @@ func TestClientUpdate_AlreadyLatest(t *testing.T) { router := buildClientUpdateRouter(u) - req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64/1.0.0", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) @@ -108,7 +140,111 @@ func TestClientUpdate_FutureVersion(t *testing.T) { router := buildClientUpdateRouter(u) // Client has a newer version than the release. - req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64/2.0.0", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/2.0.0", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestClientUpdate_WindowsTargetGetsNSISInstaller(t *testing.T) { + srv := fakeGitHubRelease(t, "v2.0.0") + u := updater.NewUpdater("1.0.0", "", "test", "repo") + u.SetBaseURL(srv.URL) + + router := buildClientUpdateRouter(u) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + entry := platformEntry(t, rr, "windows-x86_64-nsis") + url, _ := entry["url"].(string) + if !strings.HasSuffix(url, "_x64-setup.nsis.zip") { + t.Errorf("windows url = %q, want NSIS installer", url) + } +} + +func TestClientUpdate_LinuxTargetGetsAppImage(t *testing.T) { + srv := fakeGitHubRelease(t, "v2.0.0") + u := updater.NewUpdater("1.0.0", "", "test", "repo") + u.SetBaseURL(srv.URL) + + router := buildClientUpdateRouter(u) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/linux-x86_64-appimage/1.0.0", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + entry := platformEntry(t, rr, "linux-x86_64-appimage") + url, _ := entry["url"].(string) + if !strings.HasSuffix(url, "_amd64.AppImage.tar.gz") { + t.Errorf("linux url = %q, want x86_64 AppImage updater archive", url) + } + if sig, _ := entry["signature"].(string); sig == "" { + t.Error("linux platform entry missing signature") + } +} + +func TestClientUpdate_LinuxArm64TargetGetsAarch64AppImage(t *testing.T) { + srv := fakeGitHubRelease(t, "v2.0.0") + u := updater.NewUpdater("1.0.0", "", "test", "repo") + u.SetBaseURL(srv.URL) + + router := buildClientUpdateRouter(u) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/linux-aarch64-appimage/1.0.0", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + entry := platformEntry(t, rr, "linux-aarch64-appimage") + url, _ := entry["url"].(string) + if !strings.HasSuffix(url, "_aarch64.AppImage.tar.gz") { + t.Errorf("linux arm64 url = %q, want aarch64 AppImage updater archive", url) + } +} + +func TestClientUpdate_UnsupportedTargetNoContent(t *testing.T) { + srv := fakeGitHubRelease(t, "v2.0.0") + u := updater.NewUpdater("1.0.0", "", "test", "repo") + u.SetBaseURL(srv.URL) + + router := buildClientUpdateRouter(u) + + // No darwin client is published; the updater must not be offered a + // Windows installer for it. + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/darwin-aarch64-app/1.0.0", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestClientUpdate_DebTargetNoContent(t *testing.T) { + srv := fakeGitHubRelease(t, "v2.0.0") + u := updater.NewUpdater("1.0.0", "", "test", "repo") + u.SetBaseURL(srv.URL) + + router := buildClientUpdateRouter(u) + + // The release ships .deb packages but no signed deb UPDATER artifact. + // A deb client falling back to the AppImage archive would fail install + // forever (the plugin's install_deb rejects gzip bytes), so it must get + // 204 rather than an artifact for a different installer. + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/linux-x86_64-deb/1.0.0", nil) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) @@ -129,7 +265,7 @@ func TestClientUpdate_GitHubError(t *testing.T) { router := buildClientUpdateRouter(u) - req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64/1.0.0", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) diff --git a/Server/updater/coverage_boost_test.go b/Server/updater/coverage_boost_test.go index 6e5d922e..224a3740 100644 --- a/Server/updater/coverage_boost_test.go +++ b/Server/updater/coverage_boost_test.go @@ -14,30 +14,49 @@ import ( func TestFindClientAssets_NilCache(t *testing.T) { u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") - ca := u.FindClientAssets() + ca := u.FindClientAssets("windows-x86_64-nsis") if ca.InstallerURL != "" || ca.SignatureURL != "" { t.Error("expected empty ClientAssets when no cache") } } -func TestFindClientAssets_WithMatchingAssets(t *testing.T) { +func TestFindClientAssets_ByTarget(t *testing.T) { u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") u.mu.Lock() u.cache = &UpdateInfo{ Assets: []Asset{ {Name: "OwnCord_1.0.0_x64-setup.nsis.zip", DownloadURL: "https://example.com/installer.zip"}, {Name: "OwnCord_1.0.0_x64-setup.nsis.zip.sig", DownloadURL: "https://example.com/installer.zip.sig"}, + {Name: "OwnCord_1.0.0_amd64.AppImage.tar.gz", DownloadURL: "https://example.com/amd64.AppImage.tar.gz"}, + {Name: "OwnCord_1.0.0_amd64.AppImage.tar.gz.sig", DownloadURL: "https://example.com/amd64.AppImage.tar.gz.sig"}, + {Name: "OwnCord_1.0.0_aarch64.AppImage.tar.gz", DownloadURL: "https://example.com/aarch64.AppImage.tar.gz"}, + {Name: "OwnCord_1.0.0_aarch64.AppImage.tar.gz.sig", DownloadURL: "https://example.com/aarch64.AppImage.tar.gz.sig"}, {Name: "chatserver.exe", DownloadURL: "https://example.com/chatserver.exe"}, }, } u.mu.Unlock() - ca := u.FindClientAssets() - if ca.InstallerURL != "https://example.com/installer.zip" { - t.Errorf("InstallerURL = %q, want installer URL", ca.InstallerURL) + cases := []struct { + target string + wantInstaller string + wantSig string + }{ + {"windows-x86_64-nsis", "https://example.com/installer.zip", "https://example.com/installer.zip.sig"}, + {"linux-x86_64-appimage", "https://example.com/amd64.AppImage.tar.gz", "https://example.com/amd64.AppImage.tar.gz.sig"}, + {"linux-aarch64-appimage", "https://example.com/aarch64.AppImage.tar.gz", "https://example.com/aarch64.AppImage.tar.gz.sig"}, + // No deb updater artifact is published — a deb client must get + // nothing rather than an AppImage archive its installer rejects. + {"linux-x86_64-deb", "", ""}, + {"darwin-aarch64-app", "", ""}, + {"windows-x86_64-unknown", "", ""}, + {"", "", ""}, } - if ca.SignatureURL != "https://example.com/installer.zip.sig" { - t.Errorf("SignatureURL = %q, want signature URL", ca.SignatureURL) + for _, tc := range cases { + ca := u.FindClientAssets(tc.target) + if ca.InstallerURL != tc.wantInstaller || ca.SignatureURL != tc.wantSig { + t.Errorf("FindClientAssets(%q) = {%q, %q}, want {%q, %q}", + tc.target, ca.InstallerURL, ca.SignatureURL, tc.wantInstaller, tc.wantSig) + } } } @@ -52,7 +71,7 @@ func TestFindClientAssets_NoMatchingAssets(t *testing.T) { } u.mu.Unlock() - ca := u.FindClientAssets() + ca := u.FindClientAssets("windows-x86_64-nsis") if ca.InstallerURL != "" || ca.SignatureURL != "" { t.Error("expected empty ClientAssets when no NSIS assets") } diff --git a/Server/updater/release_manifest_test.go b/Server/updater/release_manifest_test.go new file mode 100644 index 00000000..27fc5807 --- /dev/null +++ b/Server/updater/release_manifest_test.go @@ -0,0 +1,88 @@ +package updater + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" +) + +func testHash(seed string) string { + sum := sha256.Sum256([]byte(seed)) + return hex.EncodeToString(sum[:]) +} + +// multiAssetManifest mirrors the exact JSON the release workflow generates: +// legacy top-level fields bind the Windows binary (kept so already-deployed +// servers, which only understand the single-asset schema, can still verify) +// and the assets list binds every OS. +func multiAssetManifest(exeHash, linuxHash string) []byte { + return fmt.Appendf(nil, + `{"version":"v1.0.0","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}]}`, + exeHash, exeHash, linuxHash) +} + +func TestVerifyReleaseManifest_MultiAssetSelectsLinuxEntry(t *testing.T) { + u, key := newSignedTestUpdater(t, "", "1.0.0") + manifest := multiAssetManifest(testHash("exe"), testHash("linux")) + sig := signTestAsset(t, key, manifest) + + got, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "chatserver-linux-amd64.tar.gz") + if err != nil { + t.Fatalf("VerifyReleaseManifest: %v", err) + } + if got.Asset != "chatserver-linux-amd64.tar.gz" || got.SHA256 != testHash("linux") { + t.Errorf("resolved binding = {%s, %s}, want linux entry", got.Asset, got.SHA256) + } +} + +func TestVerifyReleaseManifest_MultiAssetSelectsWindowsEntry(t *testing.T) { + u, key := newSignedTestUpdater(t, "", "1.0.0") + manifest := multiAssetManifest(testHash("exe"), testHash("linux")) + sig := signTestAsset(t, key, manifest) + + got, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "chatserver.exe") + if err != nil { + t.Fatalf("VerifyReleaseManifest: %v", err) + } + if got.Asset != "chatserver.exe" || got.SHA256 != testHash("exe") { + t.Errorf("resolved binding = {%s, %s}, want windows entry", got.Asset, got.SHA256) + } +} + +func TestVerifyReleaseManifest_MultiAssetUnknownAssetFails(t *testing.T) { + u, key := newSignedTestUpdater(t, "", "1.0.0") + manifest := multiAssetManifest(testHash("exe"), testHash("linux")) + sig := signTestAsset(t, key, manifest) + + if _, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "other.bin"); err == nil { + t.Error("expected error for asset the manifest does not bind") + } +} + +func TestVerifyReleaseManifest_MultiAssetBadChecksumFails(t *testing.T) { + u, key := newSignedTestUpdater(t, "", "1.0.0") + manifest := fmt.Appendf(nil, + `{"version":"v1.0.0","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver-linux-amd64.tar.gz","sha256":"not-a-hash"}]}`, + testHash("exe")) + sig := signTestAsset(t, key, manifest) + + if _, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "chatserver-linux-amd64.tar.gz"); err == nil { + t.Error("expected error for invalid checksum in assets entry") + } +} + +func TestVerifyReleaseManifest_LegacySingleAssetStillVerifies(t *testing.T) { + u, key := newSignedTestUpdater(t, "", "1.0.0") + manifest := fmt.Appendf(nil, + `{"version":"v1.0.0","asset":"chatserver.exe","sha256":"%s"}`, testHash("exe")) + sig := signTestAsset(t, key, manifest) + + got, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "chatserver.exe") + if err != nil { + t.Fatalf("VerifyReleaseManifest: %v", err) + } + if got.SHA256 != testHash("exe") { + t.Errorf("SHA256 = %s, want legacy hash", got.SHA256) + } +} diff --git a/Server/updater/updater.go b/Server/updater/updater.go index 2a6c74aa..b958940d 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -76,8 +76,19 @@ type UpdateInfo struct { type releaseManifest struct { Version string `json:"version"` - Asset string `json:"asset"` - SHA256 string `json:"sha256"` + // Asset/SHA256 bind a single artifact. Releases before the multi-OS + // manifest bound only this pair; newer releases keep it pointing at the + // Windows binary so already-deployed servers can still verify and update. + Asset string `json:"asset"` + SHA256 string `json:"sha256"` + // Assets binds every server artifact the release ships (one per OS). + Assets []releaseManifestAsset `json:"assets,omitempty"` +} + +// releaseManifestAsset is one artifact binding in a multi-OS release manifest. +type releaseManifestAsset struct { + Asset string `json:"asset"` + SHA256 string `json:"sha256"` } // Asset is a simplified release asset with name and download URL. @@ -525,26 +536,37 @@ func (u *Updater) VerifyReleaseManifest(manifestData, signatureText []byte, expe return releaseManifest{}, fmt.Errorf("parsing release manifest: %w", err) } manifest.Version = ensureVPrefix(strings.TrimSpace(manifest.Version)) - manifest.Asset = strings.TrimSpace(manifest.Asset) - manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256)) - if manifest.Version == "" || manifest.Asset == "" || manifest.SHA256 == "" { + if manifest.Version == "v" { return releaseManifest{}, fmt.Errorf("release manifest is missing required fields") } if manifest.Version != ensureVPrefix(expectedVersion) { return releaseManifest{}, fmt.Errorf("release manifest version %q does not match release %q", manifest.Version, ensureVPrefix(expectedVersion)) } - if manifest.Asset != expectedAsset { - return releaseManifest{}, fmt.Errorf("release manifest asset %q does not match expected asset %q", manifest.Asset, expectedAsset) - } - if len(manifest.SHA256) != sha256.Size*2 { - return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", manifest.Asset) - } - if _, err := hex.DecodeString(manifest.SHA256); err != nil { - return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", manifest.Asset, err) - } - return manifest, nil + // Candidate bindings: the per-OS assets list plus the legacy single-asset + // pair (the only binding manifests from older releases carry). + candidates := append([]releaseManifestAsset{}, manifest.Assets...) + candidates = append(candidates, releaseManifestAsset{Asset: manifest.Asset, SHA256: manifest.SHA256}) + for _, c := range candidates { + asset := strings.TrimSpace(c.Asset) + if asset == "" || asset != expectedAsset { + continue + } + sum := strings.ToLower(strings.TrimSpace(c.SHA256)) + if len(sum) != sha256.Size*2 { + return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", asset) + } + if _, err := hex.DecodeString(sum); err != nil { + return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", asset, err) + } + // Normalize the returned binding to the matched entry so callers can + // keep reading manifest.Asset/manifest.SHA256 regardless of schema. + manifest.Asset = asset + manifest.SHA256 = sum + return manifest, nil + } + return releaseManifest{}, fmt.Errorf("release manifest does not bind expected asset %q", expectedAsset) } // VerifySignature checks whether the detached minisign signature matches the @@ -720,9 +742,28 @@ func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) { return io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes)) } -// FindClientAssets scans the cached release assets for the Tauri NSIS -// installer zip and its Ed25519 signature file. -func (u *Updater) FindClientAssets() ClientAssets { +// clientAssetSuffixByTarget maps a Tauri updater target +// ("{os}-{arch}-{installer}") to the release asset suffix for that platform's +// updater artifact. The matching signature asset is the same suffix plus +// ".sig". Targets without a published updater artifact are absent — notably +// linux-*-deb: the release ships .deb packages but no signed deb updater +// artifact, and serving the AppImage archive instead would make the plugin's +// install_deb reject every update. +var clientAssetSuffixByTarget = map[string]string{ + "windows-x86_64-nsis": "_x64-setup.nsis.zip", + "linux-x86_64-appimage": "_amd64.AppImage.tar.gz", + "linux-aarch64-appimage": "_aarch64.AppImage.tar.gz", +} + +// FindClientAssets scans the cached release assets for the client updater +// artifact and its signature matching the given Tauri updater target +// (e.g. "windows-x86_64-nsis"). Unknown targets return empty ClientAssets. +func (u *Updater) FindClientAssets(target string) ClientAssets { + suffix, ok := clientAssetSuffixByTarget[target] + if !ok { + return ClientAssets{} + } + u.mu.Lock() defer u.mu.Unlock() @@ -733,9 +774,9 @@ func (u *Updater) FindClientAssets() ClientAssets { var ca ClientAssets for _, a := range u.cache.Assets { switch { - case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip.sig"): + case strings.HasSuffix(a.Name, suffix+".sig"): ca.SignatureURL = a.DownloadURL - case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip"): + case strings.HasSuffix(a.Name, suffix): ca.InstallerURL = a.DownloadURL } } diff --git a/docs/api.md b/docs/api.md index f0a85fd3..70a3fc81 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1098,7 +1098,7 @@ Tauri-compatible update endpoint. The desktop client checks this to see if a new | Param | Type | Description | | ----- | ---- | ----------- | -| `target` | string | Platform target (e.g., `windows-x86_64`) | +| `target` | string | Tauri updater target `{os}-{arch}-{installer}` (e.g., `windows-x86_64-nsis`, `linux-x86_64-appimage`, `linux-aarch64-appimage`). Selects the platform's updater artifact and is echoed back as the `platforms` key. Targets without a published updater artifact (e.g., `linux-x86_64-deb`) get 204. | | `current_version` | string | Client's current semver version (e.g., `1.0.0`) | #### Response 200 OK (update available) @@ -1109,7 +1109,7 @@ Tauri-compatible update endpoint. The desktop client checks this to see if a new "notes": "## What's Changed\n...", "pub_date": "2026-03-28T00:00:00Z", "platforms": { - "windows-x86_64": { + "windows-x86_64-nsis": { "signature": "base64-encoded-signature", "url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/OwnCord_1.2.0_x64-setup.nsis.zip" } @@ -1119,7 +1119,7 @@ Tauri-compatible update endpoint. The desktop client checks this to see if a new #### Response 204 No Content -Client is already up-to-date. +Client is already up-to-date, or no client build is published for `target`. ---