feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated apps, mobile gamepad enhancements, content HTTP handler, package install config updates, health monitor tweaks, shared content UI, container specs and image version updates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
24f122f35a
commit
bb14490fb7
@ -6,7 +6,7 @@ app:
|
||||
category: community
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/botfights:1.0.0
|
||||
image: git.tx1138.com/lfg2025/botfights:1.1.0
|
||||
pull_policy: always
|
||||
|
||||
dependencies:
|
||||
|
||||
@ -119,4 +119,56 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
|
||||
pub(super) async fn handle_content_preview(
|
||||
path: &str,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Path format: /content/{id}/preview
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/preview"))
|
||||
.unwrap_or("");
|
||||
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "text/plain", hyper::Body::from("Invalid content ID")));
|
||||
}
|
||||
|
||||
match content_server::serve_content_preview(&config.data_dir, content_id).await {
|
||||
Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::PreviewResult::BlurPreview(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.header("X-Content-Preview", "blur")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::PreviewResult::TruncatedPreview(bytes, mime_type, total_size)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.header("X-Content-Preview", "truncated")
|
||||
.header("X-Content-Total-Size", total_size.to_string())
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::PreviewResult::NotFound) | Err(_) => {
|
||||
Ok(build_response(StatusCode::NOT_FOUND, "text/plain", hyper::Body::from("Preview not available")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,6 +205,11 @@ impl ApiHandler {
|
||||
Self::handle_node_message(body_bytes).await
|
||||
}
|
||||
|
||||
// Content preview — degraded previews for paid content (no auth, no payment)
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
|
||||
Self::handle_content_preview(p, &self.config).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth)
|
||||
(Method::GET, p) if p.starts_with("/content/") => {
|
||||
Self::handle_content_request(p, &headers, &self.config).await
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use super::RpcHandler;
|
||||
use crate::content_server::{self, AccessControl, Availability, ContentItem};
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use crate::wallet::ecash;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::debug;
|
||||
|
||||
@ -313,4 +314,156 @@ impl RpcHandler {
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Download paid content from a peer: mint ecash token, send with request.
|
||||
pub(super) async fn handle_content_download_peer_paid(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
let price_sats = params
|
||||
.get("price_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
|
||||
|
||||
if price_sats == 0 {
|
||||
return Err(anyhow::anyhow!("price_sats must be > 0"));
|
||||
}
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
// Mint ecash payment token
|
||||
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
|
||||
.await
|
||||
.context("Failed to create ecash payment token — check wallet balance")?;
|
||||
|
||||
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||
.context("Failed to create SOCKS proxy")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(socks_proxy)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("Failed to build Tor HTTP client")?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
|
||||
let url = format!("http://{}/content/{}", onion, content_id);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("X-Federation-DID", &local_did)
|
||||
.header("X-Payment-Token", &token_str)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer over Tor")?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected — token is spent but content not received
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment rejected by peer — token may have been insufficient or invalid"
|
||||
));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"paid_sats": price_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch a preview of paid content from a peer (no payment required).
|
||||
pub(super) async fn handle_content_preview_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||
.context("Failed to create SOCKS proxy")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(socks_proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build Tor HTTP client")?;
|
||||
|
||||
let url = format!("http://{}/content/{}/preview", onion, content_id);
|
||||
debug!("Fetching content preview from {}", url);
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Peer returned error for preview: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let is_preview = response
|
||||
.headers()
|
||||
.get("X-Content-Preview")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read preview response")?;
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"content_type": content_type,
|
||||
"preview_mode": is_preview,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -218,6 +218,11 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"60s",
|
||||
"3",
|
||||
),
|
||||
"fedimint-gateway" => (
|
||||
"curl -sf http://localhost:8176/ || exit 1",
|
||||
"60s",
|
||||
"3",
|
||||
),
|
||||
"nostr-rs-relay" | "nostr-relay" => {
|
||||
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||
}
|
||||
@ -754,37 +759,43 @@ pub(super) async fn get_app_config(
|
||||
Some(vec![
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
format!("--bitcoind-url=http://{}:{}@host.containers.internal:8332", rpc_user, rpc_pass),
|
||||
]),
|
||||
),
|
||||
"fedimint-gateway" => (
|
||||
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
||||
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
Some(vec![
|
||||
"gatewayd".to_string(),
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
"--listen".to_string(),
|
||||
"0.0.0.0:8176".to_string(),
|
||||
"--bcrypt-password-hash".to_string(),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
format!("http://{}:8332", host_ip),
|
||||
"--bitcoind-username".to_string(),
|
||||
rpc_user.to_string(),
|
||||
"--bitcoind-password".to_string(),
|
||||
rpc_pass.to_string(),
|
||||
"ldk".to_string(),
|
||||
"--ldk-lightning-port".to_string(),
|
||||
"9737".to_string(),
|
||||
"--ldk-alias".to_string(),
|
||||
"archipelago-gateway".to_string(),
|
||||
format!("--bitcoind-url=http://{}:{}@{}:8332", rpc_user, rpc_pass, host_ip),
|
||||
]),
|
||||
),
|
||||
"fedimint-gateway" => {
|
||||
let fedi_hash = read_secret(
|
||||
"fedimint-gateway-hash",
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
|
||||
);
|
||||
(
|
||||
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
||||
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
Some(vec![
|
||||
"gatewayd".to_string(),
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
"--listen".to_string(),
|
||||
"0.0.0.0:8176".to_string(),
|
||||
"--bcrypt-password-hash".to_string(),
|
||||
fedi_hash,
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
format!("http://{}:8332", host_ip),
|
||||
"--bitcoind-username".to_string(),
|
||||
rpc_user.to_string(),
|
||||
"--bitcoind-password".to_string(),
|
||||
rpc_pass.to_string(),
|
||||
"ldk".to_string(),
|
||||
"--ldk-lightning-port".to_string(),
|
||||
"9737".to_string(),
|
||||
"--ldk-alias".to_string(),
|
||||
"archipelago-gateway".to_string(),
|
||||
]),
|
||||
)
|
||||
}
|
||||
"indeedhub" => (
|
||||
vec!["7778:7777".to_string()],
|
||||
vec![],
|
||||
|
||||
@ -567,8 +567,18 @@ impl RpcHandler {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
// Set TMPDIR to user-writable location — rootless podman's user namespace
|
||||
// makes /var/tmp read-only, which causes `podman pull` to fail with
|
||||
// "mkdir /var/tmp/container_images_storage...: read-only file system"
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&user_tmp);
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", docker_image])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
|
||||
@ -76,7 +76,7 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
|
||||
"mempool-api" => &["mempool-db", "electrumx"],
|
||||
"mempool-web" => &["mempool-api"],
|
||||
"fedimint" => &["bitcoin-knots"],
|
||||
"fedimint-gateway" => &["lnd"],
|
||||
"fedimint-gateway" => &["bitcoin-knots", "fedimint"],
|
||||
|
||||
// IndeedHub stack
|
||||
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
|
||||
@ -525,6 +525,14 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
let (mut data, _) = state.get_snapshot().await;
|
||||
|
||||
for container in &containers {
|
||||
// Skip optional/marketplace containers that aren't installed
|
||||
if let Some(pkg) = data.package_data.get(&container.app_id) {
|
||||
if pkg.installed.is_none() {
|
||||
debug!("Skipping uninstalled container: {}", container.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if container.healthy {
|
||||
if tracker.attempt_count(&container.name) > 0 {
|
||||
info!("Container {} is healthy again after restart", container.name);
|
||||
|
||||
BIN
neode-ui/public/assets/img/featured/indeedhub-banner.jpg
Normal file
BIN
neode-ui/public/assets/img/featured/indeedhub-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
48
neode-ui/public/catalog.json
Normal file
48
neode-ui/public/catalog.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-04-11T00:00:00Z",
|
||||
"registry": "git.tx1138.com/lfg2025",
|
||||
"featured": {
|
||||
"id": "indeedhub",
|
||||
"banner": "/assets/img/featured/indeedhub-banner.jpg",
|
||||
"headline": "Stream Sovereignty",
|
||||
"description": "Bitcoin documentaries with Nostr identity. God Bless Bitcoin, The Bitcoin Psyop, and more — streaming from your own node.",
|
||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||
},
|
||||
"apps": [
|
||||
{ "id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0", "description": "Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.", "icon": "/assets/img/app-icons/bitcoin-knots.webp", "author": "Bitcoin Knots", "dockerImage": "bitcoin-knots:latest", "repoUrl": "https://github.com/bitcoinknots/bitcoin", "category": "money", "tier": "core" },
|
||||
{ "id": "lnd", "title": "LND", "version": "0.18.4", "description": "Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.", "icon": "/assets/img/app-icons/lnd.svg", "author": "Lightning Labs", "dockerImage": "lnd:v0.18.4-beta", "repoUrl": "https://github.com/lightningnetwork/lnd", "category": "money", "tier": "core" },
|
||||
{ "id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7", "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.", "icon": "/assets/img/app-icons/btcpay-server.png", "author": "BTCPay Server Foundation", "dockerImage": "btcpayserver:1.13.7", "repoUrl": "https://github.com/btcpayserver/btcpayserver", "category": "commerce", "tier": "core" },
|
||||
{ "id": "mempool", "title": "Mempool Explorer", "version": "3.0.0", "description": "Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses.", "icon": "/assets/img/app-icons/mempool.webp", "author": "Mempool", "dockerImage": "mempool-frontend:v3.0.0", "repoUrl": "https://github.com/mempool/mempool", "category": "money", "tier": "core" },
|
||||
{ "id": "electrumx", "title": "ElectrumX", "version": "1.18.0", "description": "Electrum protocol server. Index the blockchain for fast wallet lookups, privately.", "icon": "/assets/img/app-icons/electrumx.webp", "author": "Luke Childs", "dockerImage": "electrumx:v1.18.0", "repoUrl": "https://github.com/spesmilo/electrumx", "category": "money", "tier": "core" },
|
||||
{ "id": "indeedhub", "title": "IndeeHub", "version": "1.0.0", "description": "Bitcoin documentary streaming with Nostr identity. Stream sovereignty content from your node.", "icon": "/assets/img/app-icons/indeedhub.png", "author": "IndeeHub Team", "dockerImage": "indeedhub:1.0.0", "repoUrl": "https://github.com/indeedhub/indeedhub", "category": "community" },
|
||||
{ "id": "botfights", "title": "BotFights", "version": "1.0.0", "description": "Bot arena + 2-player arcade fighter with controller support.", "icon": "/assets/img/app-icons/botfights.svg", "author": "BotFights", "dockerImage": "botfights:1.1.0", "repoUrl": "https://botfights.net", "category": "community" },
|
||||
{ "id": "filebrowser", "title": "File Browser", "version": "2.27.0", "description": "Web-based file manager. Browse, upload, and manage files on your server.", "icon": "/assets/img/app-icons/file-browser.webp", "author": "File Browser", "dockerImage": "filebrowser:v2.27.0", "repoUrl": "https://github.com/filebrowser/filebrowser", "category": "data", "tier": "core" },
|
||||
{ "id": "vaultwarden", "title": "Vaultwarden", "version": "1.30.0", "description": "Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.", "icon": "/assets/img/app-icons/vaultwarden.webp", "author": "Vaultwarden", "dockerImage": "vaultwarden:1.30.0-alpine", "repoUrl": "https://github.com/dani-garcia/vaultwarden", "category": "data", "tier": "recommended" },
|
||||
{ "id": "searxng", "title": "SearXNG", "version": "2024.1.0", "description": "Privacy-respecting metasearch engine. Search the internet without being tracked.", "icon": "/assets/img/app-icons/searxng.png", "author": "SearXNG", "dockerImage": "searxng:latest", "repoUrl": "https://github.com/searxng/searxng", "category": "data", "tier": "recommended" },
|
||||
{ "id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0", "description": "Your own Nostr relay. Store events locally, relay for friends, publish over Tor.", "icon": "/assets/img/app-icons/nostr-rs-relay.svg", "author": "scsiblade", "dockerImage": "nostr-rs-relay:0.9.0", "repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/", "category": "nostr" },
|
||||
{ "id": "fedimint", "title": "Fedimint", "version": "0.10.0", "description": "Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.", "icon": "/assets/img/app-icons/fedimint.png", "author": "Fedimint", "dockerImage": "fedimintd:v0.10.0", "repoUrl": "https://github.com/fedimint/fedimint", "category": "money" },
|
||||
{ "id": "ollama", "title": "Ollama", "version": "0.5.4", "description": "Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.", "icon": "/assets/img/app-icons/ollama.png", "author": "Ollama", "dockerImage": "ollama:latest", "repoUrl": "https://github.com/ollama/ollama", "category": "data" },
|
||||
{ "id": "nextcloud", "title": "Nextcloud", "version": "28", "description": "Your own private cloud. File sync, calendars, contacts — all on your hardware.", "icon": "/assets/img/app-icons/nextcloud.webp", "author": "Nextcloud", "dockerImage": "nextcloud:28", "repoUrl": "https://github.com/nextcloud/server", "category": "data" },
|
||||
{ "id": "jellyfin", "title": "Jellyfin", "version": "10.8.13", "description": "Free media server. Stream your movies, music, and photos to any device.", "icon": "/assets/img/app-icons/jellyfin.webp", "author": "Jellyfin", "dockerImage": "jellyfin:10.8.13", "repoUrl": "https://github.com/jellyfin/jellyfin", "category": "data" },
|
||||
{ "id": "immich", "title": "Immich", "version": "1.90.0", "description": "High-performance photo and video backup. Mobile-first with ML features.", "icon": "/assets/img/app-icons/immich.png", "author": "Immich", "dockerImage": "immich-server:release", "repoUrl": "https://github.com/immich-app/immich", "category": "data" },
|
||||
{ "id": "homeassistant", "title": "Home Assistant", "version": "2024.1", "description": "Open-source home automation. Control smart home devices privately.", "icon": "/assets/img/app-icons/homeassistant.png", "author": "Home Assistant", "dockerImage": "home-assistant:2024.1", "repoUrl": "https://github.com/home-assistant/core", "category": "home" },
|
||||
{ "id": "grafana", "title": "Grafana", "version": "10.2.0", "description": "Analytics and monitoring platform. Dashboards for your node metrics.", "icon": "/assets/img/app-icons/grafana.png", "author": "Grafana Labs", "dockerImage": "grafana:10.2.0", "repoUrl": "https://github.com/grafana/grafana", "category": "data", "tier": "recommended" },
|
||||
{ "id": "tailscale", "title": "Tailscale", "version": "1.78.0", "description": "Zero-config VPN. Secure remote access with WireGuard mesh networking.", "icon": "/assets/img/app-icons/tailscale.webp", "author": "Tailscale", "dockerImage": "tailscale:stable", "repoUrl": "https://github.com/tailscale/tailscale", "category": "networking", "tier": "recommended" },
|
||||
{ "id": "penpot", "title": "Penpot", "version": "2.4", "description": "Open-source design platform. Self-hosted alternative to Figma.", "icon": "/assets/img/app-icons/penpot.webp", "author": "Penpot", "dockerImage": "penpot-frontend:2.4", "repoUrl": "https://github.com/penpot/penpot", "category": "data" },
|
||||
{ "id": "photoprism", "title": "PhotoPrism", "version": "240915", "description": "AI-powered photo management with facial recognition, privately.", "icon": "/assets/img/app-icons/photoprism.svg", "author": "PhotoPrism", "dockerImage": "photoprism:240915", "repoUrl": "https://github.com/photoprism/photoprism", "category": "data" },
|
||||
{ "id": "uptime-kuma", "title": "Uptime Kuma", "version": "1.23.0", "description": "Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.", "icon": "/assets/img/app-icons/uptime-kuma.webp", "author": "Uptime Kuma", "dockerImage": "uptime-kuma:1", "repoUrl": "https://github.com/louislam/uptime-kuma", "category": "data", "tier": "recommended" },
|
||||
{ "id": "nostr-vpn", "title": "Nostr VPN", "version": "0.3.7", "description": "Tailscale-style mesh VPN with Nostr control plane.", "icon": "/assets/img/app-icons/nostr-vpn.svg", "author": "Martti Malmi", "dockerImage": "nostr-vpn:v0.3.7", "repoUrl": "https://github.com/mmalmi/nostr-vpn", "category": "networking" },
|
||||
{ "id": "fips", "title": "FIPS", "version": "0.1.0", "description": "Free Internetworking Peering System. Self-organizing encrypted mesh.", "icon": "/assets/img/app-icons/fips.svg", "author": "Jim Corgan", "dockerImage": "fips:v0.1.0", "repoUrl": "https://github.com/jmcorgan/fips", "category": "networking" },
|
||||
{ "id": "routstr", "title": "Routstr", "version": "0.4.3", "description": "Decentralized AI inference proxy. Pay-per-request with Cashu ecash.", "icon": "/assets/img/app-icons/routstr.svg", "author": "Routstr", "dockerImage": "routstr:v0.4.3", "repoUrl": "https://github.com/routstr/routstr-core", "category": "community" },
|
||||
{ "id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0", "description": "Own your data with DID-based access control. Sync across devices.", "icon": "/assets/img/app-icons/dwn.svg", "author": "TBD", "dockerImage": "dwn-server:main", "repoUrl": "https://github.com/TBD54566975/dwn-server", "category": "data" },
|
||||
{ "id": "cryptpad", "title": "CryptPad", "version": "2024.12.0", "description": "End-to-end encrypted documents and collaboration. Zero-knowledge.", "icon": "/assets/img/app-icons/cryptpad.webp", "author": "XWiki SAS", "dockerImage": "cryptpad:2024.12.0", "repoUrl": "https://github.com/cryptpad/cryptpad", "category": "data" },
|
||||
{ "id": "nostrudel", "title": "noStrudel", "version": "0.40.0", "description": "Feature-rich Nostr web client.", "icon": "/assets/img/app-icons/nostrudel.svg", "author": "hzrd149", "dockerImage": "", "repoUrl": "https://github.com/hzrd149/nostrudel", "webUrl": "https://nostrudel.ninja", "category": "nostr" },
|
||||
{ "id": "nwnn", "title": "Next Web News Network", "version": "1.0.0", "description": "Decentralized news aggregator.", "icon": "/assets/img/app-icons/nwnn.png", "author": "L484", "dockerImage": "", "webUrl": "https://nwnn.l484.com", "category": "l484" },
|
||||
{ "id": "484-kitchen", "title": "484 Kitchen", "version": "1.0.0", "description": "K484 application platform.", "icon": "/assets/img/app-icons/484-kitchen.png", "author": "L484", "dockerImage": "", "webUrl": "https://484.kitchen", "category": "l484" },
|
||||
{ "id": "call-the-operator", "title": "Call the Operator", "version": "1.0.0", "description": "Escape the Matrix.", "icon": "/assets/img/app-icons/call-the-operator.png", "author": "TX1138", "dockerImage": "", "webUrl": "https://cta.tx1138.com", "category": "l484" },
|
||||
{ "id": "arch-presentation", "title": "Arch Presentation", "version": "1.0.0", "description": "The Future of Decentralized Infrastructure.", "icon": "/assets/img/app-icons/arch-presentation.png", "author": "L484", "dockerImage": "", "webUrl": "https://present.l484.com", "category": "l484" },
|
||||
{ "id": "syntropy-institute", "title": "Syntropy Institute", "version": "1.0.0", "description": "Medicine Reimagined.", "icon": "/assets/img/app-icons/syntropy-institute.png", "author": "Syntropy Institute", "dockerImage": "", "webUrl": "https://syntropy.institute", "category": "l484" },
|
||||
{ "id": "t-zero", "title": "T-0", "version": "1.0.0", "description": "Documentary series exploring decentralization.", "icon": "/assets/img/app-icons/t-zero.png", "author": "T-0", "dockerImage": "", "webUrl": "https://teeminuszero.net", "category": "l484" }
|
||||
]
|
||||
}
|
||||
@ -102,6 +102,7 @@ const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
delete: [path: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
preview: [path: string]
|
||||
}>()
|
||||
|
||||
const cloudStore = useCloudStore()
|
||||
@ -109,7 +110,7 @@ const imgFailed = ref(false)
|
||||
|
||||
const ext = computed(() => props.item.extension)
|
||||
const isDir = computed(() => props.item.isDir)
|
||||
const { isImage, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||
const { isImage, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
if (!isImage.value || imgFailed.value) return null
|
||||
@ -121,6 +122,8 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
||||
function handleClick() {
|
||||
if (props.item.isDir) {
|
||||
emit('navigate', props.item.path)
|
||||
} else if (isImage.value || isVideo.value) {
|
||||
emit('preview', props.item.path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
@delete="$emit('delete', $event)"
|
||||
@play="(path, name) => $emit('play', path, name)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
@preview="$emit('preview', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -53,6 +54,7 @@
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
@preview="$emit('preview', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -76,5 +78,6 @@ defineEmits<{
|
||||
delete: [path: string]
|
||||
play: [path: string, name: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
preview: [path: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@ -1894,6 +1894,113 @@ html:has(body.video-background-active)::before {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Media Lightbox ── */
|
||||
.lightbox-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
}
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 4010;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
.lightbox-counter {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 4010;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lightbox-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 4010;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.lightbox-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.lightbox-nav-prev { left: 1rem; }
|
||||
.lightbox-nav-next { right: 1rem; }
|
||||
.lightbox-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: calc(100vw - 8rem);
|
||||
max-height: calc(100vh - 6rem);
|
||||
}
|
||||
.lightbox-media {
|
||||
max-width: calc(100vw - 8rem);
|
||||
max-height: calc(100vh - 6rem);
|
||||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.lightbox-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
.lightbox-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
.lightbox-filename {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 4010;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 0.375rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.lightbox-nav-prev { left: 0.25rem; }
|
||||
.lightbox-nav-next { right: 0.25rem; }
|
||||
.lightbox-content {
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100vh - 4rem);
|
||||
}
|
||||
.lightbox-media {
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100vh - 4rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Share action button highlight */
|
||||
.cloud-file-action-share:hover {
|
||||
background: rgba(251, 146, 60, 0.2);
|
||||
@ -2302,6 +2409,42 @@ html:has(body.video-background-active)::before {
|
||||
border: 1px solid rgba(251, 146, 60, 0.2);
|
||||
}
|
||||
|
||||
/* Featured App Banner */
|
||||
.featured-banner {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(251, 146, 60, 0.15);
|
||||
}
|
||||
.featured-banner-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.featured-banner:hover .featured-banner-img {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.featured-banner-overlay {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 320px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.5) 40%, rgba(0,0,0,0.1) 100%);
|
||||
border-radius: 16px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.featured-banner { min-height: 240px; }
|
||||
.featured-banner-overlay { padding: 1.5rem; min-height: 240px; }
|
||||
}
|
||||
|
||||
.discover-stat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@ -38,11 +38,14 @@
|
||||
@open-new-tab-and-back="openNewTabAndBack"
|
||||
/>
|
||||
|
||||
<!-- Mobile: gamepad for botfights, browser bar for everything else -->
|
||||
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
|
||||
<MobileGamepad
|
||||
v-if="isMobile && appId === 'botfights'"
|
||||
:iframe-ref="iframeRef ?? null"
|
||||
:player="1"
|
||||
@refresh="refresh"
|
||||
@openBrowser="openNewTab"
|
||||
@close="closeSession"
|
||||
/>
|
||||
<div v-else class="md:hidden app-session-mobile-bar">
|
||||
<button class="app-session-bar-btn" aria-label="Back" @click="iframeGoBack">
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero + Featured (only when no search) -->
|
||||
<!-- Hero + Featured + Banner (only when no search) -->
|
||||
<template v-if="!searchQuery">
|
||||
<DiscoverHero
|
||||
:total-apps="allApps.length"
|
||||
@ -65,10 +65,49 @@
|
||||
@install="handleInstall"
|
||||
/>
|
||||
|
||||
<!-- Featured App Banner (from catalog or hardcoded) -->
|
||||
<div
|
||||
v-if="featuredBanner"
|
||||
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
|
||||
>
|
||||
<img
|
||||
:src="featuredBanner.banner"
|
||||
:alt="featuredBanner.headline"
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<div class="featured-banner-overlay">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="discover-terminal-tag">featured</span>
|
||||
<span class="text-white/50 text-sm font-mono">{{ featuredBanner.tag }}</span>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ featuredBanner.headline }}</h2>
|
||||
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ featuredBanner.description }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="featuredBannerApp && isInstalled(featuredBannerApp.id) && !isStartingUp(featuredBannerApp.id)"
|
||||
@click.stop="launchInstalledApp(featuredBannerApp)"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||
>Launch</button>
|
||||
<button
|
||||
v-else-if="featuredBannerApp && !isInstalled(featuredBannerApp.id) && featuredBannerApp.dockerImage"
|
||||
@click.stop="handleInstall(featuredBannerApp)"
|
||||
:disabled="installingApps.has(featuredBannerApp.id)"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} v{{ featuredBannerApp?.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Section Divider -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">all</span>
|
||||
<h2 class="text-xl font-bold text-white">All Applications</h2>
|
||||
<h2 class="text-xl font-bold text-white">Available to Install</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
|
||||
</div>
|
||||
@ -150,7 +189,7 @@ import FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
import FilterModal from './discover/FilterModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp } from './discover/curatedApps'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
@ -297,11 +336,8 @@ const filteredApps = computed(() => {
|
||||
app.author?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
apps.sort((a, b) => {
|
||||
const aInstalled = isInstalled(a.id) ? 1 : 0
|
||||
const bInstalled = isInstalled(b.id) ? 1 : 0
|
||||
return aInstalled - bInstalled
|
||||
})
|
||||
// Hide installed apps and web-only links (no dockerImage = not installable)
|
||||
apps = apps.filter(app => !isInstalled(app.id) && app.dockerImage)
|
||||
return apps
|
||||
})
|
||||
|
||||
@ -309,6 +345,21 @@ const installedCount = computed(() => {
|
||||
return allApps.value.filter(app => isInstalled(app.id)).length
|
||||
})
|
||||
|
||||
// Featured banner — from catalog.json or first FEATURED_DEFINITIONS entry with banner
|
||||
const featuredBanner = computed(() => {
|
||||
if (catalogFeatured.value) return catalogFeatured.value
|
||||
const first = FEATURED_DEFINITIONS.find(f => f.banner)
|
||||
if (!first) return null
|
||||
const app = allApps.value.find(a => a.id === first.id)
|
||||
if (!app) return null
|
||||
return { id: first.id, banner: first.banner!, headline: app.title ?? first.id, description: first.desc, tag: first.tag }
|
||||
})
|
||||
|
||||
const featuredBannerApp = computed(() => {
|
||||
if (!featuredBanner.value) return null
|
||||
return allApps.value.find(a => a.id === featuredBanner.value!.id) ?? null
|
||||
})
|
||||
|
||||
const featuredApps = computed<FeaturedApp[]>(() => {
|
||||
return FEATURED_DEFINITIONS
|
||||
.map(f => {
|
||||
@ -473,11 +524,21 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const catalogFeatured = ref<CatalogFeatured | null>(null)
|
||||
|
||||
async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = true
|
||||
communityError.value = ''
|
||||
if (import.meta.env.DEV) console.log('Loading Docker-based app marketplace')
|
||||
communityApps.value = getCuratedAppList()
|
||||
// Try dynamic catalog first, fall back to hardcoded
|
||||
const catalog = await fetchAppCatalog()
|
||||
if (catalog) {
|
||||
communityApps.value = catalog.apps
|
||||
catalogFeatured.value = catalog.featured
|
||||
if (import.meta.env.DEV) console.log('Loaded app catalog from registry:', catalog.apps.length, 'apps')
|
||||
} else {
|
||||
communityApps.value = getCuratedAppList()
|
||||
if (import.meta.env.DEV) console.log('Using hardcoded app list (catalog.json unavailable)')
|
||||
}
|
||||
loadingCommunity.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -271,8 +271,8 @@ const filteredApps = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
// Hide installed and installing apps from marketplace — they belong in My Apps
|
||||
apps = apps.filter(app => !isInstalled(app.id) && !installingApps.has(app.id))
|
||||
// Hide installed, installing, and web-only apps (no dockerImage = not installable)
|
||||
apps = apps.filter(app => !isInstalled(app.id) && !installingApps.has(app.id) && app.dockerImage)
|
||||
|
||||
return apps
|
||||
})
|
||||
|
||||
@ -43,18 +43,37 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center: START / SELECT -->
|
||||
<!-- Center: START / SELECT + utility buttons -->
|
||||
<div class="gamepad-meta">
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Escape')"
|
||||
aria-label="Select"
|
||||
>SEL</button>
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Enter')"
|
||||
aria-label="Start"
|
||||
>START</button>
|
||||
<div class="meta-row">
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Escape')"
|
||||
aria-label="Select"
|
||||
>SEL</button>
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Enter')"
|
||||
aria-label="Start"
|
||||
>START</button>
|
||||
</div>
|
||||
<div class="meta-utility">
|
||||
<button class="util-btn" aria-label="Refresh" @click="$emit('refresh')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="util-btn" aria-label="Open in browser" @click="$emit('openBrowser')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="util-btn" aria-label="Close" @click="$emit('close')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (right side) -->
|
||||
@ -83,6 +102,12 @@ const props = defineProps<{
|
||||
player?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refresh: []
|
||||
openBrowser: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function send(key: string, action: 'down' | 'up') {
|
||||
props.iframeRef?.contentWindow?.postMessage(
|
||||
{ type: 'arcade-input', key, player: props.player ?? 1, action },
|
||||
@ -152,10 +177,39 @@ function tap(key: string) { send(key, 'down'); setTimeout(() => send(key, 'up'),
|
||||
.gamepad-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-utility {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.util-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.util-btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.meta-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 12px;
|
||||
|
||||
@ -2,6 +2,47 @@ import type { MarketplaceApp } from './types'
|
||||
|
||||
const R = 'git.tx1138.com/lfg2025'
|
||||
|
||||
// ---------- Dynamic catalog from registry ----------
|
||||
export interface CatalogFeatured {
|
||||
id: string
|
||||
banner: string
|
||||
headline: string
|
||||
description: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface AppCatalog {
|
||||
version: number
|
||||
registry: string
|
||||
featured: CatalogFeatured
|
||||
apps: MarketplaceApp[]
|
||||
}
|
||||
|
||||
let cachedCatalog: AppCatalog | null = null
|
||||
|
||||
/** Fetch catalog.json (served by nginx, can be updated independently of the build).
|
||||
* Returns null if fetch fails — caller should fall back to hardcoded list. */
|
||||
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
if (cachedCatalog) return cachedCatalog
|
||||
try {
|
||||
const res = await fetch('/catalog.json', { signal: AbortSignal.timeout(5000) })
|
||||
if (!res.ok) return null
|
||||
const data = await res.json() as AppCatalog
|
||||
// Expand short docker image refs to full registry paths
|
||||
const registry = data.registry || R
|
||||
for (const app of data.apps) {
|
||||
if (app.dockerImage && !app.dockerImage.includes('/')) {
|
||||
app.dockerImage = `${registry}/${app.dockerImage}`
|
||||
}
|
||||
}
|
||||
cachedCatalog = data
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Hardcoded fallback (used when catalog.json is unavailable) ----------
|
||||
export function getCuratedAppList(): MarketplaceApp[] {
|
||||
return [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||
@ -27,13 +68,13 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.webp', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
||||
{ id: 'nostr-rs-relay', title: 'Nostr Relay', version: '0.9.0', category: 'nostr', description: 'Your own Nostr relay. Store events locally, relay for friends, publish over Tor.', icon: '/assets/img/app-icons/nostr-rs-relay.svg', author: 'scsiblade', dockerImage: `${R}/nostr-rs-relay:0.9.0`, repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/' },
|
||||
{ id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: 'git.tx1138.com/lfg2025/indeedhub:latest', repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||
{ id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },
|
||||
{ id: 'nostr-vpn', title: 'Nostr VPN', version: '0.3.7', category: 'networking', description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.', icon: '/assets/img/app-icons/nostr-vpn.svg', author: 'Martti Malmi', dockerImage: `${R}/nostr-vpn:v0.3.7`, repoUrl: 'https://github.com/mmalmi/nostr-vpn' },
|
||||
{ id: 'fips', title: 'FIPS', version: '0.1.0', category: 'networking', description: 'Free Internetworking Peering System. Self-organizing encrypted mesh network with Nostr identity.', icon: '/assets/img/app-icons/fips.svg', author: 'Jim Corgan', dockerImage: `${R}/fips:v0.1.0`, repoUrl: 'https://github.com/jmcorgan/fips' },
|
||||
{ id: 'routstr', title: 'Routstr', version: '0.4.3', category: 'community', description: 'Decentralized AI inference proxy. Pay-per-request with Cashu ecash, provider discovery via Nostr.', icon: '/assets/img/app-icons/routstr.svg', author: 'Routstr', dockerImage: `${R}/routstr:v0.4.3`, repoUrl: 'https://github.com/routstr/routstr-core' },
|
||||
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
|
||||
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.0.0`, repoUrl: 'https://botfights.net' },
|
||||
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
|
||||
{ id: 'nwnn', title: 'Next Web News Network', version: '1.0.0', category: 'l484', description: 'Decentralized news aggregator. Community-curated Bitcoin and sovereignty content.', icon: '/assets/img/app-icons/nwnn.png', author: 'L484', dockerImage: '', repoUrl: 'https://nwnn.l484.com', webUrl: 'https://nwnn.l484.com' },
|
||||
{ id: '484-kitchen', title: '484 Kitchen', version: '1.0.0', category: 'l484', description: 'K484 application platform for the L484 network.', icon: '/assets/img/app-icons/484-kitchen.png', author: 'L484', dockerImage: '', repoUrl: 'https://484.kitchen', webUrl: 'https://484.kitchen' },
|
||||
{ id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', category: 'l484', description: 'Escape the Matrix — explore decentralized alternatives and reclaim sovereignty.', icon: '/assets/img/app-icons/call-the-operator.png', author: 'TX1138', dockerImage: '', repoUrl: 'https://cta.tx1138.com', webUrl: 'https://cta.tx1138.com' },
|
||||
@ -43,49 +84,68 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
]
|
||||
}
|
||||
|
||||
// Only PRIMARY containers trigger "installed" status.
|
||||
// Supporting containers (DBs, caches, workers) do NOT — having only a DB
|
||||
// without the main app should not mark the app as installed in the UI.
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
|
||||
btcpay: ['btcpay-server'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
electrumx: ['electrumx', 'archy-electrs-ui'],
|
||||
electrumx: ['electrumx'],
|
||||
grafana: ['grafana'],
|
||||
jellyfin: ['jellyfin'],
|
||||
vaultwarden: ['vaultwarden'],
|
||||
searxng: ['searxng'],
|
||||
homeassistant: ['homeassistant'],
|
||||
photoprism: ['photoprism'],
|
||||
lnd: ['lnd', 'archy-lnd-ui'],
|
||||
lnd: ['lnd'],
|
||||
filebrowser: ['filebrowser'],
|
||||
tailscale: ['tailscale'],
|
||||
ollama: ['ollama'],
|
||||
indeedhub: ['indeedhub'],
|
||||
'nostr-vpn': ['nostr-vpn'],
|
||||
fips: ['fips'],
|
||||
routstr: ['routstr'],
|
||||
botfights: ['botfights'],
|
||||
}
|
||||
|
||||
export const FEATURED_DEFINITIONS = [
|
||||
// Featured apps shown at the top of the App Store.
|
||||
// The first entry with a `banner` is displayed as a full-width hero banner.
|
||||
// To change the featured app, move the desired entry to position 0 and set its `banner`.
|
||||
export const FEATURED_DEFINITIONS: {
|
||||
id: string
|
||||
desc: string
|
||||
tag: string
|
||||
banner?: string // path to banner image (shown as full-width hero)
|
||||
}[] = [
|
||||
{
|
||||
id: 'indeedhub',
|
||||
desc: 'Bitcoin documentaries with Nostr identity. God Bless Bitcoin, The Bitcoin Psyop, and more — streaming from your own node. No accounts, no subscriptions. Sign in with Nostr.',
|
||||
tag: 'NOSTR IDENTITY // YOUR NODE',
|
||||
banner: '/assets/img/featured/indeedhub-banner.jpg',
|
||||
},
|
||||
{
|
||||
id: 'bitcoin-knots',
|
||||
desc: 'The foundation of sovereignty. Run a full Bitcoin node to validate every transaction yourself. No trusted third parties. No asking permission. Your node enforces the consensus rules that protect your wealth. Don\'t trust — verify.',
|
||||
tag: 'FULL VALIDATION // ZERO TRUST'
|
||||
tag: 'FULL VALIDATION // ZERO TRUST',
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
desc: 'Lightning-fast payments over the Lightning Network. Open channels, route transactions, and earn routing fees — all from your sovereign node. Instant settlement. Near-zero fees. The future of money, running on your hardware.',
|
||||
tag: 'INSTANT SETTLEMENT // YOUR CHANNELS'
|
||||
tag: 'INSTANT SETTLEMENT // YOUR CHANNELS',
|
||||
},
|
||||
{
|
||||
id: 'btcpay-server',
|
||||
desc: 'Accept Bitcoin payments without intermediaries. No fees to payment processors. No KYC. No permission needed. Your commerce, your terms. Self-hosted payment infrastructure that makes you truly independent.',
|
||||
tag: 'NO INTERMEDIARIES // NO KYC'
|
||||
tag: 'NO INTERMEDIARIES // NO KYC',
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
desc: 'Your passwords belong to you. Self-hosted password vault with full Bitwarden compatibility. Zero-knowledge encryption means even you can\'t see your passwords without your master key. No cloud required — your secrets, your server.',
|
||||
tag: 'ZERO KNOWLEDGE // SELF-HOSTED'
|
||||
tag: 'ZERO KNOWLEDGE // SELF-HOSTED',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
|
||||
export type FeaturedApp = MarketplaceApp & {
|
||||
featuredDescription: string
|
||||
privacyTag: string
|
||||
bannerImage?: string
|
||||
}
|
||||
|
||||
export interface InstallProgress {
|
||||
|
||||
@ -474,7 +474,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.',
|
||||
icon: '/assets/img/app-icons/botfights.svg',
|
||||
author: 'BotFights',
|
||||
dockerImage: `${REGISTRY}/botfights:1.0.0`,
|
||||
dockerImage: `${REGISTRY}/botfights:1.1.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://botfights.net',
|
||||
},
|
||||
|
||||
@ -213,12 +213,29 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isMediaType(pItem.mime_type)"
|
||||
v-if="isMediaType(pItem.mime_type) && getItemPrice(pItem.access) === 0"
|
||||
@click="streamPeerContent(pItem)"
|
||||
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
|
||||
>
|
||||
{{ t('web5.stream') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="getItemPrice(pItem.access) > 0"
|
||||
@click="purchaseAndDownload(pItem)"
|
||||
:disabled="purchasingId === pItem.id"
|
||||
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
|
||||
>
|
||||
<template v-if="purchasingId === pItem.id">
|
||||
<div class="w-3 h-3 border-2 border-orange-400/30 border-t-orange-400 rounded-full animate-spin"></div>
|
||||
Paying...
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Buy {{ getItemPrice(pItem.access) }} sats
|
||||
</template>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="downloadPeerContent(pItem)"
|
||||
@ -370,6 +387,9 @@ const browsingPeerContent = ref(false)
|
||||
const browsePeerError = ref('')
|
||||
const peerContentItems = ref<PeerContentItem[]>([])
|
||||
|
||||
// Purchase flow
|
||||
const purchasingId = ref<string | null>(null)
|
||||
|
||||
// Streaming player
|
||||
const streamingItem = ref<PeerContentItem | null>(null)
|
||||
const streamUrl = ref('')
|
||||
@ -513,6 +533,53 @@ function downloadPeerContent(item: PeerContentItem) {
|
||||
safeClipboardWrite(url)
|
||||
}
|
||||
|
||||
async function purchaseAndDownload(item: PeerContentItem) {
|
||||
if (!browsePeerOnion.value || purchasingId.value) return
|
||||
const price = getItemPrice(item.access)
|
||||
if (price <= 0) return
|
||||
|
||||
purchasingId.value = item.id
|
||||
try {
|
||||
// Check balance first
|
||||
try {
|
||||
const balRes = await rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
|
||||
const balance = balRes?.balance_sats ?? 0
|
||||
if (balance < price) {
|
||||
emit('toast', `Insufficient ecash balance (${balance} sats). Need ${price} sats.`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Balance check failed — try the purchase anyway
|
||||
}
|
||||
|
||||
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
if (result?.data) {
|
||||
const blob = new Blob(
|
||||
[Uint8Array.from(atob(result.data), c => c.charCodeAt(0))],
|
||||
{ type: item.mime_type },
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = item.filename.split('/').pop() || item.filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
emit('toast', `Downloaded for ${price} sats`)
|
||||
} else {
|
||||
emit('toast', 'Purchase failed — no data received')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
emit('toast', e instanceof Error ? e.message : 'Purchase failed')
|
||||
} finally {
|
||||
purchasingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function closePlayer() {
|
||||
if (audioPlayerRef.value) {
|
||||
audioPlayerRef.value.pause()
|
||||
|
||||
@ -265,6 +265,7 @@ load_spec_fedimint() {
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/fedimint"
|
||||
SPEC_DEPENDS="bitcoin-knots"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_fedimint-gateway() {
|
||||
@ -275,10 +276,11 @@ load_spec_fedimint-gateway() {
|
||||
SPEC_PORTS="8176:8176"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/fedimint-gateway:/data"
|
||||
SPEC_MEMORY="$(mem_limit fedimint-gateway)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8175/ || exit 1"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8176/ || exit 1"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/fedimint-gateway"
|
||||
SPEC_DEPENDS="bitcoin-knots fedimint"
|
||||
SPEC_OPTIONAL="true"
|
||||
# Custom entrypoint depends on whether LND is available
|
||||
local LND_CERT=/var/lib/archipelago/lnd/tls.cert
|
||||
local LND_MAC=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
|
||||
@ -321,6 +323,7 @@ load_spec_homeassistant() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/home-assistant"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_grafana() {
|
||||
@ -338,6 +341,7 @@ load_spec_grafana() {
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/grafana"
|
||||
SPEC_DATA_UID="100472:100472"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_uptime-kuma() {
|
||||
@ -352,6 +356,7 @@ load_spec_uptime-kuma() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/uptime-kuma"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_jellyfin() {
|
||||
@ -365,6 +370,7 @@ load_spec_jellyfin() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/jellyfin"
|
||||
SPEC_CAPS=""
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_photoprism() {
|
||||
@ -379,6 +385,7 @@ load_spec_photoprism() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/photoprism"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_vaultwarden() {
|
||||
@ -392,6 +399,7 @@ load_spec_vaultwarden() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/vaultwarden"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_nextcloud() {
|
||||
@ -405,6 +413,7 @@ load_spec_nextcloud() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/nextcloud"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_searxng() {
|
||||
@ -420,6 +429,7 @@ load_spec_searxng() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_CAPS=""
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/searxng"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_onlyoffice() {
|
||||
@ -431,6 +441,7 @@ load_spec_onlyoffice() {
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_filebrowser() {
|
||||
@ -444,6 +455,7 @@ load_spec_filebrowser() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/filebrowser"
|
||||
SPEC_CAPS=""
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_nginx-proxy-manager() {
|
||||
@ -457,6 +469,7 @@ load_spec_nginx-proxy-manager() {
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/nginx-proxy-manager"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_portainer() {
|
||||
@ -469,6 +482,7 @@ load_spec_portainer() {
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:9000/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/portainer"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_ollama() {
|
||||
|
||||
@ -72,9 +72,12 @@ FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:latest"
|
||||
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
|
||||
|
||||
# Community / Gaming
|
||||
BOTFIGHTS_IMAGE="$ARCHY_REGISTRY/botfights:1.0.0"
|
||||
BOTFIGHTS_IMAGE="$ARCHY_REGISTRY/botfights:1.1.0"
|
||||
|
||||
# IndeedHub stack (local builds use :local tag, not :latest)
|
||||
# IndeedHub stack
|
||||
INDEEDHUB_IMAGE="$ARCHY_REGISTRY/indeedhub:1.0.0"
|
||||
INDEEDHUB_API_IMAGE="$ARCHY_REGISTRY/indeedhub-api:1.0.0"
|
||||
INDEEDHUB_FFMPEG_IMAGE="$ARCHY_REGISTRY/indeedhub-ffmpeg:1.0.0"
|
||||
MINIO_IMAGE="$ARCHY_REGISTRY/minio:RELEASE.2024-11-07T00-52-20Z"
|
||||
INDEEDHUB_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:16.13-alpine"
|
||||
INDEEDHUB_REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8-alpine"
|
||||
|
||||
@ -211,8 +211,16 @@ reconcile() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Optional/local images: skip if image doesn't exist and container doesn't exist
|
||||
if [ "$SPEC_OPTIONAL" = "true" ] || [ "$SPEC_LOCAL_IMAGE" = "true" ]; then
|
||||
# Optional apps: only reconcile if already installed (container exists).
|
||||
# The install RPC creates the container; the reconciler just keeps it running.
|
||||
if [ "$SPEC_OPTIONAL" = "true" ] && ! container_exists "$name"; then
|
||||
skip "$name — not installed"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
# Local images: skip if image doesn't exist and container doesn't exist
|
||||
if [ "$SPEC_LOCAL_IMAGE" = "true" ]; then
|
||||
if ! image_exists "$SPEC_IMAGE" && ! container_exists "$name"; then
|
||||
skip "$name — image not available"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user