Skip to main content

max / makenotwork

8.7 KB · 266 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::helpers::check_response;
23 use super::SyncKitClient;
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 pub signature: String,
34 }
35
36 /// A presigned upload target for an OTA artifact.
37 #[derive(Debug, Clone, Deserialize)]
38 pub struct OtaArtifactUpload {
39 /// Presigned S3 PUT URL — upload the artifact bytes here.
40 pub upload_url: String,
41 /// The S3 object key the artifact will live at.
42 pub s3_key: String,
43 }
44
45 /// The Tauri-compatible updater manifest returned by the public updater check.
46 ///
47 /// Field names mirror the server's `TauriUpdaterResponse` exactly; Tauri's
48 /// updater plugin reads these five and nothing else.
49 #[derive(Debug, Clone, Deserialize)]
50 pub struct OtaManifest {
51 pub version: String,
52 pub url: String,
53 pub signature: String,
54 pub notes: String,
55 pub pub_date: String,
56 }
57
58 #[derive(Serialize)]
59 struct CreateReleaseBody<'a> {
60 version: &'a str,
61 notes: &'a str,
62 signature: &'a str,
63 }
64
65 #[derive(Serialize)]
66 struct RegisterArtifactBody<'a> {
67 target: &'a str,
68 arch: &'a str,
69 file_size: i64,
70 }
71
72 impl SyncKitClient {
73 /// Base URL for OTA management endpoints scoped to the authenticated app.
74 fn ota_app_base(&self) -> Result<String> {
75 let (app_id, _user_id) = self.require_session_ids()?;
76 let base = self.config().server_url.trim_end_matches('/');
77 Ok(format!("{base}/api/v1/sync/ota/apps/{app_id}"))
78 }
79
80 /// Create a new OTA release for the authenticated app.
81 ///
82 /// Pass an empty `signature` only for unsigned platforms — Tauri's updater
83 /// silently refuses an update whose manifest signature is empty, so a real
84 /// release must carry the minisign signature of the artifact.
85 #[instrument(skip(self, signature))]
86 pub async fn ota_create_release(
87 &self,
88 version: &str,
89 notes: &str,
90 signature: &str,
91 ) -> Result<OtaRelease> {
92 let token = self.require_token()?;
93 let url = format!("{}/releases", self.ota_app_base()?);
94
95 let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody {
96 version,
97 notes,
98 signature,
99 })?);
100
101 self.retry_request_json(|| {
102 let req = self
103 .http
104 .post(&url)
105 .bearer_auth(&token)
106 .header("content-type", "application/json")
107 .body(body.clone());
108 async move { check_response(req.send().await?).await }
109 })
110 .await
111 }
112
113 /// Register an artifact for a release and obtain a presigned upload URL.
114 ///
115 /// `target` is the OS (`linux`/`darwin`/`windows`), `arch` is the CPU
116 /// (`x86_64`/`aarch64`), and `file_size` is the artifact size in bytes.
117 #[instrument(skip(self))]
118 pub async fn ota_register_artifact(
119 &self,
120 release_id: Uuid,
121 target: &str,
122 arch: &str,
123 file_size: i64,
124 ) -> Result<OtaArtifactUpload> {
125 let token = self.require_token()?;
126 let url = format!("{}/releases/{release_id}/artifacts", self.ota_app_base()?);
127
128 let body = Bytes::from(serde_json::to_vec(&RegisterArtifactBody {
129 target,
130 arch,
131 file_size,
132 })?);
133
134 self.retry_request_json(|| {
135 let req = self
136 .http
137 .post(&url)
138 .bearer_auth(&token)
139 .header("content-type", "application/json")
140 .body(body.clone());
141 async move { check_response(req.send().await?).await }
142 })
143 .await
144 }
145
146 /// Upload artifact bytes to S3 via a presigned PUT URL.
147 ///
148 /// The bytes are sent as-is (no encryption — OTA artifacts are public).
149 #[instrument(skip(self, presigned_url, data))]
150 pub async fn ota_upload_artifact(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> {
151 let data = Bytes::from(data);
152 self.retry_request(|| {
153 let req = self
154 .http
155 .put(presigned_url)
156 .header("content-type", "application/octet-stream")
157 .body(data.clone());
158 async move { check_response(req.send().await?).await }
159 })
160 .await?;
161 Ok(())
162 }
163
164 /// Check the public Tauri updater endpoint.
165 ///
166 /// Returns `Some(manifest)` when a newer version than `current_version` is
167 /// available for `slug`/`target`/`arch`, or `None` when the client is up to
168 /// date (HTTP 204). Use this to verify a freshly published release is live.
169 #[instrument(skip(self))]
170 pub async fn ota_updater_check(
171 &self,
172 slug: &str,
173 target: &str,
174 arch: &str,
175 current_version: &str,
176 ) -> Result<Option<OtaManifest>> {
177 let base = self.config().server_url.trim_end_matches('/');
178 let url = format!("{base}/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}");
179
180 let resp = self
181 .retry_request(|| {
182 let req = self.http.get(&url);
183 async move { check_response(req.send().await?).await }
184 })
185 .await?;
186
187 if resp.status() == reqwest::StatusCode::NO_CONTENT {
188 return Ok(None);
189 }
190
191 Ok(Some(resp.json::<OtaManifest>().await?))
192 }
193 }
194
195 #[cfg(test)]
196 mod tests {
197 use super::*;
198
199 #[test]
200 fn create_release_body_serializes_expected_fields() {
201 let body = CreateReleaseBody {
202 version: "0.4.1",
203 notes: "Bug fixes",
204 signature: "RWS...==",
205 };
206 let v: serde_json::Value = serde_json::to_value(&body).unwrap();
207 assert_eq!(v["version"], "0.4.1");
208 assert_eq!(v["notes"], "Bug fixes");
209 assert_eq!(v["signature"], "RWS...==");
210 }
211
212 #[test]
213 fn register_artifact_body_serializes_expected_fields() {
214 let body = RegisterArtifactBody {
215 target: "darwin",
216 arch: "aarch64",
217 file_size: 12_345,
218 };
219 let v: serde_json::Value = serde_json::to_value(&body).unwrap();
220 assert_eq!(v["target"], "darwin");
221 assert_eq!(v["arch"], "aarch64");
222 assert_eq!(v["file_size"], 12_345);
223 }
224
225 #[test]
226 fn release_response_deserializes_with_uuid_id() {
227 let json = r#"{
228 "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
229 "version": "0.4.1",
230 "notes": "",
231 "signature": "RWS=",
232 "pub_date": "2026-06-07T00:00:00Z",
233 "created_at": "2026-06-07T00:00:00Z"
234 }"#;
235 let r: OtaRelease = serde_json::from_str(json).unwrap();
236 assert_eq!(r.version, "0.4.1");
237 assert_eq!(
238 r.id,
239 Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()
240 );
241 }
242
243 #[test]
244 fn artifact_upload_response_deserializes() {
245 let json = r#"{"upload_url": "https://s3.example/put?sig=abc", "s3_key": "ota/app/0.4.1/darwin/aarch64/artifact"}"#;
246 let u: OtaArtifactUpload = serde_json::from_str(json).unwrap();
247 assert_eq!(u.upload_url, "https://s3.example/put?sig=abc");
248 assert!(u.s3_key.ends_with("/artifact"));
249 }
250
251 #[test]
252 fn manifest_deserializes_tauri_five_fields() {
253 let json = r#"{
254 "version": "0.4.1",
255 "url": "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64",
256 "signature": "RWS=",
257 "notes": "Bug fixes",
258 "pub_date": "2026-06-07T00:00:00+00:00"
259 }"#;
260 let m: OtaManifest = serde_json::from_str(json).unwrap();
261 assert_eq!(m.version, "0.4.1");
262 assert!(m.url.contains("/download/"));
263 assert_eq!(m.signature, "RWS=");
264 }
265 }
266