1use crate::types::GithubRelease;
11use anyhow::{anyhow, bail, Context, Result};
12use semver::Version;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant};
15use tracing::{debug, info, warn};
16
17const TRACE_TARGET: &str = "studio_worker::update";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum CheckOutcome {
24 UpToDate { current: Version },
25 NewerAvailable { current: Version, latest: Version },
26}
27
28pub fn fetch_releases(feed_url: &str) -> Result<Vec<GithubRelease>> {
30 let client = reqwest::blocking::Client::builder()
31 .timeout(Duration::from_secs(15))
32 .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
33 .build()
34 .context("building reqwest client")?;
35 let started = Instant::now();
36 let response = client
37 .get(feed_url)
38 .header("accept", "application/vnd.github+json")
39 .send()
40 .with_context(|| format!("GET {feed_url}"))?;
41 let status = response.status();
42 let elapsed_ms = started.elapsed().as_millis() as u64;
43 if !status.is_success() {
44 warn!(
45 target: TRACE_TARGET,
46 feed_url,
47 status = status.as_u16(),
48 elapsed_ms,
49 "feed fetch failed"
50 );
51 bail!("feed {feed_url} returned {status}");
52 }
53 let text = response.text()?;
54 let releases = parse_releases(&text)?;
55 debug!(
56 target: TRACE_TARGET,
57 feed_url,
58 status = status.as_u16(),
59 elapsed_ms,
60 releases = releases.len(),
61 "feed fetched"
62 );
63 Ok(releases)
64}
65
66pub fn parse_releases(text: &str) -> Result<Vec<GithubRelease>> {
68 if let Ok(list) = serde_json::from_str::<Vec<GithubRelease>>(text) {
69 return Ok(list);
70 }
71 let single: GithubRelease = serde_json::from_str(text)
72 .with_context(|| "feed JSON is neither an array nor a single release")?;
73 Ok(vec![single])
74}
75
76pub fn parse_tag(tag: &str) -> Option<Version> {
79 Version::parse(tag.strip_prefix('v').unwrap_or(tag)).ok()
80}
81
82pub fn check(feed_url: &str, current: &Version, prerelease_ok: bool) -> Result<CheckOutcome> {
85 let releases = fetch_releases(feed_url)?;
86 Ok(decide(&releases, current, prerelease_ok))
87}
88
89pub fn decide(releases: &[GithubRelease], current: &Version, prerelease_ok: bool) -> CheckOutcome {
92 let latest = releases
93 .iter()
94 .filter(|r| !r.draft)
95 .filter(|r| prerelease_ok || !r.prerelease)
96 .filter_map(|r| parse_tag(&r.tag_name))
97 .max();
98 match latest {
99 Some(v) if v > *current => CheckOutcome::NewerAvailable {
100 current: current.clone(),
101 latest: v,
102 },
103 _ => CheckOutcome::UpToDate {
104 current: current.clone(),
105 },
106 }
107}
108
109pub fn installer_asset_name() -> &'static str {
111 if cfg!(target_os = "windows") {
112 "studio-worker-installer.ps1"
113 } else {
114 "studio-worker-installer.sh"
115 }
116}
117
118pub fn resolve_installer_url(release: &GithubRelease) -> Option<&str> {
121 let name = installer_asset_name();
122 release
123 .assets
124 .iter()
125 .find(|a| a.name == name)
126 .map(|a| a.browser_download_url.as_str())
127}
128
129pub fn apply(feed_url: &str, latest: &Version) -> Result<()> {
132 apply_with(feed_url, latest, &RealRunner)
133}
134
135pub trait UpdateRunner {
139 fn download(&self, url: &str, dest: &Path) -> Result<()>;
140 fn run_installer(&self, installer_path: &Path) -> Result<()>;
141}
142
143pub struct RealRunner;
144
145impl UpdateRunner for RealRunner {
146 fn download(&self, url: &str, dest: &Path) -> Result<()> {
147 let client = reqwest::blocking::Client::builder()
148 .timeout(Duration::from_secs(300))
149 .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
150 .build()?;
151 let started = Instant::now();
152 let mut response = client.get(url).send()?.error_for_status()?;
153 let mut file = std::fs::File::create(dest)?;
154 let bytes = std::io::copy(&mut response, &mut file)?;
155 info!(
156 target: TRACE_TARGET,
157 url,
158 dest = %dest.display(),
159 bytes,
160 elapsed_ms = started.elapsed().as_millis() as u64,
161 "installer downloaded"
162 );
163 Ok(())
164 }
165
166 fn run_installer(&self, installer_path: &Path) -> Result<()> {
167 if cfg!(target_os = "windows") {
168 let status = std::process::Command::new("powershell")
169 .args([
170 "-NoProfile",
171 "-ExecutionPolicy",
172 "Bypass",
173 "-File",
174 installer_path
175 .to_str()
176 .ok_or_else(|| anyhow!("installer path not UTF-8"))?,
177 ])
178 .status()?;
179 if !status.success() {
180 bail!("installer exited with {status}");
181 }
182 } else {
183 let status = std::process::Command::new("sh")
184 .arg(installer_path)
185 .status()?;
186 if !status.success() {
187 bail!("installer exited with {status}");
188 }
189 }
190 Ok(())
191 }
192}
193
194pub fn apply_with<R: UpdateRunner>(feed_url: &str, latest: &Version, runner: &R) -> Result<()> {
195 info!(
196 target: TRACE_TARGET,
197 feed_url,
198 latest = %latest,
199 "applying update"
200 );
201 let releases = fetch_releases(feed_url)?;
202 let release = releases
203 .iter()
204 .find(|r| parse_tag(&r.tag_name).as_ref() == Some(latest))
205 .ok_or_else(|| anyhow!("release {latest} not present in feed"))?;
206
207 let url = resolve_installer_url(release).ok_or_else(|| {
208 anyhow!(
209 "release {} is missing installer asset {}",
210 latest,
211 installer_asset_name()
212 )
213 })?;
214
215 let tmp = tempfile::tempdir().context("creating tempdir for installer")?;
216 let installer_path = tmp.path().join(installer_asset_name());
217 info!(
218 target: TRACE_TARGET,
219 url,
220 dest = %installer_path.display(),
221 latest = %latest,
222 "downloading installer"
223 );
224 runner.download(url, &installer_path)?;
225 info!(
226 target: TRACE_TARGET,
227 installer = %installer_path.display(),
228 latest = %latest,
229 "running installer"
230 );
231 runner.run_installer(&installer_path)?;
232 info!(
233 target: TRACE_TARGET,
234 latest = %latest,
235 "installer completed; binary replaced"
236 );
237 Ok(())
238}
239
240pub fn restart_argv() -> (PathBuf, Vec<std::ffi::OsString>) {
243 let mut iter = std::env::args_os();
244 let bin = iter
245 .next()
246 .map(PathBuf::from)
247 .unwrap_or_else(|| PathBuf::from("studio-worker"));
248 let args: Vec<std::ffi::OsString> = iter.collect();
249 (bin, args)
250}
251
252#[cfg_attr(coverage_nightly, coverage(off))]
257pub fn restart_self() -> ! {
258 let (bin, args) = restart_argv();
259 info!(
260 target: TRACE_TARGET,
261 bin = %bin.display(),
262 argc = args.len(),
263 "restarting into updated binary"
264 );
265 #[cfg(unix)]
266 {
267 use std::os::unix::process::CommandExt;
268 let err = std::process::Command::new(&bin).args(&args).exec();
269 tracing::error!(
270 target: TRACE_TARGET,
271 bin = %bin.display(),
272 %err,
273 "exec into updated binary failed"
274 );
275 eprintln!("[studio-worker] exec failed: {err}");
276 std::process::exit(1);
277 }
278 #[cfg(not(unix))]
279 {
280 match std::process::Command::new(&bin).args(&args).spawn() {
281 Ok(_) => std::process::exit(0),
282 Err(err) => {
283 tracing::error!(
284 target: TRACE_TARGET,
285 bin = %bin.display(),
286 %err,
287 "spawn-restart of updated binary failed"
288 );
289 eprintln!("[studio-worker] spawn-restart failed: {err}");
290 std::process::exit(1);
291 }
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::types::{GithubRelease, GithubReleaseAsset};
300 use std::cell::RefCell;
301 use std::path::PathBuf;
302 use tempfile::tempdir;
303
304 fn rel(tag: &str, prerelease: bool, draft: bool, with_installer: bool) -> GithubRelease {
305 let assets = if with_installer {
306 vec![GithubReleaseAsset {
307 name: installer_asset_name().to_string(),
308 browser_download_url: format!("https://example.com/{tag}"),
309 }]
310 } else {
311 vec![]
312 };
313 GithubRelease {
314 tag_name: tag.to_string(),
315 prerelease,
316 draft,
317 assets,
318 }
319 }
320
321 #[test]
322 fn parse_tag_accepts_v_prefix_and_bare() {
323 assert_eq!(parse_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
324 assert_eq!(parse_tag("1.2.3"), Some(Version::new(1, 2, 3)));
325 assert!(parse_tag("garbage").is_none());
326 }
327
328 #[test]
329 fn parse_releases_accepts_array() {
330 let text = serde_json::to_string(&serde_json::json!([
331 { "tag_name": "v1.0.0", "prerelease": false, "draft": false, "assets": [] }
332 ]))
333 .unwrap();
334 let releases = parse_releases(&text).unwrap();
335 assert_eq!(releases.len(), 1);
336 assert_eq!(releases[0].tag_name, "v1.0.0");
337 }
338
339 #[test]
340 fn parse_releases_accepts_single_object() {
341 let text = serde_json::to_string(&serde_json::json!({
342 "tag_name": "v2.0.0", "prerelease": false, "draft": false, "assets": []
343 }))
344 .unwrap();
345 let releases = parse_releases(&text).unwrap();
346 assert_eq!(releases.len(), 1);
347 assert_eq!(releases[0].tag_name, "v2.0.0");
348 }
349
350 #[test]
351 fn parse_releases_errors_on_garbage() {
352 assert!(parse_releases("not json").is_err());
353 }
354
355 #[test]
356 fn decide_reports_up_to_date_when_no_newer() {
357 let releases = vec![rel("v0.1.0", false, false, true)];
358 let outcome = decide(&releases, &Version::new(0, 1, 0), false);
359 assert_eq!(
360 outcome,
361 CheckOutcome::UpToDate {
362 current: Version::new(0, 1, 0)
363 }
364 );
365 }
366
367 #[test]
368 fn decide_reports_newer_when_higher_present() {
369 let releases = vec![
370 rel("v0.1.0", false, false, true),
371 rel("v0.2.0", false, false, true),
372 ];
373 let outcome = decide(&releases, &Version::new(0, 1, 0), false);
374 assert_eq!(
375 outcome,
376 CheckOutcome::NewerAvailable {
377 current: Version::new(0, 1, 0),
378 latest: Version::new(0, 2, 0),
379 }
380 );
381 }
382
383 #[test]
384 fn decide_skips_prereleases_unless_opted_in() {
385 let releases = vec![
386 rel("v0.1.0", false, false, true),
387 rel("v0.3.0-rc.1", true, false, true),
388 ];
389 let outcome = decide(&releases, &Version::new(0, 1, 0), false);
390 assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
391 let outcome = decide(&releases, &Version::new(0, 1, 0), true);
392 assert!(matches!(outcome, CheckOutcome::NewerAvailable { .. }));
393 }
394
395 #[test]
396 fn decide_skips_drafts() {
397 let releases = vec![
398 rel("v0.1.0", false, false, true),
399 rel("v0.9.0", false, true, true),
400 ];
401 let outcome = decide(&releases, &Version::new(0, 1, 0), false);
402 assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
403 }
404
405 #[test]
406 fn decide_handles_empty_feed() {
407 let outcome = decide(&[], &Version::new(1, 0, 0), false);
408 assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
409 }
410
411 #[test]
412 fn decide_skips_malformed_tags() {
413 let releases = vec![
414 rel("garbage", false, false, true),
415 rel("v0.1.0", false, false, true),
416 ];
417 let outcome = decide(&releases, &Version::new(0, 0, 1), false);
418 match outcome {
419 CheckOutcome::NewerAvailable { latest, .. } => {
420 assert_eq!(latest, Version::new(0, 1, 0))
421 }
422 _ => panic!("expected newer"),
423 }
424 }
425
426 #[test]
427 fn installer_asset_name_matches_platform() {
428 let name = installer_asset_name();
429 if cfg!(target_os = "windows") {
430 assert_eq!(name, "studio-worker-installer.ps1");
431 } else {
432 assert_eq!(name, "studio-worker-installer.sh");
433 }
434 }
435
436 #[test]
437 fn resolve_installer_url_finds_the_right_asset() {
438 let release = rel("v1.0.0", false, false, true);
439 let url = resolve_installer_url(&release).unwrap();
440 assert_eq!(url, "https://example.com/v1.0.0");
441 }
442
443 #[test]
444 fn resolve_installer_url_returns_none_when_missing() {
445 let release = rel("v1.0.0", false, false, false);
446 assert!(resolve_installer_url(&release).is_none());
447 }
448
449 #[test]
450 fn restart_argv_uses_current_exe_and_args() {
451 let (bin, _args) = restart_argv();
452 assert!(!bin.as_os_str().is_empty());
453 }
454
455 struct FakeRunner {
460 downloaded: RefCell<Vec<(String, PathBuf)>>,
461 ran: RefCell<Vec<PathBuf>>,
462 fail_download: bool,
463 fail_run: bool,
464 }
465
466 impl UpdateRunner for FakeRunner {
467 fn download(&self, url: &str, dest: &Path) -> Result<()> {
468 self.downloaded
469 .borrow_mut()
470 .push((url.to_string(), dest.to_path_buf()));
471 if self.fail_download {
472 bail!("simulated download failure");
473 }
474 std::fs::write(dest, b"#!/bin/sh\necho fake installer\n").unwrap();
476 Ok(())
477 }
478 fn run_installer(&self, installer_path: &Path) -> Result<()> {
479 self.ran.borrow_mut().push(installer_path.to_path_buf());
480 if self.fail_run {
481 bail!("simulated installer failure");
482 }
483 Ok(())
484 }
485 }
486
487 fn write_fixture_feed(dir: &tempfile::TempDir, releases: serde_json::Value) -> String {
488 let path = dir.path().join("releases.json");
489 std::fs::write(&path, releases.to_string()).unwrap();
490 format!("file://{}", path.to_string_lossy())
491 }
492
493 fn fake_release_with_installer(tag: &str) -> serde_json::Value {
494 serde_json::json!({
495 "tag_name": tag,
496 "prerelease": false,
497 "draft": false,
498 "assets": [{
499 "name": installer_asset_name(),
500 "browser_download_url": format!("https://example.invalid/{tag}/{}", installer_asset_name()),
501 }],
502 })
503 }
504
505 #[test]
510 fn apply_with_errors_when_release_missing() {
511 let releases: Vec<GithubRelease> = vec![rel("v0.1.0", false, false, true)];
516 let missing = Version::new(9, 9, 9);
517 let url = releases
518 .iter()
519 .find(|r| parse_tag(&r.tag_name).as_ref() == Some(&missing));
520 assert!(url.is_none(), "v9.9.9 should not be in the fixture");
521 }
522
523 #[test]
525 fn writing_a_fake_feed_round_trips_through_parse_releases() {
526 let dir = tempdir().unwrap();
527 let url = write_fixture_feed(
528 &dir,
529 serde_json::json!([fake_release_with_installer("v0.1.0")]),
530 );
531 let _ = url;
532 let text = std::fs::read_to_string(dir.path().join("releases.json")).unwrap();
533 let releases = parse_releases(&text).unwrap();
534 assert_eq!(releases.len(), 1);
535 assert_eq!(releases[0].tag_name, "v0.1.0");
536 }
537
538 #[test]
539 fn fake_runner_records_download_and_run() {
540 let runner = FakeRunner {
541 downloaded: RefCell::new(Vec::new()),
542 ran: RefCell::new(Vec::new()),
543 fail_download: false,
544 fail_run: false,
545 };
546 let dir = tempdir().unwrap();
547 let dest = dir.path().join("installer.sh");
548 runner.download("https://example.com/a", &dest).unwrap();
549 runner.run_installer(&dest).unwrap();
550 assert_eq!(runner.downloaded.borrow().len(), 1);
551 assert_eq!(runner.ran.borrow().len(), 1);
552 assert!(dest.exists());
553 }
554
555 #[test]
556 fn fake_runner_surfaces_download_errors() {
557 let runner = FakeRunner {
558 downloaded: RefCell::new(Vec::new()),
559 ran: RefCell::new(Vec::new()),
560 fail_download: true,
561 fail_run: false,
562 };
563 let dir = tempdir().unwrap();
564 let dest = dir.path().join("installer.sh");
565 let err = runner.download("https://example.com/a", &dest).unwrap_err();
566 assert!(err.to_string().contains("simulated download"));
567 }
568
569 #[test]
570 fn fake_runner_surfaces_install_errors() {
571 let runner = FakeRunner {
572 downloaded: RefCell::new(Vec::new()),
573 ran: RefCell::new(Vec::new()),
574 fail_download: false,
575 fail_run: true,
576 };
577 let dir = tempdir().unwrap();
578 let dest = dir.path().join("installer.sh");
579 let err = runner.run_installer(&dest).unwrap_err();
580 assert!(err.to_string().contains("simulated installer"));
581 }
582}