use anyhow::Result;
use async_trait::async_trait;
use axum::{body::Body, response::Response, routing::get, Router};
use rust_embed::Embed;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use oxios_gateway::plugin::{ChannelBundle, ChannelContext, ChannelPlugin};
use oxios_markdown::KnowledgeBase;
use crate::api_docs;
use crate::channel::{WebChannel, WebChannelHandle};
use crate::middleware::RateLimiter;
use crate::routes;
use crate::server::AppState;
const GITHUB_REPO: &str = "a7garden/oxios";
#[derive(Embed)]
#[folder = "web/dist/"]
struct EmbeddedAssets;
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"))
}
async fn fetch_latest_release_tag() -> Result<String> {
let url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
let client = reqwest::Client::builder()
.user_agent("oxios-web")
.build()?;
let resp: serde_json::Value = client.get(&url).send().await?.json().await?;
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/{}/releases/download/{}/web-dist.zip",
GITHUB_REPO, version_tag
);
tracing::info!(url = %url, "Downloading web UI from GitHub Releases");
let client = reqwest::Client::builder()
.user_agent("oxios-web")
.build()?;
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
anyhow::bail!(
"Failed to download web-dist.zip: HTTP {}",
resp.status()
);
}
let bytes = resp.bytes().await?;
let reader = std::io::Cursor::new(bytes.as_ref());
let mut archive = zip::ZipArchive::new(reader)?;
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)?;
}
}
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)
}
async fn ensure_web_dist(workspace: &Path) -> Option<PathBuf> {
if let Some(ref dist) = user_web_dist_dir() {
if dist.join("index.html").is_file() {
let version_file = user_web_version_file().unwrap();
let current_version = std::fs::read_to_string(&version_file).ok();
match fetch_latest_release_tag().await {
Ok(latest_tag) => {
let current = current_version.as_deref().unwrap_or("");
if current != latest_tag {
tracing::info!(
current = %current,
latest = %latest_tag,
"New web UI version available, downloading..."
);
match download_and_extract_web_dist(&latest_tag).await {
Ok(p) => {
tracing::info!(path = ?p, "Web UI updated");
return Some(p);
}
Err(e) => {
tracing::warn!(error = %e, "Failed to download update, using existing");
}
}
}
}
Err(e) => {
tracing::debug!(error = %e, "Could not check for web UI updates");
}
}
tracing::info!(path = ?dist, "Serving web UI from ~/.oxios/web/dist/");
return Some(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 Some(workspace_dist);
}
if EmbeddedAssets::get("index.html").is_some() {
tracing::info!("Serving web UI from embedded assets (binary built with --features web)");
return None;
}
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) => return Some(p),
Err(e) => tracing::warn!(error = %e, "Failed to auto-download web UI"),
},
Err(e) => tracing::warn!(error = %e, "Could not fetch latest release info"),
}
None
}
fn fs_read(dist: &std::path::Path, path: &str) -> Option<Vec<u8>> {
let clean = path.trim_start_matches('/');
let file_path = dist.join(clean);
std::fs::read(&file_path).ok()
}
fn mime_type(path: &str) -> axum::http::HeaderValue {
let clean = path.trim_start_matches('/');
mime_guess::from_path(clean)
.first_or_octet_stream()
.to_string()
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("application/octet-stream"))
}
fn serve_file(dist: Option<&std::path::Path>, path: &str) -> Response {
let clean = path.trim_start_matches('/');
if let Some(d) = dist {
if let Some(data) = fs_read(d, clean) {
let lookup = if clean.starts_with("assets/") {
clean.to_string()
} else {
format!("assets/{}", clean)
};
return Response::builder()
.status(200)
.header("Content-Type", mime_type(&lookup))
.header("Cache-Control", "no-cache") .body(Body::from(data))
.unwrap();
}
if let Some(data) = fs_read(d, &format!("assets/{}", clean)) {
return Response::builder()
.status(200)
.header("Content-Type", mime_type(clean))
.header("Cache-Control", "no-cache")
.body(Body::from(data))
.unwrap();
}
}
let asset =
EmbeddedAssets::get(clean).or_else(|| EmbeddedAssets::get(&format!("assets/{}", clean)));
match asset {
Some(content) => {
let lookup = if clean.starts_with("assets/") {
clean.to_string()
} else {
format!("assets/{}", clean)
};
let mime = mime_guess::from_path(lookup.as_str())
.first_or_octet_stream()
.to_string();
Response::builder()
.status(200)
.header("Content-Type", mime)
.body(Body::from(content.data.to_vec()))
.unwrap()
}
None => Response::builder().status(404).body(Body::empty()).unwrap(),
}
}
async fn static_handler(
path: axum::extract::Path<String>,
state: axum::extract::State<Arc<AppState>>,
) -> Response {
let dist = state.web_dist.clone();
serve_file(dist.as_deref(), &path)
}
async fn spa_handler(axum::extract::State(state): axum::extract::State<Arc<AppState>>) -> Response {
if let Some(ref dist) = state.web_dist {
if let Some(data) = fs_read(dist, "index.html") {
return Response::builder()
.status(200)
.header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache")
.body(Body::from(data))
.unwrap();
}
}
match EmbeddedAssets::get("index.html") {
Some(content) => Response::builder()
.status(200)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(content.data.to_vec()))
.unwrap(),
None => Response::builder()
.status(404)
.body(Body::from(
"index.html not found — run `cd web && npm run build`",
))
.unwrap(),
}
}
pub struct WebPlugin;
impl WebPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for WebPlugin {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ChannelPlugin for WebPlugin {
fn name(&self) -> &str {
"web"
}
async fn setup(&self, ctx: ChannelContext) -> Result<ChannelBundle> {
let config = ctx.config.read().clone();
let host = config.gateway.host.clone();
let port = config.gateway.port;
let rate_limit = config.security.rate_limit_per_minute;
let workspace = PathBuf::from(&config.kernel.workspace);
let web_dist = ensure_web_dist(&workspace).await;
let web_channel = WebChannel::new(256);
let channel_handle = WebChannelHandle::from_channel(&web_channel);
let knowledge_root = workspace.join("knowledge");
let knowledge = match KnowledgeBase::new(knowledge_root) {
Ok(kb) => Arc::new(kb),
Err(e) => {
tracing::warn!(error = %e, "Failed to create KnowledgeBase at workspace, using temp dir");
let fallback_dir = std::env::temp_dir().join("oxios-web-knowledge");
Arc::new(
KnowledgeBase::new(fallback_dir)
.expect("Failed to create fallback KnowledgeBase"),
)
}
};
let state = Arc::new(AppState {
base_url: format!("http://{}:{}", host, port),
knowledge,
kernel: ctx.kernel.clone(),
channel: channel_handle,
config: ctx.config.clone(),
config_path: ctx.config_path.clone(),
start_time: ctx.kernel.start_time(),
rate_limiter: RateLimiter::new(rate_limit),
web_dist,
});
let api_routes = routes::build_routes(state.clone());
let cors_origins: Vec<_> = config
.security
.cors_origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect();
let cors = tower_http::cors::CorsLayer::new()
.allow_origin(cors_origins)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any);
let openapi = api_docs::build_openapi();
let swagger: Router<()> = utoipa_swagger_ui::SwaggerUi::new("/api-docs")
.url("/openapi.json", openapi)
.into();
let spa_routes: Router<Arc<AppState>> = Router::new()
.route("/assets/{*path}", get(static_handler))
.route("/favicon.svg", get(static_handler))
.route("/icons.svg", get(static_handler))
.route("/{*path}", get(spa_handler))
.route("/", get(spa_handler));
let app = Router::new()
.merge(api_routes)
.merge(spa_routes)
.layer(cors)
.nest_service("/api-docs", swagger)
.with_state(state);
let addr = format!("{}:{}", host, port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(addr = %addr, "Web server listening");
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.ok();
tracing::info!("Web server shutting down");
})
.await
{
tracing::error!(error = %e, "Web server error");
}
});
Ok(ChannelBundle {
channel: Box::new(web_channel),
tasks: vec![handle],
})
}
}