//! OTA (Over-The-Air) release publishing. //! //! These methods drive the app-owner side of the MNW OTA system: create a //! release, register an artifact (which returns a presigned S3 PUT URL), upload //! the bytes, and verify the public Tauri updater endpoint now serves it. They //! reuse the authenticated [`SyncKitClient`] session (JWT + app id), so a //! publisher authenticates once with [`authenticate`](SyncKitClient::authenticate) //! and then calls these in sequence. //! //! Unlike blobs, OTA artifacts are NOT end-to-end encrypted, they are public //! downloads served to every installed app, so the bytes are uploaded as-is. //! //! Server contract: `server/src/routes/ota.rs`. The Tauri updater manifest //! ([`OtaManifest`]) field names are load-bearing, Tauri's updater plugin //! deserializes exactly these five fields. use bytes::Bytes; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; use super::SyncKitClient; use super::helpers::{Idempotency, check_response}; use crate::error::Result; /// A created OTA release (subset of the server's release response). #[derive(Debug, Clone, Deserialize)] pub struct OtaRelease { /// Release id, used when registering artifacts. pub id: Uuid, /// Release version string (semver). pub version: String, /// Release notes / changelog text. pub notes: String, } /// A presigned upload target for an OTA artifact. #[derive(Debug, Clone, Deserialize)] pub struct OtaArtifactUpload { /// Presigned S3 PUT URL, upload the artifact bytes here. pub upload_url: String, /// The S3 object key the artifact will live at. pub s3_key: String, } /// The Tauri-compatible updater manifest returned by the public updater check. /// /// Field names mirror the server's `TauriUpdaterResponse` exactly; Tauri's /// updater plugin reads these five and nothing else. #[derive(Debug, Clone, Deserialize)] pub struct OtaManifest { /// Version offered by the update. pub version: String, /// Download URL for the update artifact. pub url: String, /// Detached signature Tauri verifies against the pinned public key. pub signature: String, /// Release notes shown in the update prompt. pub notes: String, /// Publish timestamp (RFC 3339). pub pub_date: String, } #[derive(Serialize)] struct CreateReleaseBody<'a> { version: &'a str, notes: &'a str, } #[derive(Serialize)] struct RegisterArtifactBody<'a> { target: &'a str, arch: &'a str, file_size: i64, signature: &'a str, } impl SyncKitClient { /// Create a new OTA release for the authenticated app. /// /// The signature is per-artifact (Tauri signs each platform's file /// independently), so it is supplied to [`Self::ota_register_artifact`], not /// here. #[instrument(skip(self))] pub async fn ota_create_release(&self, version: &str, notes: &str) -> Result { let token = self.require_token()?; let (app_id, _user_id) = self.require_session_ids()?; let url = self.endpoints.ota_releases(app_id); let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody { version, notes })?); self.retry_request_json( Idempotency::IdempotentWrite { on: "(app_id, version) unique, migration 033", }, || { let req = self .http .post(&url) .bearer_auth(&token) .header("content-type", "application/json") .body(body.clone()); async move { check_response(req.send().await?).await } }, ) .await } /// Register an artifact for a release and obtain a presigned upload URL. /// /// `target` is the OS (`linux`/`darwin`/`windows`), `arch` is the CPU /// (`x86_64`/`aarch64`), `file_size` is the artifact size in bytes, and /// `signature` is this file's minisign signature, Tauri's updater silently /// refuses an update whose signature is empty or belongs to another platform, /// so each artifact must carry its own. #[instrument(skip(self, signature))] pub async fn ota_register_artifact( &self, release_id: Uuid, target: &str, arch: &str, file_size: i64, signature: &str, ) -> Result { let token = self.require_token()?; let (app_id, _user_id) = self.require_session_ids()?; let url = self.endpoints.ota_artifacts(app_id, release_id); let body = Bytes::from(serde_json::to_vec(&RegisterArtifactBody { target, arch, file_size, signature, })?); self.retry_request_json( Idempotency::IdempotentWrite { on: "(release_id, target, arch) unique, migration 033", }, || { let req = self .http .post(&url) .bearer_auth(&token) .header("content-type", "application/json") .body(body.clone()); async move { check_response(req.send().await?).await } }, ) .await } /// Confirm an uploaded artifact: the server verifies the object landed and /// enqueues its malware scan. Until the scan clears, the artifact stays /// `pending` and is neither advertised nor downloadable. Call after /// [`Self::ota_upload_artifact`]. #[instrument(skip(self))] pub async fn ota_confirm_artifact( &self, release_id: Uuid, target: &str, arch: &str, ) -> Result<()> { let token = self.require_token()?; let (app_id, _user_id) = self.require_session_ids()?; let url = self.endpoints.ota_confirm(app_id, release_id); let body = Bytes::from(serde_json::to_vec(&serde_json::json!({ "target": target, "arch": arch, }))?); self.retry_request( Idempotency::IdempotentWrite { on: "(release_id, target, arch)", }, || { let req = self .http .post(&url) .bearer_auth(&token) .header("content-type", "application/json") .body(body.clone()); async move { check_response(req.send().await?).await } }, ) .await?; Ok(()) } /// Upload artifact bytes to S3 via a presigned PUT URL. /// /// The bytes are sent as-is (no encryption, OTA artifacts are public). #[instrument(skip(self, presigned_url, data))] pub async fn ota_upload_artifact(&self, presigned_url: &str, data: Vec) -> Result<()> { let data = Bytes::from(data); self.retry_request( Idempotency::IdempotentWrite { on: "S3 key (content-addressed)", }, || { let req = self .http_stream .put(presigned_url) .header("content-type", "application/octet-stream") .body(data.clone()); async move { check_response(req.send().await?).await } }, ) .await?; Ok(()) } /// Check the public Tauri updater endpoint. /// /// Returns `Some(manifest)` when a newer version than `current_version` is /// available for `slug`/`target`/`arch`, or `None` when the client is up to /// date (HTTP 204). Use this to verify a freshly published release is live. #[instrument(skip(self))] pub async fn ota_updater_check( &self, slug: &str, target: &str, arch: &str, current_version: &str, ) -> Result> { let url = self .endpoints .ota_updater(slug, target, arch, current_version); let resp = self .retry_request(Idempotency::ReadOnly, || { let req = self.http_stream.get(&url); async move { check_response(req.send().await?).await } }) .await?; if resp.status() == reqwest::StatusCode::NO_CONTENT { return Ok(None); } Ok(Some( crate::client::helpers::read_json_capped::( resp, crate::client::helpers::MAX_CONTROL_BODY_BYTES, ) .await?, )) } } #[cfg(test)] mod tests { use super::*; #[test] fn create_release_body_serializes_expected_fields() { let body = CreateReleaseBody { version: "0.4.1", notes: "Bug fixes", }; let v: serde_json::Value = serde_json::to_value(&body).unwrap(); assert_eq!(v["version"], "0.4.1"); assert_eq!(v["notes"], "Bug fixes"); // Signature is per-artifact now, not on the release. assert!(v.get("signature").is_none()); } #[test] fn register_artifact_body_serializes_expected_fields() { let body = RegisterArtifactBody { target: "darwin", arch: "aarch64", file_size: 12_345, signature: "RWS...==", }; let v: serde_json::Value = serde_json::to_value(&body).unwrap(); assert_eq!(v["target"], "darwin"); assert_eq!(v["arch"], "aarch64"); assert_eq!(v["file_size"], 12_345); assert_eq!(v["signature"], "RWS...=="); } #[test] fn release_response_deserializes_with_uuid_id() { let json = r#"{ "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "version": "0.4.1", "notes": "", "pub_date": "2026-06-07T00:00:00Z", "created_at": "2026-06-07T00:00:00Z" }"#; let r: OtaRelease = serde_json::from_str(json).unwrap(); assert_eq!(r.version, "0.4.1"); assert_eq!( r.id, Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap() ); } #[test] fn artifact_upload_response_deserializes() { let json = r#"{"upload_url": "https://s3.example/put?sig=abc", "s3_key": "ota/app/0.4.1/darwin/aarch64/artifact"}"#; let u: OtaArtifactUpload = serde_json::from_str(json).unwrap(); assert_eq!(u.upload_url, "https://s3.example/put?sig=abc"); assert!(u.s3_key.ends_with("/artifact")); } #[test] fn manifest_deserializes_tauri_five_fields() { let json = r#"{ "version": "0.4.1", "url": "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64", "signature": "RWS=", "notes": "Bug fixes", "pub_date": "2026-06-07T00:00:00+00:00" }"#; let m: OtaManifest = serde_json::from_str(json).unwrap(); assert_eq!(m.version, "0.4.1"); assert!(m.url.contains("/download/")); assert_eq!(m.signature, "RWS="); } }