use anyhow::{Context, Result};
use console::style;
use indicatif::{ProgressBar, ProgressStyle};
use std::path::{Path, PathBuf};
const GITHUB_REPO: &str = "a7garden/oxios";
fn user_web_dist_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".oxios").join("web").join("dist"))
}
fn user_web_version_file() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".oxios").join("web").join("version"))
}
#[derive(Debug)]
pub enum WebDistResult {
UserDir(PathBuf),
WorkspaceDir(PathBuf),
Downloaded { path: PathBuf, version: String },
#[allow(dead_code)]
Embedded,
DownloadFailed { reason: String },
}
impl WebDistResult {
pub fn version_display(&self) -> Option<&str> {
match self {
WebDistResult::Downloaded { version, .. } => Some(version.trim_start_matches('v')),
_ => None,
}
}
}
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
async fn fetch_latest_release_tag() -> Result<String> {
let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
let client = reqwest::Client::builder()
.user_agent("oxios-web")
.build()
.context("failed to create HTTP client")?;
let resp: serde_json::Value = client
.get(&url)
.send()
.await
.context("failed to fetch GitHub release info")?
.json()
.await
.context("failed to parse GitHub response")?;
let tag = resp["tag_name"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("tag_name not found in GitHub response"))?;
Ok(tag.to_string())
}
async fn download_and_extract_web_dist(version_tag: &str) -> Result<PathBuf> {
let dist_dir =
user_web_dist_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
let version_file = user_web_version_file()
.ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
let url =
format!("https://github.com/{GITHUB_REPO}/releases/download/{version_tag}/web-dist.zip");
let client = reqwest::Client::builder()
.user_agent("oxios-web")
.build()
.context("failed to create HTTP client")?;
let resp = client
.get(&url)
.send()
.await
.context("download request failed")?;
if !resp.status().is_success() {
anyhow::bail!("Failed to download web-dist.zip: HTTP {}", resp.status());
}
let total_size = resp.content_length().unwrap_or(0);
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template(" {spinner} {msg} [{bar:>.dim}] {bytes}/{total_bytes} ({bytes_per_sec})")
.unwrap()
.progress_chars("█▉▊▋▌▍▎▏ "),
);
let tag_label = style(version_tag).cyan().to_string();
pb.set_message(format!("Downloading web UI {tag_label}"));
let bytes = resp.bytes().await.context("failed to read response body")?;
let ok = style("✓").green().to_string();
let downloaded = style("Downloaded").green().to_string();
let done_msg = format!(
" {} {} ({})",
ok,
downloaded,
format_size(bytes.len() as u64)
);
pb.finish_with_message(done_msg);
let reader = std::io::Cursor::new(bytes.as_ref());
let mut archive = zip::ZipArchive::new(reader).context("invalid zip file")?;
let file_count = archive.len();
let extract_pb = ProgressBar::new(file_count as u64);
extract_pb.set_style(
ProgressStyle::default_bar()
.template(" {spinner} {msg} [{bar:>.dim}] {pos}/{len}")
.unwrap()
.progress_chars("█▉▊▋▌▍▎▏ "),
);
extract_pb.set_message("Extracting files".to_string());
if dist_dir.exists() {
std::fs::remove_dir_all(&dist_dir)?;
}
std::fs::create_dir_all(&dist_dir)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let outpath = match file.enclosed_name() {
Some(path) => dist_dir.join(path),
None => continue,
};
if file.is_dir() {
std::fs::create_dir_all(&outpath)?;
} else {
if let Some(p) = outpath.parent() {
if !p.exists() {
std::fs::create_dir_all(p)?;
}
}
let mut outfile = std::fs::File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
}
extract_pb.inc(1);
}
let ok = style("✓").green().to_string();
let done_msg = format!(" {ok} {file_count} files extracted");
extract_pb.finish_with_message(done_msg);
if let Some(parent) = version_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&version_file, version_tag)?;
tracing::info!(
path = ?dist_dir,
version = %version_tag,
"Web UI downloaded and extracted"
);
Ok(dist_dir)
}
pub async fn ensure_web_dist(workspace: &Path) -> WebDistResult {
if let Some(ref dist) = user_web_dist_dir() {
if dist.join("index.html").is_file() {
tracing::info!(path = ?dist, "Serving web UI from ~/.oxios/web/dist/");
return WebDistResult::UserDir(dist.clone());
}
}
let workspace_dist = workspace.join("web").join("dist");
if workspace_dist.join("index.html").is_file() {
tracing::info!(path = ?workspace_dist, "Serving web UI from workspace (web/dist/)");
return WebDistResult::WorkspaceDir(workspace_dist);
}
tracing::info!("No web UI found locally, downloading from GitHub Releases...");
match fetch_latest_release_tag().await {
Ok(tag) => match download_and_extract_web_dist(&tag).await {
Ok(p) => WebDistResult::Downloaded {
path: p,
version: tag,
},
Err(e) => {
tracing::warn!(error = %e, "Failed to auto-download web UI");
WebDistResult::DownloadFailed {
reason: e.to_string(),
}
}
},
Err(e) => {
tracing::warn!(error = %e, "Could not fetch latest release info");
WebDistResult::DownloadFailed {
reason: e.to_string(),
}
}
}
}