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:
Dorian
2026-04-11 23:11:41 -04:00
parent 24f122f35a
commit bb14490fb7
23 changed files with 782 additions and 75 deletions

View File

@@ -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:

View File

@@ -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")))
}
}
}
}

View File

@@ -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

View File

@@ -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,
}))
}
}

View File

@@ -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![],

View File

@@ -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()

View File

@@ -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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View 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" }
]
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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">

View File

@@ -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>

View File

@@ -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
})

View File

@@ -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;

View File

@@ -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',
},
]

View File

@@ -10,6 +10,7 @@ export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
export type FeaturedApp = MarketplaceApp & {
featuredDescription: string
privacyTag: string
bannerImage?: string
}
export interface InstallProgress {

View File

@@ -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',
},

View File

@@ -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()

View File

@@ -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() {

View File

@@ -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"

View File

@@ -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))