Skip to main content

max / synckit

10.5 KB · 316 lines History Blame Raw
1 //! OTA (Over-The-Air) release publishing.
2 //!
3 //! These methods drive the app-owner side of the MNW OTA system: create a
4 //! release, register an artifact (which returns a presigned S3 PUT URL), upload
5 //! the bytes, and verify the public Tauri updater endpoint now serves it. They
6 //! reuse the authenticated [`SyncKitClient`] session (JWT + app id), so a
7 //! publisher authenticates once with [`authenticate`](SyncKitClient::authenticate)
8 //! and then calls these in sequence.
9 //!
10 //! Unlike blobs, OTA artifacts are NOT end-to-end encrypted, they are public
11 //! downloads served to every installed app, so the bytes are uploaded as-is.
12 //!
13 //! Server contract: `server/src/routes/ota.rs`. The Tauri updater manifest
14 //! ([`OtaManifest`]) field names are load-bearing, Tauri's updater plugin
15 //! deserializes exactly these five fields.
16
17 use bytes::Bytes;
18 use serde::{Deserialize, Serialize};
19 use tracing::instrument;
20 use uuid::Uuid;
21
22 use super::SyncKitClient;
23 use super::helpers::{Idempotency, check_response};
24 use crate::error::Result;
25
26 /// A created OTA release (subset of the server's release response).
27 #[derive(Debug, Clone, Deserialize)]
28 pub struct OtaRelease {
29 /// Release id, used when registering artifacts.
30 pub id: Uuid,
31 pub version: String,
32 pub notes: String,
33 }
34
35 /// A presigned upload target for an OTA artifact.
36 #[derive(Debug, Clone, Deserialize)]
37 pub struct OtaArtifactUpload {
38 /// Presigned S3 PUT URL, upload the artifact bytes here.
39 pub upload_url: String,
40 /// The S3 object key the artifact will live at.
41 pub s3_key: String,
42 }
43
44 /// The Tauri-compatible updater manifest returned by the public updater check.
45 ///
46 /// Field names mirror the server's `TauriUpdaterResponse` exactly; Tauri's
47 /// updater plugin reads these five and nothing else.
48 #[derive(Debug, Clone, Deserialize)]
49 pub struct OtaManifest {
50 pub version: String,
51 pub url: String,
52 pub signature: String,
53 pub notes: String,
54 pub pub_date: String,
55 }
56
57 #[derive(Serialize)]
58 struct CreateReleaseBody<'a> {
59 version: &'a str,
60 notes: &'a str,
61 }
62
63 #[derive(Serialize)]
64 struct RegisterArtifactBody<'a> {
65 target: &'a str,
66 arch: &'a str,
67 file_size: i64,
68 signature: &'a str,
69 }
70
71 impl SyncKitClient {
72 /// Create a new OTA release for the authenticated app.
73 ///
74 /// The signature is per-artifact (Tauri signs each platform's file
75 /// independently), so it is supplied to [`Self::ota_register_artifact`], not
76 /// here.
77 #[instrument(skip(self))]
78 pub async fn ota_create_release(&self, version: &str, notes: &str) -> Result<OtaRelease> {
79 let token = self.require_token()?;
80 let (app_id, _user_id) = self.require_session_ids()?;
81 let url = self.endpoints.ota_releases(app_id);
82
83 let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody { version, notes })?);
84
85 self.retry_request_json(
86 Idempotency::IdempotentWrite {
87 on: "(app_id, version) unique, migration 033",
88 },
89 || {
90 let req = self
91 .http
92 .post(&url)
93 .bearer_auth(&token)
94 .header("content-type", "application/json")
95 .body(body.clone());
96 async move { check_response(req.send().await?).await }
97 },
98 )
99 .await
100 }
101
102 /// Register an artifact for a release and obtain a presigned upload URL.
103 ///
104 /// `target` is the OS (`linux`/`darwin`/`windows`), `arch` is the CPU
105 /// (`x86_64`/`aarch64`), `file_size` is the artifact size in bytes, and
106 /// `signature` is this file's minisign signature, Tauri's updater silently
107 /// refuses an update whose signature is empty or belongs to another platform,
108 /// so each artifact must carry its own.
109 #[instrument(skip(self, signature))]
110 pub async fn ota_register_artifact(
111 &self,
112 release_id: Uuid,
113 target: &str,
114 arch: &str,
115 file_size: i64,
116 signature: &str,
117 ) -> Result<OtaArtifactUpload> {
118 let token = self.require_token()?;
119 let (app_id, _user_id) = self.require_session_ids()?;
120 let url = self.endpoints.ota_artifacts(app_id, release_id);
121
122 let body = Bytes::from(serde_json::to_vec(&RegisterArtifactBody {
123 target,
124 arch,
125 file_size,
126 signature,
127 })?);
128
129 self.retry_request_json(
130 Idempotency::IdempotentWrite {
131 on: "(release_id, target, arch) unique, migration 033",
132 },
133 || {
134 let req = self
135 .http
136 .post(&url)
137 .bearer_auth(&token)
138 .header("content-type", "application/json")
139 .body(body.clone());
140 async move { check_response(req.send().await?).await }
141 },
142 )
143 .await
144 }
145
146 /// Confirm an uploaded artifact: the server verifies the object landed and
147 /// enqueues its malware scan. Until the scan clears, the artifact stays
148 /// `pending` and is neither advertised nor downloadable. Call after
149 /// [`Self::ota_upload_artifact`].
150 #[instrument(skip(self))]
151 pub async fn ota_confirm_artifact(
152 &self,
153 release_id: Uuid,
154 target: &str,
155 arch: &str,
156 ) -> Result<()> {
157 let token = self.require_token()?;
158 let (app_id, _user_id) = self.require_session_ids()?;
159 let url = self.endpoints.ota_confirm(app_id, release_id);
160 let body = Bytes::from(serde_json::to_vec(&serde_json::json!({
161 "target": target,
162 "arch": arch,
163 }))?);
164
165 self.retry_request(
166 Idempotency::IdempotentWrite {
167 on: "(release_id, target, arch)",
168 },
169 || {
170 let req = self
171 .http
172 .post(&url)
173 .bearer_auth(&token)
174 .header("content-type", "application/json")
175 .body(body.clone());
176 async move { check_response(req.send().await?).await }
177 },
178 )
179 .await?;
180 Ok(())
181 }
182
183 /// Upload artifact bytes to S3 via a presigned PUT URL.
184 ///
185 /// The bytes are sent as-is (no encryption, OTA artifacts are public).
186 #[instrument(skip(self, presigned_url, data))]
187 pub async fn ota_upload_artifact(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> {
188 let data = Bytes::from(data);
189 self.retry_request(
190 Idempotency::IdempotentWrite {
191 on: "S3 key (content-addressed)",
192 },
193 || {
194 let req = self
195 .http_stream
196 .put(presigned_url)
197 .header("content-type", "application/octet-stream")
198 .body(data.clone());
199 async move { check_response(req.send().await?).await }
200 },
201 )
202 .await?;
203 Ok(())
204 }
205
206 /// Check the public Tauri updater endpoint.
207 ///
208 /// Returns `Some(manifest)` when a newer version than `current_version` is
209 /// available for `slug`/`target`/`arch`, or `None` when the client is up to
210 /// date (HTTP 204). Use this to verify a freshly published release is live.
211 #[instrument(skip(self))]
212 pub async fn ota_updater_check(
213 &self,
214 slug: &str,
215 target: &str,
216 arch: &str,
217 current_version: &str,
218 ) -> Result<Option<OtaManifest>> {
219 let url = self
220 .endpoints
221 .ota_updater(slug, target, arch, current_version);
222
223 let resp = self
224 .retry_request(Idempotency::ReadOnly, || {
225 let req = self.http_stream.get(&url);
226 async move { check_response(req.send().await?).await }
227 })
228 .await?;
229
230 if resp.status() == reqwest::StatusCode::NO_CONTENT {
231 return Ok(None);
232 }
233
234 Ok(Some(
235 crate::client::helpers::read_json_capped::<OtaManifest>(
236 resp,
237 crate::client::helpers::MAX_CONTROL_BODY_BYTES,
238 )
239 .await?,
240 ))
241 }
242 }
243
244 #[cfg(test)]
245 mod tests {
246 use super::*;
247
248 #[test]
249 fn create_release_body_serializes_expected_fields() {
250 let body = CreateReleaseBody {
251 version: "0.4.1",
252 notes: "Bug fixes",
253 };
254 let v: serde_json::Value = serde_json::to_value(&body).unwrap();
255 assert_eq!(v["version"], "0.4.1");
256 assert_eq!(v["notes"], "Bug fixes");
257 // Signature is per-artifact now, not on the release.
258 assert!(v.get("signature").is_none());
259 }
260
261 #[test]
262 fn register_artifact_body_serializes_expected_fields() {
263 let body = RegisterArtifactBody {
264 target: "darwin",
265 arch: "aarch64",
266 file_size: 12_345,
267 signature: "RWS...==",
268 };
269 let v: serde_json::Value = serde_json::to_value(&body).unwrap();
270 assert_eq!(v["target"], "darwin");
271 assert_eq!(v["arch"], "aarch64");
272 assert_eq!(v["file_size"], 12_345);
273 assert_eq!(v["signature"], "RWS...==");
274 }
275
276 #[test]
277 fn release_response_deserializes_with_uuid_id() {
278 let json = r#"{
279 "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
280 "version": "0.4.1",
281 "notes": "",
282 "pub_date": "2026-06-07T00:00:00Z",
283 "created_at": "2026-06-07T00:00:00Z"
284 }"#;
285 let r: OtaRelease = serde_json::from_str(json).unwrap();
286 assert_eq!(r.version, "0.4.1");
287 assert_eq!(
288 r.id,
289 Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()
290 );
291 }
292
293 #[test]
294 fn artifact_upload_response_deserializes() {
295 let json = r#"{"upload_url": "https://s3.example/put?sig=abc", "s3_key": "ota/app/0.4.1/darwin/aarch64/artifact"}"#;
296 let u: OtaArtifactUpload = serde_json::from_str(json).unwrap();
297 assert_eq!(u.upload_url, "https://s3.example/put?sig=abc");
298 assert!(u.s3_key.ends_with("/artifact"));
299 }
300
301 #[test]
302 fn manifest_deserializes_tauri_five_fields() {
303 let json = r#"{
304 "version": "0.4.1",
305 "url": "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64",
306 "signature": "RWS=",
307 "notes": "Bug fixes",
308 "pub_date": "2026-06-07T00:00:00+00:00"
309 }"#;
310 let m: OtaManifest = serde_json::from_str(json).unwrap();
311 assert_eq!(m.version, "0.4.1");
312 assert!(m.url.contains("/download/"));
313 assert_eq!(m.signature, "RWS=");
314 }
315 }
316