Skip to main content

max / makenotwork

4.8 KB · 129 lines History Blame Raw
1 //! Release-channel abstraction.
2 //!
3 //! The publish step is not hardcoded to one updater or store. A recipe calls
4 //! `publish("<channel>", ...)`; the daemon dispatches to the named
5 //! [`OtaBackend`]. The flaky/lying-exit-code lessons (altool exits 0 on
6 //! failure; notarytool needs retry) get encoded inside the backends so every
7 //! recipe inherits them.
8 //!
9 //! P1 status: the trait, the registry, and a `tauri-mnw` backend skeleton are
10 //! here so recipes can name a channel and the wiring is exercised end-to-end.
11 //! The actual artifact upload to the MNW OTA API (`server/routes/ota.rs`) is
12 //! P2 — [`TauriMnwBackend::publish`] currently performs the guardrail checks
13 //! and returns a descriptive receipt without transferring bytes.
14
15 use crate::domain::{AppId, Target, Version};
16 use anyhow::Result;
17 use std::collections::HashMap;
18 use std::path::Path;
19
20 /// One signed artifact ready to publish for `(app, target, version)`.
21 pub struct Release<'a> {
22 pub app: &'a AppId,
23 pub target: Target,
24 pub version: &'a Version,
25 pub notes: String,
26 }
27
28 /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`,
29 /// `github-releases`, …) is a single `impl` plus registering its id; recipes
30 /// don't change.
31 pub trait OtaBackend: Send + Sync {
32 fn id(&self) -> &str;
33 fn supports(&self, target: Target) -> bool;
34 /// Publish one artifact; return a channel-specific receipt string. Must be
35 /// idempotent at the backend boundary (re-publishing the same
36 /// version+artifact is a no-op, not a duplicate release).
37 fn publish(&self, rel: &Release, artifact: &Path) -> Result<String>;
38 }
39
40 /// Named lookup of registered backends.
41 #[derive(Default)]
42 pub struct OtaRegistry {
43 backends: HashMap<String, Box<dyn OtaBackend>>,
44 }
45
46 impl OtaRegistry {
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 pub fn register(&mut self, backend: Box<dyn OtaBackend>) {
52 self.backends.insert(backend.id().to_string(), backend);
53 }
54
55 pub fn get(&self, channel: &str) -> Option<&dyn OtaBackend> {
56 self.backends.get(channel).map(|b| b.as_ref())
57 }
58
59 /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint.
60 pub fn standard(mnw_base_url: impl Into<String>) -> Self {
61 let mut reg = Self::new();
62 reg.register(Box::new(TauriMnwBackend { base_url: mnw_base_url.into() }));
63 reg
64 }
65 }
66
67 /// Hook into Tauri's OTA system via the MNW server (the manifest host +
68 /// release registry). The Tauri updater polls
69 /// `GET /api/v1/sync/ota/{slug}/{target}/{arch}/{current}` and verifies a
70 /// minisign signature; this backend registers the release + uploads the signed
71 /// `*.app.tar.gz` (+ its `.sig`) so that endpoint serves the right manifest.
72 pub struct TauriMnwBackend {
73 pub base_url: String,
74 }
75
76 impl OtaBackend for TauriMnwBackend {
77 fn id(&self) -> &str {
78 "tauri-mnw"
79 }
80
81 fn supports(&self, target: Target) -> bool {
82 use crate::domain::Platform::*;
83 // Tauri's updater covers the desktop trio; mobile rides testflight/play.
84 matches!(target.platform, Macos | Linux | Windows)
85 }
86
87 fn publish(&self, rel: &Release, artifact: &Path) -> Result<String> {
88 // Guardrail: never publish an artifact that isn't there.
89 anyhow::ensure!(artifact.exists(), "artifact {} does not exist", artifact.display());
90 // P2: POST the release + upload the artifact + its minisign .sig to the
91 // MNW OTA API at `self.base_url`. Until then, report what would happen
92 // so the recipe path is exercised without a half-published release.
93 Ok(format!(
94 "tauri-mnw: would publish {app} {ver} {target} ({artifact}) to {base} [P2: upload not yet wired]",
95 app = rel.app,
96 ver = rel.version,
97 target = rel.target,
98 artifact = artifact.display(),
99 base = self.base_url,
100 ))
101 }
102 }
103
104 #[cfg(test)]
105 mod tests {
106 use super::*;
107
108 #[test]
109 fn standard_registry_has_tauri_mnw() {
110 let reg = OtaRegistry::standard("https://makenot.work");
111 let b = reg.get("tauri-mnw").expect("registered");
112 assert_eq!(b.id(), "tauri-mnw");
113 assert!(b.supports("macos/aarch64".parse().unwrap()));
114 assert!(b.supports("linux/x86_64".parse().unwrap()));
115 assert!(!b.supports("ios/universal".parse().unwrap()));
116 assert!(reg.get("nope").is_none());
117 }
118
119 #[test]
120 fn publish_refuses_missing_artifact() {
121 let reg = OtaRegistry::standard("https://makenot.work");
122 let b = reg.get("tauri-mnw").unwrap();
123 let app = AppId::new("goingson");
124 let ver = Version::parse("0.4.1").unwrap();
125 let rel = Release { app: &app, target: "macos/aarch64".parse().unwrap(), version: &ver, notes: String::new() };
126 assert!(b.publish(&rel, Path::new("/no/such/file")).is_err());
127 }
128 }
129