oxide_cli/addons/
install.rs1use 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 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}