Skip to main content

oxide_cli/addons/
install.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use serde::Deserialize;
5
6use crate::{AppContext, auth::token::get_auth_user, utils::archive::download_and_extract};
7
8use super::{
9  cache::{CachedAddon, get_cached_addon, update_addons_cache},
10  manifest::AddonManifest,
11};
12
13#[derive(Deserialize)]
14struct AddonUrlResponse {
15  archive_url: String,
16  commit_sha: String,
17}
18
19#[derive(Debug)]
20pub enum AddonInstallResult {
21  Installed(AddonManifest),
22  Updated(AddonManifest),
23  UpToDate(AddonManifest),
24}
25
26impl AddonInstallResult {
27  pub fn into_manifest(self) -> AddonManifest {
28    match self {
29      Self::Installed(manifest) | Self::Updated(manifest) | Self::UpToDate(manifest) => manifest,
30    }
31  }
32
33  pub fn message(&self, addon_id: &str) -> Option<String> {
34    match self {
35      Self::Installed(_) => Some(format!("Addon '{addon_id}' successfully downloaded")),
36      Self::Updated(manifest) => Some(format!(
37        "Addon '{addon_id}' updated to v{}",
38        manifest.version
39      )),
40      Self::UpToDate(_) => None,
41    }
42  }
43
44  pub fn update_message(&self, addon_id: &str) -> Option<String> {
45    match self {
46      Self::Updated(manifest) => Some(format!(
47        "Addon '{addon_id}' updated to v{}",
48        manifest.version
49      )),
50      Self::Installed(_) | Self::UpToDate(_) => None,
51    }
52  }
53
54  pub fn up_to_date_message(addon_id: &str) -> String {
55    format!("Addon '{addon_id}' is already up to date")
56  }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum InstallState {
61  Install,
62  Update,
63  UpToDate,
64}
65
66fn classify_install_state(
67  cached_addon: Option<&CachedAddon>,
68  addon_dir_exists: bool,
69  latest_commit_sha: &str,
70) -> InstallState {
71  let Some(cached_addon) = cached_addon else {
72    return InstallState::Install;
73  };
74
75  if !addon_dir_exists {
76    return InstallState::Install;
77  }
78
79  if cached_addon.commit_sha == latest_commit_sha {
80    InstallState::UpToDate
81  } else {
82    InstallState::Update
83  }
84}
85
86#[doc(hidden)]
87pub fn classify_install_state_for_tests(
88  cached_addon: Option<&CachedAddon>,
89  addon_dir_exists: bool,
90  latest_commit_sha: &str,
91) -> &'static str {
92  match classify_install_state(cached_addon, addon_dir_exists, latest_commit_sha) {
93    InstallState::Install => "install",
94    InstallState::Update => "update",
95    InstallState::UpToDate => "up_to_date",
96  }
97}
98
99async fn get_addon_url(ctx: &AppContext, addon_id: &str) -> Result<AddonUrlResponse> {
100  let user = get_auth_user(&ctx.paths.auth)?;
101
102  let res: AddonUrlResponse = ctx
103    .client
104    .get(format!("{}/addon/{addon_id}/url", ctx.backend_url))
105    .bearer_auth(user.token)
106    .header("Content-Type", "application/json")
107    .send()
108    .await
109    .with_context(|| format!("Failed to fetch download URL for addon '{addon_id}'"))?
110    .error_for_status()
111    .with_context(|| format!("Server returned error for addon '{addon_id}'"))?
112    .json()
113    .await
114    .with_context(|| format!("Failed to parse URL response for addon '{addon_id}'"))?;
115
116  Ok(res)
117}
118
119pub async fn install_addon(ctx: &AppContext, addon_id: &str) -> Result<AddonInstallResult> {
120  let info = get_addon_url(ctx, addon_id).await?;
121
122  // The archive already contains a top-level `{addon_id}/` directory,
123  // so we extract into the addons root — files land at addons/{addon_id}/...
124  let addons_dir = &ctx.paths.addons;
125  let addon_dir = addons_dir.join(addon_id);
126  let cached_addon = get_cached_addon(addons_dir, addon_id)
127    .with_context(|| format!("Failed to read addons cache while checking '{addon_id}'"))?;
128  let install_state =
129    classify_install_state(cached_addon.as_ref(), addon_dir.exists(), &info.commit_sha);
130
131  if install_state == InstallState::UpToDate {
132    return Ok(AddonInstallResult::UpToDate(read_cached_manifest(
133      addons_dir, addon_id,
134    )?));
135  }
136
137  {
138    let mut guard = ctx.cleanup_state.lock().unwrap_or_else(|e| e.into_inner());
139    *guard = Some(addon_dir.clone());
140  }
141
142  let download_result = download_and_extract(&ctx.client, &info.archive_url, addons_dir, None)
143    .await
144    .with_context(|| {
145      format!(
146        "Failed to download and extract addon '{addon_id}' from {}",
147        info.archive_url
148      )
149    });
150
151  {
152    let mut guard = ctx.cleanup_state.lock().unwrap_or_else(|e| e.into_inner());
153    *guard = None;
154  }
155
156  download_result?;
157
158  let manifest_path = addon_dir.join("oxide.addon.json");
159  let content = std::fs::read_to_string(&manifest_path).with_context(|| {
160    format!(
161      "Addon '{addon_id}' was extracted but 'oxide.addon.json' was not found at {}. \
162       Make sure the addon archive contains a top-level '{addon_id}/' directory with 'oxide.addon.json' inside.",
163      manifest_path.display()
164    )
165  })?;
166
167  let manifest: AddonManifest = serde_json::from_str(&content)
168    .with_context(|| format!("Failed to parse oxide.addon.json for addon '{addon_id}'"))?;
169
170  update_addons_cache(addons_dir, addon_id, &manifest, &info.commit_sha)
171    .with_context(|| format!("Failed to update addons cache after installing '{addon_id}'"))?;
172
173  Ok(match install_state {
174    InstallState::Install => AddonInstallResult::Installed(manifest),
175    InstallState::Update => AddonInstallResult::Updated(manifest),
176    InstallState::UpToDate => unreachable!("up-to-date addons should return early"),
177  })
178}
179
180pub fn read_cached_manifest(addons_dir: &Path, addon_id: &str) -> Result<AddonManifest> {
181  let manifest_path = addons_dir.join(addon_id).join("oxide.addon.json");
182  let content = std::fs::read_to_string(&manifest_path).with_context(|| {
183    format!(
184      "Failed to read manifest for addon '{addon_id}' at {}",
185      manifest_path.display()
186    )
187  })?;
188  serde_json::from_str(&content)
189    .with_context(|| format!("Failed to parse manifest for addon '{addon_id}'"))
190}