//! 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::helpers::check_response; use super::SyncKitClient; 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, pub version: String, pub notes: String, pub signature: 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 { pub version: String, pub url: String, pub signature: String, pub notes: String, pub pub_date: String, } #[derive(Serialize)] struct CreateReleaseBody<'a> { version: &'a str, notes: &'a str, signature: &'a str, } #[derive(Serialize)] struct RegisterArtifactBody<'a> { target: &'a str, arch: &'a str, file_size: i64, } impl SyncKitClient { /// Base URL for OTA management endpoints scoped to the authenticated app. fn ota_app_base(&self) -> Result { let (app_id, _user_id) = self.require_session_ids()?; let base = self.config().server_url.trim_end_matches('/'); Ok(format!("{base}/api/v1/sync/ota/apps/{app_id}")) } /// Create a new OTA release for the authenticated app. /// /// Pass an empty `signature` only for unsigned platforms — Tauri's updater /// silently refuses an update whose manifest signature is empty, so a real /// release must carry the minisign signature of the artifact. #[instrument(skip(self, signature))] pub async fn ota_create_release( &self, version: &str, notes: &str, signature: &str, ) -> Result { let token = self.require_token()?; let url = format!("{}/releases", self.ota_app_base()?); let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody { version, notes, signature, })?); self.retry_request_json(|| { 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`), and `file_size` is the artifact size in bytes. #[instrument(skip(self))] pub async fn ota_register_artifact( &self, release_id: Uuid, target: &str, arch: &str, file_size: i64, ) -> Result { let token = self.require_token()?; let url = format!("{}/releases/{release_id}/artifacts", self.ota_app_base()?); let body = Bytes::from(serde_json::to_vec(&RegisterArtifactBody { target, arch, file_size, })?); self.retry_request_json(|| { 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 } /// 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(|| { let req = self .http .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 base = self.config().server_url.trim_end_matches('/'); let url = format!("{base}/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}"); let resp = self .retry_request(|| { let req = self.http.get(&url); async move { check_response(req.send().await?).await } }) .await?; if resp.status() == reqwest::StatusCode::NO_CONTENT { return Ok(None); } Ok(Some(resp.json::().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", signature: "RWS...==", }; let v: serde_json::Value = serde_json::to_value(&body).unwrap(); assert_eq!(v["version"], "0.4.1"); assert_eq!(v["notes"], "Bug fixes"); assert_eq!(v["signature"], "RWS...=="); } #[test] fn register_artifact_body_serializes_expected_fields() { let body = RegisterArtifactBody { target: "darwin", arch: "aarch64", file_size: 12_345, }; 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); } #[test] fn release_response_deserializes_with_uuid_id() { let json = r#"{ "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "version": "0.4.1", "notes": "", "signature": "RWS=", "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="); } }