| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
use std::path::PathBuf; |
| 12 |
|
| 13 |
use anyhow::{Context, Result, bail}; |
| 14 |
use synckit_client::{SyncKitClient, SyncKitConfig}; |
| 15 |
|
| 16 |
const DEFAULT_SERVER: &str = "https://makenot.work"; |
| 17 |
const ALLOWED_TARGETS: &[&str] = &["linux", "darwin", "windows"]; |
| 18 |
const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"]; |
| 19 |
|
| 20 |
|
| 21 |
pub(crate) async fn run(rest: &[String]) -> Result<()> { |
| 22 |
match rest.first().map(String::as_str) { |
| 23 |
Some("publish") => publish(&rest[1..]).await, |
| 24 |
Some("-h" | "--help") | None => { |
| 25 |
print_usage(); |
| 26 |
Ok(()) |
| 27 |
} |
| 28 |
Some(other) => { |
| 29 |
eprintln!("Unknown ota subcommand: {other}\n"); |
| 30 |
print_usage(); |
| 31 |
std::process::exit(2); |
| 32 |
} |
| 33 |
} |
| 34 |
} |
| 35 |
|
| 36 |
fn print_usage() { |
| 37 |
eprintln!( |
| 38 |
"Usage: mnw-cli ota publish --slug SLUG --version X.Y.Z --target OS --arch ARCH --artifact FILE\n\ |
| 39 |
\n\ |
| 40 |
Required:\n\ |
| 41 |
\x20 --slug App slug (e.g. goingson, audiofiles)\n\ |
| 42 |
\x20 --version Semver version (e.g. 0.4.1)\n\ |
| 43 |
\x20 --target Target OS: {}\n\ |
| 44 |
\x20 --arch Architecture: {}\n\ |
| 45 |
\x20 --artifact Path to the built artifact file\n\ |
| 46 |
\n\ |
| 47 |
\x20 --api-key SyncKit app API key (env MNW_OTA_API_KEY)\n\ |
| 48 |
\x20 --key SyncKit SDK key (env MNW_OTA_KEY)\n\ |
| 49 |
\n\ |
| 50 |
Auth: defaults to MNW OAuth (opens a browser on this machine; required\n\ |
| 51 |
for accounts with 2FA). Pass --password (+ --email) to use password auth.\n\ |
| 52 |
\n\ |
| 53 |
Optional:\n\ |
| 54 |
\x20 --notes Release notes (default: empty)\n\ |
| 55 |
\x20 --signature Minisign signature for Tauri verification (REQUIRED for a working update)\n\ |
| 56 |
\x20 --release-id Attach to an existing release UUID instead of creating one\n\ |
| 57 |
\x20 (resume a publish whose artifact upload failed)\n\ |
| 58 |
\x20 --email MNW account email (env MNW_OTA_EMAIL; password auth only)\n\ |
| 59 |
\x20 --password MNW account password (env MNW_OTA_PASSWORD; enables password auth)\n\ |
| 60 |
\x20 --server Server URL (env MNW_OTA_SERVER, default {DEFAULT_SERVER})", |
| 61 |
ALLOWED_TARGETS.join(", "), |
| 62 |
ALLOWED_ARCHS.join(", "), |
| 63 |
); |
| 64 |
} |
| 65 |
|
| 66 |
struct PublishArgs { |
| 67 |
slug: String, |
| 68 |
version: String, |
| 69 |
target: String, |
| 70 |
arch: String, |
| 71 |
artifact: PathBuf, |
| 72 |
notes: String, |
| 73 |
signature: String, |
| 74 |
|
| 75 |
|
| 76 |
release_id: Option<String>, |
| 77 |
|
| 78 |
|
| 79 |
|
| 80 |
email: Option<String>, |
| 81 |
password: Option<String>, |
| 82 |
api_key: String, |
| 83 |
key: String, |
| 84 |
server: String, |
| 85 |
} |
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
impl std::fmt::Debug for PublishArgs { |
| 90 |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 91 |
f.debug_struct("PublishArgs") |
| 92 |
.field("slug", &self.slug) |
| 93 |
.field("version", &self.version) |
| 94 |
.field("target", &self.target) |
| 95 |
.field("arch", &self.arch) |
| 96 |
.field("artifact", &self.artifact) |
| 97 |
.field("notes", &self.notes) |
| 98 |
.field("signature", &self.signature) |
| 99 |
.field("release_id", &self.release_id) |
| 100 |
.field("email", &self.email) |
| 101 |
.field("password", &self.password.as_ref().map(|_| "<redacted>")) |
| 102 |
.field("api_key", &"<redacted>") |
| 103 |
.field("key", &"<redacted>") |
| 104 |
.field("server", &self.server) |
| 105 |
.finish() |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
|
| 110 |
fn parse_args(flags: &[String]) -> Result<PublishArgs> { |
| 111 |
let mut slug = None; |
| 112 |
let mut version = None; |
| 113 |
let mut target = None; |
| 114 |
let mut arch = None; |
| 115 |
let mut artifact = None; |
| 116 |
let mut notes = String::new(); |
| 117 |
let mut signature = String::new(); |
| 118 |
let mut release_id = None; |
| 119 |
let mut email = std::env::var("MNW_OTA_EMAIL").ok(); |
| 120 |
let mut password = std::env::var("MNW_OTA_PASSWORD").ok(); |
| 121 |
let mut api_key = std::env::var("MNW_OTA_API_KEY").ok(); |
| 122 |
let mut key = std::env::var("MNW_OTA_KEY").ok(); |
| 123 |
let mut server = std::env::var("MNW_OTA_SERVER").unwrap_or_else(|_| DEFAULT_SERVER.to_string()); |
| 124 |
|
| 125 |
let mut it = flags.iter(); |
| 126 |
while let Some(flag) = it.next() { |
| 127 |
let mut take = |name: &str| -> Result<String> { |
| 128 |
it.next() |
| 129 |
.cloned() |
| 130 |
.with_context(|| format!("{name} requires a value")) |
| 131 |
}; |
| 132 |
match flag.as_str() { |
| 133 |
"--slug" => slug = Some(take("--slug")?), |
| 134 |
"--version" => version = Some(take("--version")?), |
| 135 |
"--target" => target = Some(take("--target")?), |
| 136 |
"--arch" => arch = Some(take("--arch")?), |
| 137 |
"--artifact" => artifact = Some(PathBuf::from(take("--artifact")?)), |
| 138 |
"--notes" => notes = take("--notes")?, |
| 139 |
"--signature" => signature = take("--signature")?, |
| 140 |
"--release-id" => release_id = Some(take("--release-id")?), |
| 141 |
"--email" => email = Some(take("--email")?), |
| 142 |
"--password" => password = Some(take("--password")?), |
| 143 |
"--api-key" => api_key = Some(take("--api-key")?), |
| 144 |
"--key" => key = Some(take("--key")?), |
| 145 |
"--server" => server = take("--server")?, |
| 146 |
"-h" | "--help" => { |
| 147 |
print_usage(); |
| 148 |
std::process::exit(0); |
| 149 |
} |
| 150 |
other => bail!("Unknown flag: {other}"), |
| 151 |
} |
| 152 |
} |
| 153 |
|
| 154 |
let missing = |name: &str| anyhow::anyhow!("missing required {name}"); |
| 155 |
let target = target.ok_or_else(|| missing("--target"))?; |
| 156 |
let arch = arch.ok_or_else(|| missing("--arch"))?; |
| 157 |
|
| 158 |
if !ALLOWED_TARGETS.contains(&target.as_str()) { |
| 159 |
bail!( |
| 160 |
"invalid --target '{target}'. Allowed: {}", |
| 161 |
ALLOWED_TARGETS.join(", ") |
| 162 |
); |
| 163 |
} |
| 164 |
if !ALLOWED_ARCHS.contains(&arch.as_str()) { |
| 165 |
bail!( |
| 166 |
"invalid --arch '{arch}'. Allowed: {}", |
| 167 |
ALLOWED_ARCHS.join(", ") |
| 168 |
); |
| 169 |
} |
| 170 |
|
| 171 |
Ok(PublishArgs { |
| 172 |
slug: slug.ok_or_else(|| missing("--slug"))?, |
| 173 |
version: version.ok_or_else(|| missing("--version"))?, |
| 174 |
target, |
| 175 |
arch, |
| 176 |
artifact: artifact.ok_or_else(|| missing("--artifact"))?, |
| 177 |
notes, |
| 178 |
signature, |
| 179 |
release_id, |
| 180 |
email, |
| 181 |
password, |
| 182 |
api_key: api_key.ok_or_else(|| missing("--api-key / MNW_OTA_API_KEY"))?, |
| 183 |
key: key.ok_or_else(|| missing("--key / MNW_OTA_KEY"))?, |
| 184 |
server, |
| 185 |
}) |
| 186 |
} |
| 187 |
|
| 188 |
async fn publish(flags: &[String]) -> Result<()> { |
| 189 |
let args = parse_args(flags)?; |
| 190 |
|
| 191 |
|
| 192 |
|
| 193 |
let bytes = tokio::fs::read(&args.artifact) |
| 194 |
.await |
| 195 |
.with_context(|| format!("reading artifact {}", args.artifact.display()))?; |
| 196 |
let file_size: i64 = bytes |
| 197 |
.len() |
| 198 |
.try_into() |
| 199 |
.context("artifact is too large to publish")?; |
| 200 |
if file_size == 0 { |
| 201 |
bail!("artifact is empty: {}", args.artifact.display()); |
| 202 |
} |
| 203 |
|
| 204 |
if args.signature.trim().is_empty() { |
| 205 |
eprintln!( |
| 206 |
"warning: --signature is empty. Tauri's updater silently refuses an update with no \ |
| 207 |
signature, so installed apps will NOT apply this release. Publish with the minisign \ |
| 208 |
signature of the artifact for a working update." |
| 209 |
); |
| 210 |
} |
| 211 |
|
| 212 |
println!( |
| 213 |
"Publishing {} v{} ({}/{}, {} bytes) to {}", |
| 214 |
args.slug, args.version, args.target, args.arch, file_size, args.server |
| 215 |
); |
| 216 |
|
| 217 |
let client = SyncKitClient::new(SyncKitConfig { |
| 218 |
server_url: args.server.clone(), |
| 219 |
api_key: args.api_key.clone(), |
| 220 |
}); |
| 221 |
|
| 222 |
|
| 223 |
|
| 224 |
|
| 225 |
match &args.password { |
| 226 |
Some(password) => { |
| 227 |
let email = args |
| 228 |
.email |
| 229 |
.as_deref() |
| 230 |
.context("--email / MNW_OTA_EMAIL is required with password auth")?; |
| 231 |
print!(" authenticating (password)... "); |
| 232 |
client |
| 233 |
.authenticate(email, password, &args.key) |
| 234 |
.await |
| 235 |
.context("authentication failed")?; |
| 236 |
println!("ok"); |
| 237 |
} |
| 238 |
None => { |
| 239 |
authenticate_oauth(&client, &args.key).await?; |
| 240 |
} |
| 241 |
} |
| 242 |
let app_id = client |
| 243 |
.session_info() |
| 244 |
.map(|s| s.app_id.to_string()) |
| 245 |
.unwrap_or_default(); |
| 246 |
println!(" authenticated (app {app_id})"); |
| 247 |
|
| 248 |
|
| 249 |
|
| 250 |
let release_id = match &args.release_id { |
| 251 |
Some(rid) => { |
| 252 |
let id = rid |
| 253 |
.parse::<uuid::Uuid>() |
| 254 |
.context("--release-id must be a UUID")?; |
| 255 |
println!(" using existing release {id} (skipping create)"); |
| 256 |
id |
| 257 |
} |
| 258 |
None => { |
| 259 |
print!(" creating release v{}... ", args.version); |
| 260 |
let release = client |
| 261 |
.ota_create_release(&args.version, &args.notes) |
| 262 |
.await |
| 263 |
.context("create release failed")?; |
| 264 |
println!("ok (release {})", release.id); |
| 265 |
release.id |
| 266 |
} |
| 267 |
}; |
| 268 |
|
| 269 |
print!(" registering artifact... "); |
| 270 |
let upload = client |
| 271 |
.ota_register_artifact( |
| 272 |
release_id, |
| 273 |
&args.target, |
| 274 |
&args.arch, |
| 275 |
file_size, |
| 276 |
&args.signature, |
| 277 |
) |
| 278 |
.await |
| 279 |
.context("register artifact failed")?; |
| 280 |
println!("ok ({})", upload.s3_key); |
| 281 |
|
| 282 |
print!(" uploading {file_size} bytes... "); |
| 283 |
client |
| 284 |
.ota_upload_artifact(&upload.upload_url, bytes) |
| 285 |
.await |
| 286 |
.context("artifact upload failed")?; |
| 287 |
println!("ok"); |
| 288 |
|
| 289 |
print!(" confirming artifact (queues malware scan)... "); |
| 290 |
client |
| 291 |
.ota_confirm_artifact(release_id, &args.target, &args.arch) |
| 292 |
.await |
| 293 |
.context("artifact confirm failed")?; |
| 294 |
println!("ok"); |
| 295 |
|
| 296 |
print!(" verifying updater endpoint... "); |
| 297 |
match client |
| 298 |
.ota_updater_check(&args.slug, &args.target, &args.arch, "0.0.1") |
| 299 |
.await |
| 300 |
.context("updater check failed")? |
| 301 |
{ |
| 302 |
Some(manifest) if manifest.version == args.version => { |
| 303 |
println!("ok (serving v{})", manifest.version); |
| 304 |
} |
| 305 |
Some(manifest) => { |
| 306 |
println!( |
| 307 |
"warning: updater serves v{} but just published v{} (a newer release may exist)", |
| 308 |
manifest.version, args.version |
| 309 |
); |
| 310 |
} |
| 311 |
None => { |
| 312 |
println!( |
| 313 |
"pending scan (204). The artifact was uploaded and queued for malware \ |
| 314 |
scanning; it will be served for {}/{} once the scan clears.", |
| 315 |
args.target, args.arch |
| 316 |
); |
| 317 |
} |
| 318 |
} |
| 319 |
|
| 320 |
println!( |
| 321 |
"\nPublished {} v{} ({}/{})\nUpdater URL: {}/api/v1/sync/ota/{}/{}/{}/{}", |
| 322 |
args.slug, |
| 323 |
args.version, |
| 324 |
args.target, |
| 325 |
args.arch, |
| 326 |
args.server.trim_end_matches('/'), |
| 327 |
args.slug, |
| 328 |
args.target, |
| 329 |
args.arch, |
| 330 |
args.version, |
| 331 |
); |
| 332 |
Ok(()) |
| 333 |
} |
| 334 |
|
| 335 |
|
| 336 |
|
| 337 |
|
| 338 |
|
| 339 |
pub(crate) async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> { |
| 340 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0") |
| 341 |
.await |
| 342 |
.context("failed to bind a localhost listener for the OAuth redirect")?; |
| 343 |
let port = listener.local_addr()?.port(); |
| 344 |
|
| 345 |
let pkce = synckit_client::generate_pkce(); |
| 346 |
let state = synckit_client::generate_oauth_state(); |
| 347 |
let url = client.build_authorize_url(port, &state, &pkce.challenge); |
| 348 |
|
| 349 |
println!("\n authenticating via MNW OAuth."); |
| 350 |
println!(" Open this URL in a browser on THIS machine and approve:"); |
| 351 |
println!(" {url}"); |
| 352 |
open_browser(&url); |
| 353 |
println!(" waiting for the authorization redirect (5 min timeout)..."); |
| 354 |
|
| 355 |
let (code, got_state) = |
| 356 |
tokio::time::timeout(std::time::Duration::from_mins(5), wait_for_code(&listener)) |
| 357 |
.await |
| 358 |
.context("timed out waiting for OAuth authorization")??; |
| 359 |
|
| 360 |
if got_state.as_deref() != Some(state.as_str()) { |
| 361 |
bail!("OAuth state mismatch — aborting (possible CSRF or a stale redirect)"); |
| 362 |
} |
| 363 |
|
| 364 |
client |
| 365 |
.authenticate_with_code(&code, &pkce.verifier, port, key) |
| 366 |
.await |
| 367 |
.context("OAuth code exchange failed")?; |
| 368 |
Ok(()) |
| 369 |
} |
| 370 |
|
| 371 |
|
| 372 |
|
| 373 |
|
| 374 |
async fn wait_for_code(listener: &tokio::net::TcpListener) -> Result<(String, Option<String>)> { |
| 375 |
use tokio::io::AsyncReadExt; |
| 376 |
loop { |
| 377 |
let (mut sock, _) = listener.accept().await.context("accept failed")?; |
| 378 |
let mut buf = [0u8; 4096]; |
| 379 |
let n = sock.read(&mut buf).await.unwrap_or(0); |
| 380 |
let req = String::from_utf8_lossy(&buf[..n]); |
| 381 |
let target = req |
| 382 |
.lines() |
| 383 |
.next() |
| 384 |
.and_then(|line| line.split_whitespace().nth(1)) |
| 385 |
.unwrap_or(""); |
| 386 |
let query = target.split_once('?').map_or("", |(_, q)| q); |
| 387 |
|
| 388 |
let (mut code, mut state, mut oauth_err) = (None, None, None); |
| 389 |
for pair in query.split('&') { |
| 390 |
match pair.split_once('=') { |
| 391 |
|
| 392 |
Some(("code", v)) => code = Some(v.to_string()), |
| 393 |
Some(("state", v)) => state = Some(v.to_string()), |
| 394 |
Some(("error", v)) => oauth_err = Some(v.to_string()), |
| 395 |
_ => {} |
| 396 |
} |
| 397 |
} |
| 398 |
|
| 399 |
if let Some(e) = oauth_err { |
| 400 |
let _ = respond(&mut sock, "Authorization failed. You can close this tab.").await; |
| 401 |
bail!("authorization was denied: {e}"); |
| 402 |
} |
| 403 |
if let Some(c) = code { |
| 404 |
let _ = respond( |
| 405 |
&mut sock, |
| 406 |
"Authorization complete. You can close this tab and return to the terminal.", |
| 407 |
) |
| 408 |
.await; |
| 409 |
return Ok((c, state)); |
| 410 |
} |
| 411 |
|
| 412 |
let _ = respond(&mut sock, "Waiting for authorization...").await; |
| 413 |
} |
| 414 |
} |
| 415 |
|
| 416 |
|
| 417 |
async fn respond(sock: &mut tokio::net::TcpStream, message: &str) -> std::io::Result<()> { |
| 418 |
use tokio::io::AsyncWriteExt; |
| 419 |
let body = format!( |
| 420 |
"<!doctype html><meta charset=utf-8><body style=\"font-family:system-ui;padding:2rem\"><p>{message}</p></body>" |
| 421 |
); |
| 422 |
let resp = format!( |
| 423 |
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", |
| 424 |
body.len(), |
| 425 |
body |
| 426 |
); |
| 427 |
sock.write_all(resp.as_bytes()).await?; |
| 428 |
sock.flush().await |
| 429 |
} |
| 430 |
|
| 431 |
|
| 432 |
fn open_browser(url: &str) { |
| 433 |
let opener = if cfg!(target_os = "macos") { |
| 434 |
"open" |
| 435 |
} else { |
| 436 |
"xdg-open" |
| 437 |
}; |
| 438 |
let _ = std::process::Command::new(opener) |
| 439 |
.arg(url) |
| 440 |
.stdout(std::process::Stdio::null()) |
| 441 |
.stderr(std::process::Stdio::null()) |
| 442 |
.spawn(); |
| 443 |
} |
| 444 |
|
| 445 |
#[cfg(test)] |
| 446 |
mod tests { |
| 447 |
use super::*; |
| 448 |
|
| 449 |
fn base_flags() -> Vec<String> { |
| 450 |
[ |
| 451 |
"--slug", |
| 452 |
"goingson", |
| 453 |
"--version", |
| 454 |
"0.4.1", |
| 455 |
"--target", |
| 456 |
"darwin", |
| 457 |
"--arch", |
| 458 |
"aarch64", |
| 459 |
"--artifact", |
| 460 |
"/tmp/x", |
| 461 |
"--email", |
| 462 |
"me@example.com", |
| 463 |
"--password", |
| 464 |
"pw", |
| 465 |
"--api-key", |
| 466 |
"ak", |
| 467 |
"--key", |
| 468 |
"sdk", |
| 469 |
"--server", |
| 470 |
"https://example.test", |
| 471 |
] |
| 472 |
.iter() |
| 473 |
.map(std::string::ToString::to_string) |
| 474 |
.collect() |
| 475 |
} |
| 476 |
|
| 477 |
#[test] |
| 478 |
fn parses_full_flag_set() { |
| 479 |
let a = parse_args(&base_flags()).unwrap(); |
| 480 |
assert_eq!(a.slug, "goingson"); |
| 481 |
assert_eq!(a.version, "0.4.1"); |
| 482 |
assert_eq!(a.target, "darwin"); |
| 483 |
assert_eq!(a.arch, "aarch64"); |
| 484 |
assert_eq!(a.server, "https://example.test"); |
| 485 |
assert!(a.notes.is_empty()); |
| 486 |
} |
| 487 |
|
| 488 |
#[test] |
| 489 |
fn rejects_invalid_target() { |
| 490 |
let mut flags = base_flags(); |
| 491 |
let i = flags.iter().position(|f| f == "darwin").unwrap(); |
| 492 |
flags[i] = "macos".to_string(); |
| 493 |
let err = parse_args(&flags).unwrap_err().to_string(); |
| 494 |
assert!(err.contains("invalid --target"), "{err}"); |
| 495 |
} |
| 496 |
|
| 497 |
#[test] |
| 498 |
fn rejects_invalid_arch() { |
| 499 |
let mut flags = base_flags(); |
| 500 |
let i = flags.iter().position(|f| f == "aarch64").unwrap(); |
| 501 |
flags[i] = "arm64".to_string(); |
| 502 |
let err = parse_args(&flags).unwrap_err().to_string(); |
| 503 |
assert!(err.contains("invalid --arch"), "{err}"); |
| 504 |
} |
| 505 |
|
| 506 |
#[test] |
| 507 |
fn missing_required_flag_is_reported() { |
| 508 |
|
| 509 |
let flags: Vec<String> = base_flags().into_iter().skip(2).collect(); |
| 510 |
let err = parse_args(&flags).unwrap_err().to_string(); |
| 511 |
assert!(err.contains("--slug"), "{err}"); |
| 512 |
} |
| 513 |
|
| 514 |
#[test] |
| 515 |
fn flag_without_value_errors() { |
| 516 |
let err = parse_args(&["--slug".to_string()]).unwrap_err().to_string(); |
| 517 |
assert!(err.contains("--slug requires a value"), "{err}"); |
| 518 |
} |
| 519 |
} |
| 520 |
|