use axum::{
Json, Router,
extract::{Query, State},
http::StatusCode,
routing::{get, post},
};
use semver::Version;
use serde::Deserialize;
use serde_json::{Value, json};
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use crate::AppState;
use crate::fs::{self, SortKey};
use crate::update;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/system/info", get(system_info))
.route("/system/dirs", get(list_dirs))
.route("/system/exists", get(check_exists))
.route("/system/multiplexer", get(multiplexer_status))
.route("/system/version", get(version_check))
.route("/system/update", post(run_update))
}
#[derive(Deserialize)]
struct ListDirsQuery {
path: String,
}
async fn system_info() -> Json<Value> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/".into());
Json(json!({
"home_dir": home,
"multiplexer": crate::tmux::MULTIPLEXER_NAME,
}))
}
async fn list_dirs(
State(_state): State<AppState>,
Query(q): Query<ListDirsQuery>,
) -> (axum::http::StatusCode, Json<Value>) {
let path = std::path::Path::new(&q.path);
let canonical = match path.canonicalize() {
Ok(p) => p,
Err(_) => {
return (axum::http::StatusCode::NOT_FOUND, Json(json!({ "error": "path not found" })));
}
};
if !canonical.is_dir() {
return (axum::http::StatusCode::BAD_REQUEST, Json(json!({ "error": "not a directory" })));
}
match fs::list_dir(&canonical, "", SortKey::Name, false).await {
Ok(entries) => (axum::http::StatusCode::OK, Json(json!({ "files": entries }))),
Err(e) => {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })))
}
}
}
#[derive(Deserialize)]
struct ExistsQuery {
path: String,
}
async fn check_exists(Query(q): Query<ExistsQuery>) -> (StatusCode, Json<Value>) {
let exists = std::path::Path::new(&q.path).exists();
(StatusCode::OK, Json(json!({ "exists": exists })))
}
async fn multiplexer_status() -> (StatusCode, Json<Value>) {
match crate::tmux::check_multiplexer() {
Ok(()) => (StatusCode::OK, Json(json!({ "available": true }))),
Err(e) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"available": false,
"error": e.to_string(),
"install_hints": crate::tmux::MULTIPLEXER_INSTALL_HINTS,
})),
),
}
}
const VERSION_CACHE_TTL: Duration = Duration::from_secs(3600);
const VERSION_CACHE_NEG_TTL: Duration = Duration::from_secs(300);
const UPDATE_TIMEOUT: Duration = Duration::from_secs(300);
struct CacheEntry {
at: Instant,
result: Result<Version, String>,
}
static VERSION_CACHE: Mutex<Option<CacheEntry>> = Mutex::const_new(None);
static UPDATE_LOCK: Mutex<()> = Mutex::const_new(());
async fn cached_latest() -> Result<Version, String> {
let mut cache = VERSION_CACHE.lock().await;
if let Some(entry) = cache.as_ref() {
let ttl = if entry.result.is_ok() { VERSION_CACHE_TTL } else { VERSION_CACHE_NEG_TTL };
if entry.at.elapsed() < ttl {
return entry.result.clone();
}
}
let result = update::fetch_latest().await.map(|r| r.version).map_err(|e| format!("{e:#}"));
*cache = Some(CacheEntry { at: Instant::now(), result: result.clone() });
result
}
async fn version_check() -> (StatusCode, Json<Value>) {
let current = env!("CARGO_PKG_VERSION");
let latest = match cached_latest().await {
Ok(v) => v,
Err(e) => return (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))),
};
let channel = match update::current_exe_channel() {
Ok((_, c)) => c,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })));
}
};
let update_available = match Version::parse(current) {
Ok(cur) => latest > cur,
Err(_) => false,
};
(
StatusCode::OK,
Json(json!({
"current": current,
"latest": latest.to_string(),
"update_available": update_available,
"channel": channel,
})),
)
}
async fn run_update() -> (StatusCode, Json<Value>) {
let Ok(_guard) = UPDATE_LOCK.try_lock() else {
return (StatusCode::CONFLICT, Json(json!({ "error": "update already in progress" })));
};
let current = match Version::parse(env!("CARGO_PKG_VERSION")) {
Ok(v) => v,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })));
}
};
let release = match update::fetch_latest().await {
Ok(r) => r,
Err(e) => return (StatusCode::BAD_GATEWAY, Json(json!({ "error": format!("{e:#}") }))),
};
if release.version <= current {
return (StatusCode::CONFLICT, Json(json!({ "error": "already up to date" })));
}
let (exe, channel) = match update::current_exe_channel() {
Ok(v) => v,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })));
}
};
let result = match channel {
update::Channel::Cargo => {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "unsupported_channel" })));
}
update::Channel::Npm => {
match tokio::time::timeout(
UPDATE_TIMEOUT,
update::delegate_captured("npm", &["update", "-g", update::NPM_PACKAGE]),
)
.await
{
Err(_) => {
return (
StatusCode::GATEWAY_TIMEOUT,
Json(json!({ "error": "npm update timed out" })),
);
}
Ok(r) => r.map(|_| ()),
}
}
update::Channel::GithubRelease => update::self_replace(&exe, &release).await,
};
match result {
Ok(()) => (
StatusCode::OK,
Json(json!({
"status": "updated",
"version": release.version.to_string(),
"restart_required": true,
})),
),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("{e:#}") }))),
}
}