feat: dynamic app catalog, Gitea app polish, registry sync

App catalog served from Gitea repos (app-catalog) with 35 apps.
Nodes fetch catalog dynamically — new apps appear without frontend
rebuild. Test app added and removed to verify pipeline.

Gitea manifest updated with internal_port/nginx_proxy for iframe.
Updated catalog.json, nginx configs, app session configs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-12 08:20:18 -04:00
parent 94850b3176
commit ff5ef2951f
18 changed files with 892 additions and 142 deletions

View File

@@ -4,7 +4,6 @@ use hyper::{Request, Response};
use hyper_ws_listener::WsStream;
use serde::Deserialize;
use std::time::Instant;
use tokio::process::Command;
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
@@ -72,21 +71,9 @@ enum InputCommand {
Ping,
}
async fn xdotool(args: &[&str]) -> Result<()> {
let output = Command::new("xdotool")
.env("DISPLAY", ":0")
.args(args)
.output()
.await
.context("xdotool execution failed")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("xdotool error: {}", stderr);
}
Ok(())
}
/// Validate and acknowledge input — relay-only, no xdotool.
/// All input is forwarded to browser clients via the broadcast channel;
/// the browser's remote-relay.ts dispatches DOM events from there.
async fn handle_input(msg: &str) -> Result<Option<String>> {
let cmd: InputCommand = serde_json::from_str(msg)
.context("invalid input command")?;
@@ -97,25 +84,16 @@ async fn handle_input(msg: &str) -> Result<Option<String>> {
warn!("rejected key: {}", k);
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
}
xdotool(&["key", "--clearmodifiers", k]).await?;
}
InputCommand::MouseMove { x, y } => {
let x = x.clamp(-50, 50);
let y = y.clamp(-50, 50);
let xs = x.to_string();
let ys = y.to_string();
xdotool(&["mousemove_relative", "--", &xs, &ys]).await?;
let _x = x.clamp(-50, 50);
let _y = y.clamp(-50, 50);
}
InputCommand::Click { b } => {
let b = b.clamp(1, 3);
let bs = b.to_string();
xdotool(&["click", &bs]).await?;
let _b = b.clamp(1, 3);
}
InputCommand::Scroll { y } => {
// xdotool: button 4 = scroll up, button 5 = scroll down
let btn = if y < 0 { "4" } else { "5" };
let count = y.unsigned_abs().clamp(1, 10).to_string();
xdotool(&["click", "--repeat", &count, btn]).await?;
let _y = y.clamp(-10, 10);
}
InputCommand::Ping => {
return Ok(Some(r#"{"t":"p"}"#.to_string()));

View File

@@ -214,7 +214,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
),
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => (
"curl -sf http://localhost:8174/health || exit 1",
"curl -sf http://localhost:8175/ || exit 1",
"60s",
"3",
),
@@ -894,8 +894,10 @@ pub(super) async fn get_app_config(
None,
)
}
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
"gitea" => (
vec!["3000:3000".to_string(), "2222:22".to_string()],
vec!["3001:3000".to_string(), "2222:22".to_string()],
vec![
"/var/lib/archipelago/gitea/data:/data".to_string(),
"/var/lib/archipelago/gitea/config:/etc/gitea".to_string(),
@@ -912,6 +914,32 @@ pub(super) async fn get_app_config(
None,
None,
),
_ => (vec![], vec![], vec![], None, None),
_ => {
// Unknown app: try to load config from /var/lib/archipelago/app-configs/{id}.json
// This allows dynamic apps from the remote catalog to be installed
// without hardcoding their config here.
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
if let Ok(data) = tokio::fs::read_to_string(&config_path).await {
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) {
let ports = cfg.get("ports")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let volumes = cfg.get("volumes")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let env_vars = cfg.get("env")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
tracing::info!("Loaded dynamic config for app: {}", app_id);
return (ports, volumes, env_vars, None, None);
}
}
// No config found — use minimal defaults (container's own EXPOSE/VOLUME)
tracing::warn!("No config found for app: {} — using minimal defaults", app_id);
(vec![], vec![], vec![], None, None)
},
}
}