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::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
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/tmux/mouse", get(get_mouse_mode).post(set_mouse_mode))
.route("/system/version", get(version_check))
.route("/system/update", post(run_update))
.route("/system/restart", post(run_restart))
}
#[derive(Deserialize)]
struct ListDirsQuery {
path: String,
}
async fn system_info(State(state): State<AppState>) -> Json<Value> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/".into());
Json(json!({
"home_dir": home,
"multiplexer": state.engines.multiplexer_name(),
"proxy_domain": state.proxy.base_host,
}))
}
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(State(state): State<AppState>) -> (StatusCode, Json<Value>) {
match state.engines.check_multiplexer() {
Ok(()) => (StatusCode::OK, Json(json!({ "available": true }))),
Err(e) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"available": false,
"error": e.to_string(),
"install_hints": state.engines.multiplexer_install_hints(),
})),
),
}
}
async fn get_mouse_mode(State(state): State<AppState>) -> (StatusCode, Json<Value>) {
match state.engines.mouse_enabled().await {
Ok(enabled) => (StatusCode::OK, Json(json!({ "enabled": enabled }))),
Err(e) => (StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": e.to_string() }))),
}
}
async fn set_mouse_mode(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> (StatusCode, Json<Value>) {
let enabled = body.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
match state.engines.set_mouse_enabled(enabled).await {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true, "enabled": enabled }))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))),
}
}
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);
const RELAUNCH_DELAY: Duration = Duration::from_secs(3);
const RELAUNCH_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(15);
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(());
static RELAUNCH_TARGET: Mutex<Option<PathBuf>> = Mutex::const_new(None);
static PENDING_RESTART: AtomicBool = AtomicBool::new(false);
static TARGET_VERSION: Mutex<Option<String>> = Mutex::const_new(None);
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,
};
let pending_restart = PENDING_RESTART.load(Ordering::Relaxed);
let target_version = TARGET_VERSION.lock().await.clone();
(
StatusCode::OK,
Json(json!({
"current": current,
"latest": latest.to_string(),
"update_available": update_available,
"channel": channel,
"container": update::in_container(),
"restart_command": update::restart_command(
&std::env::args_os().collect::<Vec<_>>(),
crate::DAEMONIZED.load(std::sync::atomic::Ordering::Relaxed),
),
"pending_restart": pending_restart,
"target_version": target_version,
"restart_supported": cfg!(unix),
})),
)
}
async fn run_update(State(_state): State<AppState>) -> (StatusCode, Json<Value>) {
let Ok(_guard) = UPDATE_LOCK.try_lock() else {
return (StatusCode::CONFLICT, Json(json!({ "error": "update already in progress" })));
};
if update::in_container() {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "container_environment" })));
}
if PENDING_RESTART.load(Ordering::Relaxed) {
let ver = TARGET_VERSION
.lock()
.await
.clone()
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
return (
StatusCode::OK,
Json(json!({
"status": "updated",
"version": ver,
"restart_required": true,
"restart_supported": cfg!(unix),
"auto_restart": false,
})),
);
}
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::NPM_UPGRADE_ARGS),
)
.await
{
Err(_) => {
return (
StatusCode::GATEWAY_TIMEOUT,
Json(json!({ "error": "npm install timed out" })),
);
}
Ok(r) => r.map(|_| ()),
}
}
update::Channel::GithubRelease => update::self_replace(&exe, &release).await,
};
match result {
Ok(()) => {
*RELAUNCH_TARGET.lock().await = Some(exe);
PENDING_RESTART.store(true, Ordering::Relaxed);
*TARGET_VERSION.lock().await = Some(release.version.to_string());
#[cfg(unix)]
let restart_supported = true;
#[cfg(not(unix))]
let restart_supported = false;
(
StatusCode::OK,
Json(json!({
"status": "updated",
"version": release.version.to_string(),
"restart_required": true,
"restart_supported": restart_supported,
"auto_restart": false,
})),
)
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("{e:#}") }))),
}
}
async fn run_restart(State(state): State<AppState>) -> (StatusCode, Json<Value>) {
let Ok(_guard) = UPDATE_LOCK.try_lock() else {
return (StatusCode::CONFLICT, Json(json!({ "error": "update already in progress" })));
};
#[cfg(unix)]
{
let target = RELAUNCH_TARGET.lock().await;
let exe = match target.as_ref() {
Some(p) => p.clone(),
None => match update::current_exe_channel() {
Ok((p, _)) => p,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
},
};
let supervisor = state.acp_supervisor.clone();
tokio::spawn(async move {
let _guard = _guard;
tokio::time::sleep(RELAUNCH_DELAY).await;
if tokio::time::timeout(RELAUNCH_SHUTDOWN_TIMEOUT, supervisor.shutdown_all())
.await
.is_err()
{
eprintln!(
"omniterm-update: ACP shutdown exceeded {RELAUNCH_SHUTDOWN_TIMEOUT:?}, relaunching anyway; stale agent children may linger"
);
}
if let Err(e) = update::relaunch(&exe) {
eprintln!("omniterm-update: manual-relaunch failed, restart manually: {e:#}");
}
});
(StatusCode::OK, Json(json!({ "status": "restarting" })))
}
#[cfg(not(unix))]
{
let _ = state;
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": "unsupported_platform",
"message": "automatic restart is not supported on this platform"
})),
)
}
}