//! Release-channel abstraction. //! //! The publish step is not hardcoded to one updater or store. A recipe calls //! `publish("", ...)`; the daemon dispatches to the named //! [`OtaBackend`]. The flaky/lying-exit-code lessons (altool exits 0 on //! failure; notarytool needs retry) get encoded inside the backends so every //! recipe inherits them. //! //! P1 status: the trait, the registry, and a `tauri-mnw` backend skeleton are //! here so recipes can name a channel and the wiring is exercised end-to-end. //! The actual artifact upload to the MNW OTA API (`server/routes/ota.rs`) is //! P2 — [`TauriMnwBackend::publish`] currently performs the guardrail checks //! and returns a descriptive receipt without transferring bytes. use crate::domain::{AppId, Target, Version}; use anyhow::Result; use std::collections::HashMap; use std::path::Path; /// One signed artifact ready to publish for `(app, target, version)`. pub struct Release<'a> { pub app: &'a AppId, pub target: Target, pub version: &'a Version, pub notes: String, } /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`, /// `github-releases`, …) is a single `impl` plus registering its id; recipes /// don't change. pub trait OtaBackend: Send + Sync { fn id(&self) -> &str; fn supports(&self, target: Target) -> bool; /// Publish one artifact; return a channel-specific receipt string. Must be /// idempotent at the backend boundary (re-publishing the same /// version+artifact is a no-op, not a duplicate release). fn publish(&self, rel: &Release, artifact: &Path) -> Result; } /// Named lookup of registered backends. #[derive(Default)] pub struct OtaRegistry { backends: HashMap>, } impl OtaRegistry { pub fn new() -> Self { Self::default() } pub fn register(&mut self, backend: Box) { self.backends.insert(backend.id().to_string(), backend); } pub fn get(&self, channel: &str) -> Option<&dyn OtaBackend> { self.backends.get(channel).map(|b| b.as_ref()) } /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint. pub fn standard(mnw_base_url: impl Into) -> Self { let mut reg = Self::new(); reg.register(Box::new(TauriMnwBackend { base_url: mnw_base_url.into() })); reg } } /// Hook into Tauri's OTA system via the MNW server (the manifest host + /// release registry). The Tauri updater polls /// `GET /api/v1/sync/ota/{slug}/{target}/{arch}/{current}` and verifies a /// minisign signature; this backend registers the release + uploads the signed /// `*.app.tar.gz` (+ its `.sig`) so that endpoint serves the right manifest. pub struct TauriMnwBackend { pub base_url: String, } impl OtaBackend for TauriMnwBackend { fn id(&self) -> &str { "tauri-mnw" } fn supports(&self, target: Target) -> bool { use crate::domain::Platform::*; // Tauri's updater covers the desktop trio; mobile rides testflight/play. matches!(target.platform, Macos | Linux | Windows) } fn publish(&self, rel: &Release, artifact: &Path) -> Result { // Guardrail: never publish an artifact that isn't there. anyhow::ensure!(artifact.exists(), "artifact {} does not exist", artifact.display()); // P2: POST the release + upload the artifact + its minisign .sig to the // MNW OTA API at `self.base_url`. Until then, report what would happen // so the recipe path is exercised without a half-published release. Ok(format!( "tauri-mnw: would publish {app} {ver} {target} ({artifact}) to {base} [P2: upload not yet wired]", app = rel.app, ver = rel.version, target = rel.target, artifact = artifact.display(), base = self.base_url, )) } } #[cfg(test)] mod tests { use super::*; #[test] fn standard_registry_has_tauri_mnw() { let reg = OtaRegistry::standard("https://makenot.work"); let b = reg.get("tauri-mnw").expect("registered"); assert_eq!(b.id(), "tauri-mnw"); assert!(b.supports("macos/aarch64".parse().unwrap())); assert!(b.supports("linux/x86_64".parse().unwrap())); assert!(!b.supports("ios/universal".parse().unwrap())); assert!(reg.get("nope").is_none()); } #[test] fn publish_refuses_missing_artifact() { let reg = OtaRegistry::standard("https://makenot.work"); let b = reg.get("tauri-mnw").unwrap(); let app = AppId::new("goingson"); let ver = Version::parse("0.4.1").unwrap(); let rel = Release { app: &app, target: "macos/aarch64".parse().unwrap(), version: &ver, notes: String::new() }; assert!(b.publish(&rel, Path::new("/no/such/file")).is_err()); } }