chore: snapshot release workspace

This commit is contained in:
archipelago 2026-06-12 03:00:15 -04:00
parent 6a30ff11bd
commit d6f108d818
76 changed files with 792 additions and 3613 deletions

View File

@ -1,9 +1,21 @@
# Changelog
## v1.7.85-alpha (2026-06-12)
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
## v1.7.84-alpha (2026-06-11)
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and sendrawtransaction-focused whitelist.
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.

View File

@ -122,7 +122,7 @@ echo ""
# Install custom app dependencies
echo "Installing custom app dependencies..."
for app in did-wallet endurain morphos-server router web5-dwn; do
for app in did-wallet endurain morphos-server router; do
if [ -d "apps/$app" ]; then
echo " - Installing $app dependencies..."
cd "apps/$app"

View File

@ -20,8 +20,8 @@
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian and gateway
### Self-Hosted Apps (30)
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Self-Hosted Apps (29)
Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key)

View File

@ -425,7 +425,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:latest",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [

View File

@ -8,7 +8,6 @@
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
| btcpay-server | 23000 (HTTP) | v1.13.5 |
| thunderhub | 3010 (HTTP) | v0.13.31 |
| mempool | 4080 (HTTP) | v2.5.0 |
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
@ -43,7 +42,7 @@ cd apps
./build.sh <app-id> # Build specific app
```
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
## App Structure

View File

@ -24,7 +24,6 @@ This document lists all port assignments for Archipelago apps.
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| web5-dwn | 3000 | TCP | HTTP API | 13000 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
## Development Ports (Offset: +10000)
@ -53,7 +52,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| Strfry | http://localhost:18082 |
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Web5 DWN | http://localhost:13000 |
| Meshtastic | http://localhost:14403 |
## Port Conflict Resolution

View File

@ -30,14 +30,13 @@ cd apps
./build.sh
```
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet, web5-dwn) will be built from source.
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
### Build Specific App
```bash
./build.sh router
./build.sh did-wallet
./build.sh web5-dwn
```
## Running Apps via Archipelago
@ -64,7 +63,6 @@ In development mode, apps are accessible on offset ports:
- **Router**: http://localhost:18084
- **DID Wallet**: http://localhost:18083
- **Web5 DWN**: http://localhost:13000
- **Nostr RS Relay**: http://localhost:18081
- **Strfry**: http://localhost:18082
@ -72,7 +70,7 @@ See [PORTS.md](./PORTS.md) for complete port mapping.
## Development Workflow
### For Custom Apps (router, did-wallet, web5-dwn)
### For Custom Apps (router, did-wallet)
1. **Make changes** to source code in `apps/<app-id>/src/`
2. **Rebuild** the container:

View File

@ -8,7 +8,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **bitcoin-knots** — Full Bitcoin node (v28.1)
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
- **btcpay-server** — Payment processor (v1.13.5)
- **thunderhub** — Lightning management UI (v0.13.31)
- **mempool** — Block explorer and fee estimator (v2.5.0)
- **electrumx** — Electrum server
- **fedimint** — Federated Bitcoin minting (v0.10.0)
@ -18,7 +17,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **nostrudel** — Nostr web client (v0.40.0)
### Web5 & Identity
- **web5-dwn** — Decentralized Web Node (v0.4.0)
- **did-wallet** — Web5 DID Wallet
### Self-Hosted Services

View File

@ -33,7 +33,7 @@ app:
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee";
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";

View File

@ -33,7 +33,7 @@ app:
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee";
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";

View File

@ -10,8 +10,6 @@ app:
pull_policy: if-not-present
dependencies:
- app_id: web5-dwn
version: ">=1.0.0"
- storage: 2Gi
resources:
@ -40,7 +38,6 @@ app:
options: [rw]
environment:
- DWN_ENDPOINT=http://web5-dwn:3000
- WALLET_STORAGE=/app/wallet
health_check:

View File

@ -34,5 +34,4 @@ app.post('/api/wallet/did/create', async (req, res) => {
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
console.log(`DWN endpoint: ${process.env.DWN_ENDPOINT || 'http://web5-dwn:3000'}`);
});

View File

@ -25,7 +25,7 @@ app:
resources:
cpu_limit: 0
memory_limit: 4Gi
memory_limit: 6Gi
disk_limit: 50Gi
security:
@ -48,7 +48,7 @@ app:
- COIN=Bitcoin
- DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=3072
- CACHE_MB=1024
- MAX_SEND=10000000
health_check:

View File

@ -6,7 +6,7 @@ app:
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:latest
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
pull_policy: if-not-present
data_uid: "1000:1000"

View File

@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md

View File

@ -1,38 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
ENV DWN_STORAGE_PATH=/app/data
ENV DID_METHOD=key
CMD ["node", "dist/index.js"]

View File

@ -1,35 +0,0 @@
# Web5 DWN (Decentralized Web Node)
Personal data store for Web5. Store and sync your decentralized data across devices.
## Building
```bash
# From the apps directory
./build.sh web5-dwn
# Or manually
cd web5-dwn
docker build -t archipelago/web5-dwn:latest .
```
## Development
```bash
cd web5-dwn
npm install
npm run dev
```
## Ports
- **3000**: HTTP API (dev: 13000)
## Running Locally
```bash
docker run -p 3000:3000 \
-v /tmp/archipelago-dev/web5-dwn:/app/data \
-e DWN_STORAGE_PATH=/app/data \
archipelago/web5-dwn:latest
```

View File

@ -1,55 +0,0 @@
app:
id: web5-dwn
name: Decentralized Web Node
version: 1.0.0
description: Personal data store for Web5. Store and sync your decentralized data across devices.
container:
image: archipelago/web5-dwn:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: web5-dwn
ports:
- host: 3000
container: 3000
protocol: tcp # HTTP API
volumes:
- type: bind
source: /var/lib/archipelago/web5-dwn
target: /app/data
options: [rw]
environment:
- DWN_STORAGE_PATH=/app/data
- DID_METHOD=key
health_check:
type: http
endpoint: http://localhost:3000
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
dwn_protocol: true
sync_enabled: true

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +0,0 @@
{
"name": "web5-dwn",
"version": "1.0.0",
"description": "Decentralized Web Node for Web5",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}

View File

@ -1,34 +0,0 @@
import express from 'express';
const app = express();
const port = 3000;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'web5-dwn' });
});
// DWN API endpoints
app.post('/dwn', async (req, res) => {
// Placeholder for DWN protocol implementation
res.json({
status: 'ok',
message: 'DWN protocol endpoint (placeholder)'
});
});
app.get('/dwn', async (req, res) => {
res.json({
status: 'ok',
message: 'DWN query endpoint (placeholder)'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`Web5 DWN listening on port ${port}`);
console.log(`Storage path: ${process.env.DWN_STORAGE_PATH || '/app/data'}`);
});

View File

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -227,6 +227,9 @@ impl RpcHandler {
let report = serde_json::json!({
"node_id": node_id,
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
"hostname": system_hostname().await,
"server_url": local_server_url(&self.config.host_ip),
"version": data.server_info.version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
@ -507,3 +510,24 @@ impl RpcHandler {
}))
}
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
fn local_server_url(host_ip: &str) -> Option<String> {
let host_ip = host_ip.trim();
if host_ip.is_empty() || host_ip == "127.0.0.1" {
None
} else {
Some(format!("https://{host_ip}"))
}
}

View File

@ -659,8 +659,8 @@ async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredential
}
};
let rpcauth = match read_trimmed(&rpcauth_path).await {
Some(value) => value,
None => {
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
_ => {
let generated = generate_rpcauth(TXRELAY_USER, &password);
write_secret_file(&rpcauth_path, &generated).await?;
generated
@ -729,6 +729,24 @@ fn generate_rpcauth(username: &str, password: &str) -> String {
format!("{username}:{salt_hex}${hash_hex}")
}
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
return false;
};
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
return false;
};
if salt_hex.is_empty() || expected_hash.is_empty() {
return false;
}
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
return false;
};
mac.update(password.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
hash_hex.eq_ignore_ascii_case(expected_hash)
}
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
if settings.allow_https {
if let Some(endpoint) = settings.https_endpoint.clone() {

View File

@ -731,7 +731,6 @@ fn health_probe_url_for_app(app_id: &str) -> Option<String> {
"bitcoin-ui" => 8334,
"botfights" => 9100,
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
"dwn" => 3100,
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
"fedimint" | "fedimintd" => 8175,
"filebrowser" => 8083,

View File

@ -31,7 +31,7 @@ impl RpcHandler {
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
.unwrap_or("");
// Validate SSID (prevent command injection)
if ssid.len() > 64 || ssid.contains('\0') {
@ -284,7 +284,7 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
let networks: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ':').collect();
let parts = split_nmcli_escaped(line, 3);
if parts.len() < 3 {
return None;
}
@ -305,6 +305,28 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
Ok(networks)
}
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(next) = chars.next() {
current.push(next);
}
} else if ch == ':' && fields.len() + 1 < limit {
fields.push(current);
current = String::new();
} else {
current.push(ch);
}
}
fields.push(current);
fields
}
/// Connect to a WiFi network using nmcli.
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
let conn_name = format!("archipelago-wifi-{ssid}");
@ -321,8 +343,7 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
.output()
.await;
let output = tokio::process::Command::new("nmcli")
.args([
let mut args = vec![
"connection",
"add",
"type",
@ -333,15 +354,17 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
"*",
"ssid",
ssid,
"wifi-sec.key-mgmt",
"wpa-psk",
"wifi-sec.psk",
password,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
])
];
if !password.is_empty() {
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
}
let output = tokio::process::Command::new("nmcli")
.args(args)
.output()
.await
.context("Failed to run nmcli wifi profile create")?;

View File

@ -13,18 +13,33 @@ impl RpcHandler {
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
.query(&[("type", "WITNESS_PUBKEY_HASH")])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse newaddress response")?;
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
anyhow::bail!("LND could not generate an address: {}", error);
if !status.is_success() {
let message = lnd_error_message(&body);
anyhow::bail!(
"LND could not generate a Bitcoin address ({}): {}",
status,
message
);
}
if let Some(error) = body
.get("error")
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
{
anyhow::bail!("LND could not generate a Bitcoin address: {}", error);
}
let address = body
@ -548,3 +563,35 @@ impl RpcHandler {
}))
}
}
fn lnd_error_message(body: &serde_json::Value) -> String {
body.get("message")
.or_else(|| body.get("error"))
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("unknown LND error")
.to_string()
}
#[cfg(test)]
mod tests {
use super::lnd_error_message;
#[test]
fn lnd_error_message_prefers_message_field() {
let body = serde_json::json!({
"error": "grpc proxy error",
"message": "wallet locked",
});
assert_eq!(lnd_error_message(&body), "wallet locked");
}
#[test]
fn lnd_error_message_falls_back_to_unknown() {
assert_eq!(
lnd_error_message(&serde_json::json!({})),
"unknown LND error"
);
}
}

View File

@ -312,11 +312,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"dwn" => (
"curl -sf http://localhost:3000/health || exit 1",
"30s",
"3",
),
"portainer" => return vec![],
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
@ -360,10 +355,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: large cache materially speeds initial history indexing.
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
// socket buffers, and reorg/indexing spikes.
"electrumx" | "mempool-electrs" | "electrs" => "4g",
// ElectrumX indexing spikes above its cache size due Python,
// RocksDB, socket buffers, and reorg/history work. Keep cache
// conservative and give the process headroom to avoid restart loops.
"electrumx" | "mempool-electrs" | "electrs" => "6g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
@ -384,7 +379,6 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"uptime-kuma" => "256m",
"filebrowser" => "256m",
"searxng" => "512m",
"dwn" => "256m",
"portainer" => "256m",
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
@ -789,11 +783,9 @@ pub(super) async fn get_app_config(
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB; the container gets
// 4g (config.rs::get_memory_limit) so 3072 fits with
// headroom.
"CACHE_MB=3072".to_string(),
// Keep cache below the container limit; high values
// have caused OOM/restart loops during catch-up.
"CACHE_MB=1024".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
@ -1129,18 +1121,6 @@ pub(super) async fn get_app_config(
None,
)
}
"dwn" => (
vec!["3100:3000".to_string()],
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
vec![
"DS_PORT=3000".to_string(),
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
"DS_DATA_STORE_URI=level://data/data".to_string(),
"DS_EVENT_LOG_URI=level://data/events".to_string(),
],
None,
None,
),
"botfights" => {
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
(

View File

@ -133,6 +133,10 @@ impl RpcHandler {
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_err() {
anyhow::bail!("git/self-build updates are disabled; use manifest OTA updates instead");
}
let script = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)

View File

@ -276,7 +276,6 @@ fn get_app_tier(app_id: &str) -> &'static str {
"core"
}
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
"dwn" => "core",
"filebrowser" => "core",
// Recommended: enhanced functionality
"fedimint" | "fedimint-gateway" => "recommended",
@ -518,13 +517,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/indeedhub/indeedhub".to_string(),
tier: "",
},
"dwn" => AppMetadata {
title: "Decentralized Web Node".to_string(),
description: "Store and sync personal data with DID-based access control".to_string(),
icon: "/assets/img/app-icons/dwn.svg".to_string(),
repo: "https://github.com/TBD54566975/dwn-server".to_string(),
tier: "",
},
"tor" | "archy-tor" => AppMetadata {
title: "Tor".to_string(),
description: "Anonymous overlay network for privacy".to_string(),

View File

@ -187,9 +187,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// DWN
"dwn" => Some("DWN_SERVER_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),

View File

@ -66,7 +66,9 @@ pub async fn sync_with_peer(
// hop. Only runs when the source is Trusted — Observer-level peers
// don't get to expand our federation on their own authority.
if peer.trust_level == TrustLevel::Trusted {
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
if let Err(e) =
merge_transitive_peers(data_dir, &peer.did, local_did, &state.federated_peers).await
{
tracing::warn!(
peer_did = %peer.did,
error = %e,
@ -109,6 +111,7 @@ pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<No
async fn merge_transitive_peers(
data_dir: &std::path::Path,
source_did: &str,
local_did: &str,
hints: &[FederationPeerHint],
) -> Result<()> {
if hints.is_empty() {
@ -119,8 +122,9 @@ async fn merge_transitive_peers(
let mut refreshed = 0u32;
for hint in hints {
// Don't import our own DID (a peer advertising us back).
if hint.did == source_did {
// Don't import the source peer advertising itself, or our own DID
// when the source advertises us back as one of its trusted peers.
if hint.did == source_did || hint.did == local_did {
continue;
}
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
@ -359,4 +363,69 @@ mod tests {
Some("npub1a")
);
}
#[tokio::test]
async fn merge_transitive_peers_skips_source_and_local_node() {
let dir = tempfile::tempdir().unwrap();
super::super::storage::save_nodes(
dir.path(),
&[FederatedNode {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
trust_level: TrustLevel::Trusted,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
}],
)
.await
.unwrap();
merge_transitive_peers(
dir.path(),
"did:key:zSource",
"did:key:zLocal",
&[
FederationPeerHint {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zLocal".into(),
pubkey: "bb".into(),
onion: "local.onion".into(),
name: Some("Local".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zPeer".into(),
pubkey: "cc".into(),
onion: "peer.onion".into(),
name: Some("Kitchen".into()),
fips_npub: Some("npub1peer".into()),
},
],
)
.await
.unwrap();
let nodes = super::super::storage::load_nodes(dir.path()).await.unwrap();
assert_eq!(nodes.len(), 2);
assert!(nodes.iter().all(|n| n.did != "did:key:zLocal"));
let peer = nodes
.iter()
.find(|n| n.did == "did:key:zPeer")
.expect("trusted transitive peer should be added");
assert_eq!(peer.name.as_deref(), Some("Kitchen"));
assert_eq!(peer.trust_level, TrustLevel::Trusted);
assert_eq!(peer.fips_npub.as_deref(), Some("npub1peer"));
}
}

View File

@ -71,7 +71,7 @@ async fn build_telemetry_report(
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count, containers) =
let (node_id, node_name, version, container_count, running_count, peer_count, containers) =
if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
@ -98,6 +98,10 @@ async fn build_telemetry_report(
.count();
(
id,
data.server_info
.name
.clone()
.filter(|n| !n.trim().is_empty()),
data.server_info.version.clone(),
data.package_data.len(),
running,
@ -107,6 +111,7 @@ async fn build_telemetry_report(
} else {
(
"unknown".to_string(),
None,
"unknown".to_string(),
0,
0,
@ -125,6 +130,8 @@ async fn build_telemetry_report(
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or(0);
let hostname = system_hostname().await;
let server_url = local_server_url(data_dir).await;
// Latest metrics snapshot
let latest = store.latest().await;
@ -166,6 +173,9 @@ async fn build_telemetry_report(
Ok(serde_json::json!({
"node_id": node_id,
"node_name": node_name,
"hostname": hostname,
"server_url": server_url,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
@ -181,6 +191,35 @@ async fn build_telemetry_report(
}))
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
async fn local_server_url(data_dir: &std::path::Path) -> Option<String> {
let _ = data_dir;
let output = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let ip = String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.find(|ip| !ip.starts_with("127.") && ip.contains('.'))?
.to_string();
Some(format!("https://{ip}"))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()

View File

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::fs;
use tracing::{debug, info};
use tracing::{debug, info, warn};
/// Live download progress counters. Updated by download_component_resumable
/// as bytes arrive and read by the update.status RPC so the UI can show
@ -502,6 +502,8 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
.context("Reading update state")?;
let mut state: UpdateState = serde_json::from_str(&data).context("Parsing update state")?;
let mut changed = false;
// Keep current_version in sync with the binary. Sideloaded nodes
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
// so without this the running 1.7.0-alpha binary would keep seeing
@ -517,11 +519,36 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
// if there's genuinely something newer.
state.available_update = None;
state.manifest_mirror = None;
changed = true;
}
// `update_in_progress` means a manifest OTA is downloaded and staged,
// ready for apply. Older git/self-build update paths could leave this
// flag stuck true without a staging directory, which traps the UI in an
// unrecoverable state. Heal that on every state load.
if state.update_in_progress && !has_staged_update(data_dir).await {
warn!(
staging = %data_dir.join("update-staging").display(),
"Clearing stale update_in_progress without staged OTA files"
);
state.update_in_progress = false;
changed = true;
}
if changed {
save_state(data_dir, &state).await?;
}
Ok(state)
}
async fn has_staged_update(data_dir: &Path) -> bool {
let staging_dir = data_dir.join("update-staging");
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
return false;
};
matches!(entries.next_entry().await, Ok(Some(_)))
}
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
let path = data_dir.join(UPDATE_STATE_FILE);
let data = serde_json::to_string_pretty(state)?;
@ -1764,6 +1791,11 @@ mod tests {
#[tokio::test]
async fn test_save_and_load_state_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join("archipelago"), b"staged")
.await
.unwrap();
let state = UpdateState {
current_version: "1.0.0".to_string(),
last_check: Some("2025-06-15T12:00:00Z".to_string()),
@ -1800,6 +1832,22 @@ mod tests {
assert!(loaded.available_update.is_none());
}
#[tokio::test]
async fn test_load_state_clears_stale_in_progress_without_staging() {
let dir = tempfile::tempdir().unwrap();
let state = UpdateState {
update_in_progress: true,
..UpdateState::default()
};
save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
assert!(!loaded.update_in_progress);
let persisted = load_state(dir.path()).await.unwrap();
assert!(!persisted.update_in_progress);
}
#[tokio::test]
async fn test_dismiss_update_clears_available() {
let dir = tempfile::tempdir().unwrap();

View File

@ -123,7 +123,6 @@ impl PodmanClient {
"immich_server" | "immich" => "http://localhost:2283",
"nginx-proxy-manager" => "http://localhost:8081",
"fedimint-gateway" => "http://localhost:8176",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"netbird" => "http://localhost:8087",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",

View File

@ -1,6 +1,6 @@
# 1.8-alpha Improvements Tracker
Last updated: 2026-06-11 00:17 EDT
Last updated: 2026-06-12 01:15 EDT
This tracks the user-facing improvement list that must land with the `1.8-alpha`
container migration release and the next ISO cut produced from that release. It
@ -116,6 +116,19 @@ header gap; and removed the My Apps desktop category dropdown. Focused
Marketplace/App config tests, type-check, and scoped `git diff --check` passed.
Browser smoke against the already-running local Vite/mock session is still next.
Active-session update, 2026-06-12 01:15 EDT: system update UX hardening landed
locally. `load_state()` now clears stale `update_in_progress` when no staged OTA
files exist, so failed legacy update attempts cannot leave the update screen
permanently stuck. Direct `update.git-apply` is gated behind
`ARCHIPELAGO_GIT_UPDATES`, preventing production nodes from accidentally entering
the local git/self-build path that requires `cargo`. `.116` was recovered from a
failed self-build attempt by applying its already-staged manifest OTA; it is now
on `1.7.84-alpha`, backend health is OK, nginx is active/config-valid, HTTP UI
returns `200`, `update_in_progress=false`, and staging was removed. Validation:
`cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`
passed; focused `cargo test` was blocked by a local `rust-lld` undefined hidden
symbol linker failure unrelated to the updater patch.
Done criteria for this tracker:
- Code/UI items: implemented, covered by targeted test or manual smoke check,
@ -148,6 +161,7 @@ Done criteria for this tracker:
| Fix BTCPay issue from desktop file "BTCPay Issues" | blocked | Need file contents or path to that desktop artifact. |
| Check Nostr Discoverable Nodes and get it working correctly | in-progress | Discover modal now keeps discovered rows visible during relay refresh/failure and shows `Searching relays...` instead of dropping to an empty state. Covered by `DiscoverModal.test.ts`, local type-check, and `git diff --check`. Needs live relay/trust validation before marking done. |
| Make sure update password is working properly | done | Backend now returns separate SSH update status so a successful web password change is not reported as a full failure when optional SSH password update fails. Settings modal shows success plus SSH warning and stays open for review. Covered by local type-check, focused modal/RPC tests, auth unit test, `cargo check -p archipelago`, and `git diff --check`. |
| Prevent System Update screen from getting permanently stuck | done | Update state loading now reconciles `update_in_progress` with the actual manifest OTA staging directory and clears stale stuck state when no staged files exist. Direct git/self-build apply is disabled unless `ARCHIPELAGO_GIT_UPDATES` is explicitly set, so production nodes cannot fall into the old `self-update.sh` path that requires local `cargo`. `.116` was recovered by applying its valid staged manifest OTA and verified on `1.7.84-alpha` with backend health OK, nginx active/config-valid, HTTP UI `200`, `update_in_progress=false`, and staging removed. Validated locally with `cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`; focused `cargo test` was blocked by a local `rust-lld` linker artifact failure unrelated to the updater patch. |
| Do UI performance and general performance improvements | todo | Needs profiling target; start with obvious loading/render issues. |
| Make sure companion app is all working well, had issues with tab apps | in-progress | Mobile app-session now keeps apps that require a new tab inside the session fallback instead of auto-opening an external tab and closing immediately. Covered by `AppSessionMobileNewTab.test.ts`, existing app-session config tests, app launcher tests, local type-check, and `git diff --check`. Broader companion smoke test still needed before marking done. |
| Even though performance is better, on reboot/restart backend/update show checking-containers notification instead of no apps | done | My Apps now shows a dedicated `Checking containers` card when initial backend data has loaded but `server-info.status-info.containers-scanned` is still false and no apps are ready to render, instead of falling through to the no-apps empty state. A follow-up UI pass preserves the last known app list when a later scanner/backoff update reports an empty package map with `containers-scanned=false`, and shows a refresh status banner above the grid. Validated by local type-check, targeted tests, and `git diff --check`; follow-up validation passed `npm test -- --run src/views/apps/__tests__/appPackageCache.test.ts` and `npm run type-check`. |
@ -194,7 +208,7 @@ Done criteria for this tracker:
| Work on setup screens function and flows | in-progress | Onboarding setup choice now shows only usable paths: Fresh Start and Restore from Seed. Removed the disabled `Connect Existing (Coming Soon)` option, and covered default Fresh routing plus Restore routing with `OnboardingOptions.test.ts`; `useOnboarding.test.ts`, local type-check, and `git diff --check` passed. Broader onboarding/setup audit still needed before marking done. |
| Work on Easy Mode experience | in-progress | Easy Mode goal configure steps now route to their owning app/screen instead of silently completing without navigation; verify steps now expose a `Check & Continue` action; configure/info/verify actions start goal progress before completing the active step. Covered by `goalStepActions.test.ts`, existing goal store tests, local type-check, and `git diff --check`. Broader Easy Mode product scope still needed before marking done. |
| Update My Apps homescreen to show most-used apps instead of hardcoded | done | App launches are recorded locally through the app launcher, and the Home My Apps card now shows the top three installed user apps by launch count/recency with a running-app/name fallback when there is no history. Covered by `appUsage.test.ts`, existing app launcher tests, local type-check, targeted tests, and `git diff --check`. |
| Improve Full Archive Node dependent apps UX | todo | Already partly represented by Bitcoin-pruned install block; needs broader dependency UX. |
| Improve Full Archive Node dependent apps UX | in-progress | Electrum-style apps already block install on pruned Bitcoin nodes; Marketplace/App Store cards now surface an inline warning that a full archive Bitcoin node is required instead of only showing a terse `Bitcoin Pruned` button. Covered by `MarketplaceAppCard.test.ts` and local type-check. Broader dependency UX remains. |
| Fix incorrect modals that are wrong color and are not full-screen overlay | done | Custom Teleport modals that still used the old light `bg-black/10` overlay now use the same full-screen `bg-black/60` overlay treatment as BaseModal/newer modals. Verified no fixed modal overlays retain `bg-black/10`; validated by local type-check, targeted tests, and `git diff --check`. |
| Prevent modals from allowing background scroll | done | Added shared scroll-lock composable, root-level body lock, wheel/touch containment, and explicit dashboard route-panel locking. User validated the background no longer scrolls behind modal overlays. |
| Look over gamepad navigation | todo | Needs focused controller-nav pass. |
@ -206,6 +220,7 @@ Done criteria for this tracker:
| Delete app data option and uninstall warning | done | Uninstall dialogs in My Apps and App Details now include a clear warning plus a `Delete app data and reset it` choice. Leaving it off preserves app data for later reinstall; checking it passes `preserve_data=false` through `package.uninstall` so the app is fully reset. Covered by `AppsUninstallModal.test.ts`, `rpc-client.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Add App Store container with recommended apps that change to Home Screen | done | Home now shows up to three uninstalled core/recommended App Store apps and routes clicks through the existing Marketplace App Details handoff. Installed aliases are honored, so recommendations disappear once the app is installed and the app moves into normal My Apps/Home behavior. Follow-up layout polish moved Cloud back into the second card slot, moved Recommended Apps into Cloud's previous slot, and placed Quick Start inside the grid next to Wallet to avoid an odd-width row. Covered by `homeRecommendations.test.ts`, local type-check, `git diff --check`, and Playwright Home dashboard smoke against local Vite/mock backend. |
| Add QR code to download mobile companion app in login-triggered modal and improve modal | done | Companion intro modal now renders a QR code on desktop and a direct download button on mobile. It reads `VITE_COMPANION_APK_URL` and falls back to `/packages/archipelago-companion.apk.zip`; the APK zip is now published at `neode-ui/public/packages/archipelago-companion.apk.zip` so the modal can serve it immediately. Covered by local type-check, `git diff --check`, and manual file placement verification. |
| Fix TV HDMI overscan clipping in kiosk mode | in-progress | Kiosk launcher now passes a browser safe-area fallback through `/kiosk?safe_area=...`; `/kiosk` now persists the safe-area value during redirect; self-update and deploy paths refresh kiosk launcher/services. The X11 safe-area attempt is opt-in because it stretched the live TV output on `100.66.157.120`. Wi-Fi UI fixes are included in the same OTA patch: scan errors are visible, scans can be retried, escaped SSIDs parse correctly, and open networks do not require a password. Needs live validation on HDMI node `100.66.157.120` after applying the visible OTA update. |
| Video calling Picture-in-Picture | blocked | Need referenced document or desired provider/library. |
| Card-based loading visuals on App Store pages | done | Discover and Marketplace now show app-card skeleton grids while community/Nostr catalog data is loading and no cards are available yet, instead of a centered spinner/empty state. Validated by local type-check, targeted tests, and `git diff --check`. |

View File

@ -27,6 +27,7 @@ Allowed RPC methods:
```text
sendrawtransaction
submitpackage
testmempoolaccept
getmempoolinfo
getrawmempool
@ -37,6 +38,7 @@ getblockcount
getblockhash
getblockheader
getrawtransaction
gettxout
decoderawtransaction
decodescript
estimatesmartfee
@ -113,7 +115,7 @@ The Bitcoin Knots app should add the restricted user only when the secret exists
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0"
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee"
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"
fi
```
@ -245,6 +247,15 @@ curl -sS --user "$BITCOIN_RPC_TXRELAY_USER:$BITCOIN_RPC_TXRELAY_PASSWORD" \
Expected result is a Bitcoin RPC validation error such as `TX decode failed`,
which confirms the request reached `sendrawtransaction`.
If a wallet verifies the connection but reports `RPC Forbidden` during
broadcast, the credentials authenticated but the broadcast method was outside
the loaded `txrelay` whitelist. Restart the active Bitcoin backend after
updating the whitelist, then test both `sendrawtransaction` and, for newer
package-relay clients, `submitpackage`. Also confirm the public reverse proxy
passes the wallet's `Authorization` header through to `127.0.0.1:8332`; do not
point public wallet traffic at the Bitcoin UI `/bitcoin-rpc/` helper, because
that helper injects the local dashboard credential.
Check that wallet/admin RPC is blocked:
```sh

View File

@ -1,37 +1,87 @@
#!/bin/bash
# Start X server in the background
/usr/bin/Xorg :0 -nocursor vt1 -nolisten tcp -keeptty &
XPID=$!
sleep 2
# Check if X started
if ! kill -0 $XPID 2>/dev/null; then
echo 'ERROR: Xorg failed to start'
exit 1
fi
# Start a dedicated X server for the attached kiosk display.
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
XPID=$!
export DISPLAY=:0
export HOME=/home/archipelago
# Allow archipelago user to connect
xhost +SI:localuser:archipelago 2>/dev/null
X_READY=false
for _ in $(seq 1 30); do
if kill -0 "$XPID" 2>/dev/null && xrandr --query >/tmp/archipelago-kiosk-xrandr.txt 2>/dev/null; then
X_READY=true
break
fi
sleep 0.5
done
# Disable screen blanking
xset s off 2>/dev/null
xset -dpms 2>/dev/null
xset s noblank 2>/dev/null
if [ "$X_READY" != "true" ]; then
echo 'ERROR: Xorg failed to become ready'
kill "$XPID" 2>/dev/null || true
exit 1
fi
# Hide cursor
unclutter -idle 3 -root &
KIOSK_SAFE_AREA_X_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_X_PX:-}
KIOSK_SAFE_AREA_Y_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_Y_PX:-}
# Kill any stale Chromium instances before starting
pkill -u archipelago -f 'chromium.*kiosk' 2>/dev/null
configure_display() {
command -v xrandr >/dev/null 2>&1 || return 0
local output mode internal width height
output=$(awk '/ connected/ && $1 !~ /^eDP|^LVDS/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$output" ] || output=$(awk '/ connected/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$output" ] || return 0
mode=$(awk -v out="$output" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
if ($0 ~ /\*/) { print $1; exit }
if (!first) first = $1
}
active && /^[^[:space:]]/ { active = 0 }
END { if (first) print first }
' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$mode" ] || mode=1920x1080
# Kiosk should use one native output. A spanning desktop makes Chromium land
# on the laptop panel or stretch across both outputs.
for internal in $(awk '/ connected/ && $1 ~ /^eDP|^LVDS/{print $1}' /tmp/archipelago-kiosk-xrandr.txt); do
[ "$internal" = "$output" ] || xrandr --output "$internal" --off 2>/dev/null || true
done
xrandr --output "$output" \
--primary \
--mode "$mode" \
--pos 0x0 \
--scale 1x1 \
--panning 0x0 \
--transform none 2>/dev/null || true
width=${mode%x*}
height=${mode#*x}
case "$width:$height" in *[!0-9:]*|:*) width=1920; height=1080 ;; esac
# Browser safe-area fallback for TVs that crop edges. Driver underscan is
# preferable, but many Intel HDMI outputs do not expose that property.
KIOSK_SAFE_AREA_X_PX=${KIOSK_SAFE_AREA_X_PX:-$((width * 3 / 100))}
KIOSK_SAFE_AREA_Y_PX=${KIOSK_SAFE_AREA_Y_PX:-$((height * 3 / 100))}
}
configure_display
xhost +SI:localuser:archipelago 2>/dev/null || true
xsetroot -solid black 2>/dev/null || true
xset s off 2>/dev/null || true
xset -dpms 2>/dev/null || true
xset s noblank 2>/dev/null || true
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
sleep 1
# Run Chromium as archipelago user in a restart loop
while true; do
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk \
--app=http://localhost/kiosk \
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
--noerrdialogs \
--disable-infobars \
--disable-translate \
@ -45,9 +95,10 @@ while true; do
--enable-gpu-rasterization \
--num-raster-threads=2 \
--renderer-process-limit=2 \
--window-size=9999,9999 \
--window-size=1920,1080 \
--window-position=0,0 \
--start-fullscreen \
--force-device-scale-factor=1 \
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
@ -60,5 +111,4 @@ while true; do
sleep 3
done
# Cleanup
kill $XPID 2>/dev/null
kill "$XPID" 2>/dev/null || true

View File

@ -110,7 +110,7 @@ tail -f /tmp/neode-dev.log
- **Docker Optional** - Apps run for real if Docker/Podman is available, otherwise simulated
- **Auto-Detection** - Automatically detects container runtime and adapts
- **WebSocket Support** - Real-time state updates via JSON patches
- **Pre-loaded Apps** - 8 apps always visible in My Apps
- **Pre-loaded Apps** - 7 apps always visible in My Apps
### Pre-installed Apps (always running in mock mode)
- `bitcoin` - Bitcoin Core (port 8332)
@ -119,7 +119,6 @@ tail -f /tmp/neode-dev.log
- `mempool` - Blockchain explorer (port 4080)
- `filebrowser` - Web file manager (port 8083)
- `lorabell` - LoRa doorbell (no UI port)
- `thunderhub` - Lightning node management (port 3010)
- `fedimint` - Federated Bitcoin mint (port 8175)
Additional apps can be installed from the Marketplace (30+ available).
@ -226,4 +225,3 @@ The warning is non-fatal - Vite still works, but upgrading is recommended.
---
Happy coding! 🎨⚡

View File

@ -55,7 +55,7 @@ The mock backend supports multiple startup modes via `VITE_DEV_MODE`:
The mock backend (`mock-backend.js`) simulates the full Rust backend for local development:
**Pre-installed apps** (always visible in My Apps):
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, ThunderHub, Fedimint
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, Fedimint
**Marketplace**: 30+ curated apps with Docker images, install/uninstall simulation

View File

@ -102,6 +102,26 @@ const toastMessage = messageToast.toastMessage
useControllerNav()
function syncKioskSafeArea() {
if (typeof document === 'undefined') return
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
const rawSafeAreaY = localStorage.getItem('archipelago_kiosk_safe_area_y_px') || rawSafeArea
const safeAreaX = /^\d{1,3}$/.test(rawSafeAreaX) ? Number(rawSafeAreaX) : safeArea
const safeAreaY = /^\d{1,3}$/.test(rawSafeAreaY) ? Number(rawSafeAreaY) : safeArea
document.documentElement.classList.toggle('kiosk-safe-area', isKiosk && (safeAreaX > 0 || safeAreaY > 0))
if (isKiosk && (safeAreaX > 0 || safeAreaY > 0)) {
document.documentElement.style.setProperty('--kiosk-safe-area-x', `${safeAreaX}px`)
document.documentElement.style.setProperty('--kiosk-safe-area-y', `${safeAreaY}px`)
} else {
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
}
}
// Start/stop message polling and remote relay when auth state changes
watch(() => appStore.isAuthenticated, (authenticated) => {
if (authenticated) {
@ -330,6 +350,7 @@ function onVisibilityChange() {
}
onMounted(async () => {
syncKioskSafeArea()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
@ -393,6 +414,9 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
document.documentElement.classList.remove('kiosk-safe-area')
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('mousemove', onUserActivity)

View File

@ -463,9 +463,6 @@
"noIdentities": "No identities yet",
"createFirstIdentity": "Create your first sovereign digital identity.",
"deleting": "Deleting...",
"decentralizedWebNode": "Decentralized Web Node",
"dwnDescription": "Personal data store with DID-based access control",
"manageDwn": "Manage DWN",
"syncing": "Syncing...",
"syncNow": "Sync Now",
"verifiableCredentials": "Verifiable Credentials",
@ -590,6 +587,7 @@
},
"marketplaceDetails": {
"backToStore": "Back to App Store",
"backToHome": "Back to Home",
"screenshots": "Screenshots",
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
"about": "About {name}",

View File

@ -463,9 +463,6 @@
"noIdentities": "A\u00fan no hay identidades",
"createFirstIdentity": "Cree su primera identidad digital soberana.",
"deleting": "Eliminando...",
"decentralizedWebNode": "Nodo web descentralizado",
"dwnDescription": "Almac\u00e9n de datos personal con control de acceso basado en DID",
"manageDwn": "Administrar DWN",
"syncing": "Sincronizando...",
"syncNow": "Sincronizar ahora",
"verifiableCredentials": "Credenciales verificables",
@ -589,6 +586,7 @@
},
"marketplaceDetails": {
"backToStore": "Volver a la tienda",
"backToHome": "Volver al inicio",
"screenshots": "Capturas de pantalla",
"screenshotPlaceholder": "Capturas de pantalla de ejemplo \u2014 im\u00e1genes disponibles pronto",
"about": "Acerca de {name}",

View File

@ -86,11 +86,26 @@ const router = createRouter({
{
path: '/kiosk',
name: 'kiosk',
redirect: '/',
beforeEnter: () => {
component: () => import('../views/Kiosk.vue'),
beforeEnter: (to) => {
// Persist kiosk mode before redirect so App.vue can skip the remote relay
// (relay duplicates xdotool input on the kiosk display)
localStorage.setItem('kiosk', 'true')
const safeArea = to.query.safe_area
const safeAreaPx = Array.isArray(safeArea) ? safeArea[0] : safeArea
if (safeAreaPx && /^\d{1,3}$/.test(safeAreaPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_px', safeAreaPx)
}
const safeAreaX = to.query.safe_area_x
const safeAreaXPx = Array.isArray(safeAreaX) ? safeAreaX[0] : safeAreaX
if (safeAreaXPx && /^\d{1,3}$/.test(safeAreaXPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_x_px', safeAreaXPx)
}
const safeAreaY = to.query.safe_area_y
const safeAreaYPx = Array.isArray(safeAreaY) ? safeAreaY[0] : safeAreaY
if (safeAreaYPx && /^\d{1,3}$/.test(safeAreaYPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_y_px', safeAreaYPx)
}
},
},
{
@ -386,4 +401,3 @@ router.afterEach((to) => {
})
export default router

View File

@ -111,10 +111,8 @@ const PORT_TO_APP_ID: Record<string, string> = {
'4080': 'mempool',
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'7778': 'indeedhub',
'50002': 'electrumx',
'3010': 'thunderhub',
}
const APP_ID_TO_PORT: Record<string, string> = {

View File

@ -1294,6 +1294,21 @@ body {
min-height: 100dvh;
}
html.kiosk-safe-area,
html.kiosk-safe-area body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #000;
}
html.kiosk-safe-area #app {
width: 100vw;
height: 100vh;
min-height: 0;
overflow: hidden;
}
/* Custom scrollbar for glass containers */
.custom-scrollbar::-webkit-scrollbar {
width: 10px;

View File

@ -416,8 +416,8 @@ function isStartingUp(appId: string): boolean {
}
function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'

View File

@ -125,55 +125,21 @@
</div>
</div>
<!-- App Store Recommendations -->
<div
v-if="homeRecommendedApps.length > 0"
data-controller-container
tabindex="0"
class="home-card controller-focusable"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 2"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</RouterLink>
</div>
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
<button
v-for="app in homeRecommendedApps"
:key="app.id"
type="button"
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
@click="viewRecommendedApp(app)"
>
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title || app.id"
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
@error="handleImageError"
<!-- Wallet Overview -->
<HomeWalletCard
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@faucet="devFaucet"
@open-in-mempool="openInMempool"
/>
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
</div>
<span class="text-xs text-white/45 capitalize">{{ getAppTier(app.id) }}</span>
</button>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Network Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
@ -213,21 +179,54 @@
</div>
</div>
<!-- Wallet Overview -->
<HomeWalletCard
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@faucet="devFaucet"
@open-in-mempool="openInMempool"
<!-- App Store Recommendations -->
<div
v-if="homeRecommendedApps.length > 0"
data-controller-container
tabindex="0"
class="home-card controller-focusable lg:col-span-2"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 4"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</RouterLink>
</div>
<div class="home-card-stats grid grid-cols-1 md:grid-cols-3 gap-3 mb-4 flex-1 min-h-0">
<button
v-for="app in homeRecommendedApps"
:key="app.id"
type="button"
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
@click="viewRecommendedApp(app)"
>
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title || app.id"
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
@error="handleImageError"
/>
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
</div>
</button>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Quick Start Goals -->
<div
@ -299,7 +298,7 @@ import { rpcClient } from '@/api/rpc-client'
import { getAppUsage } from '@/utils/appUsage'
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { getAppTier, getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
import { getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
import { getHomeRecommendedApps } from './home/homeRecommendations'
import HomeWalletCard from './home/HomeWalletCard.vue'
import HomeSystemCard from './home/HomeSystemCard.vue'
@ -389,7 +388,7 @@ const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppL
function viewRecommendedApp(app: MarketplaceApp) {
setCurrentApp(app)
router.push({ name: 'marketplace-app-detail', params: { id: app.id } }).catch(() => {})
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'home' } }).catch(() => {})
}
function marketplaceDescription(app: MarketplaceApp) {

View File

@ -107,7 +107,6 @@ const launchableApps = computed<KioskApp[]>(() => {
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/',
'indeedhub': 'http://localhost:7778',
'botfights': 'http://localhost:9100',
'nwnn': 'https://nwnn.l484.com',
@ -172,7 +171,10 @@ onUnmounted(() => {
<style scoped>
.kiosk-root {
position: fixed;
inset: 0;
left: var(--kiosk-safe-area-x, 0px);
top: var(--kiosk-safe-area-y, 0px);
width: calc(100vw - (var(--kiosk-safe-area-x, 0px) * 2));
height: calc(100vh - (var(--kiosk-safe-area-y, 0px) * 2));
background: #000;
outline: none;
overflow: hidden;
@ -180,11 +182,12 @@ onUnmounted(() => {
}
.kiosk-launcher {
height: 100vh;
height: 100%;
display: flex;
flex-direction: column;
padding: 2rem 3rem;
padding: clamp(1rem, 3vh, 2rem) clamp(1.5rem, 4vw, 3rem);
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
box-sizing: border-box;
}
.kiosk-header {

View File

@ -5,7 +5,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ t('marketplaceDetails.backToStore') }}
{{ backButtonLabel }}
</button>
<!-- Mobile Full-Width Back Button -->
@ -20,7 +20,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ t('marketplaceDetails.backToStore') }}</span>
<span>{{ backButtonLabel }}</span>
</button>
<Transition name="content-fade" mode="out-in">
@ -397,6 +397,7 @@ const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
const bitcoinPruned = ref(false)
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const appId = computed(() => route.params.id as string)
@ -550,7 +551,9 @@ onBeforeUnmount(() => {
})
function goBack() {
if (route.query.from === 'discover') {
if (route.query.from === 'home') {
router.push('/dashboard').catch(() => {})
} else if (route.query.from === 'discover') {
router.push('/dashboard/discover').catch(() => {})
} else {
router.push('/dashboard/marketplace').catch(() => {})

View File

@ -347,6 +347,7 @@
:wifi-submitting="wifiSubmitting"
:wifi-selected-ssid="wifiSelectedSsid"
:wifi-error="wifiError"
:wifi-scan-error="wifiScanError"
:dns-selected-provider="dnsSelectedProvider"
:dns-servers="networkData.dnsServers"
:dns-applying="dnsApplying"
@ -358,6 +359,7 @@
@close-wifi="showWifiModal = false"
@select-wifi="selectWifi"
@connect-wifi="connectToWifi"
@scan-wifi="scanWifi"
@cancel-wifi-connect="wifiConnecting = false; wifiPassword = ''; wifiError = ''"
@close-dns="showDnsModal = false; dnsError = ''"
@select-dns-provider="(v: string) => { dnsSelectedProvider = v }"
@ -564,6 +566,7 @@ const wifiSubmitting = ref(false)
const wifiSelectedSsid = ref('')
const wifiPassword = ref('')
const wifiError = ref('')
const wifiScanError = ref('')
// DNS
const showDnsModal = ref(false)
@ -610,15 +613,33 @@ async function loadInterfaces() {
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
}
async function scanWifi() {
wifiScanning.value = true; wifiNetworks.value = []
try { const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' }); wifiNetworks.value = res.networks } catch { wifiNetworks.value = [] } finally { wifiScanning.value = false }
function wifiRequiresPassword(network: WifiNetwork | undefined): boolean {
const security = (network?.security || '').trim().toLowerCase()
return security.length > 0 && security !== '--' && security !== 'none' && security !== 'open'
}
function selectWifi(ssid: string) { wifiSelectedSsid.value = ssid; wifiPassword.value = ''; wifiConnecting.value = true }
async function scanWifi() {
wifiScanning.value = true; wifiNetworks.value = []; wifiScanError.value = ''; wifiError.value = ''
try {
const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' })
wifiNetworks.value = res.networks
} catch (e) {
wifiNetworks.value = []
wifiScanError.value = e instanceof Error ? e.message : 'WiFi scan failed.'
} finally { wifiScanning.value = false }
}
function selectWifi(network: WifiNetwork) {
wifiSelectedSsid.value = network.ssid; wifiPassword.value = ''; wifiError.value = ''
if (wifiRequiresPassword(network)) {
wifiConnecting.value = true
} else {
connectToWifi('')
}
}
async function connectToWifi(password: string) {
if (!password || !wifiSelectedSsid.value) return
if (!wifiSelectedSsid.value) return
wifiError.value = ''; wifiSubmitting.value = true
try {
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password } })

View File

@ -27,7 +27,6 @@ export const APP_PORTS: Record<string, number> = {
'tailscale': 8240,
'fedimintd': 8175,
'fedimint-gateway': 8176,
'dwn': 3100,
'endurain': 8080,
}

View File

@ -27,7 +27,6 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"strfry": 8082,
"uptime-kuma": 3002,
"vaultwarden": 8082,
"web5-dwn": 3000,
}
export const GENERATED_APP_TITLES: Record<string, string> = {
@ -69,7 +68,6 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"strfry": "Strfry Nostr Relay",
"uptime-kuma": "Uptime Kuma",
"vaultwarden": "Vaultwarden",
"web5-dwn": "Decentralized Web Node",
}
export const GENERATED_NEW_TAB_APPS = new Set<string>([

View File

@ -269,7 +269,7 @@ const tier = computed(() => {
const t = props.pkg.manifest?.tier
if (t && t !== '') return t
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(props.id)) return 'core'
if (recommended.includes(props.id)) return 'recommended'
return 'optional'

View File

@ -58,7 +58,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
'nostrudel': 'nostr',
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking', 'dwn': 'data',
'uptime-kuma': 'networking',
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
}

View File

@ -100,7 +100,6 @@ 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.png', 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: '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: '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.1.0`, repoUrl: 'https://botfights.net' },
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },

View File

@ -2,13 +2,21 @@
<div v-if="node" class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white/80">
Node Detail <span class="font-mono">{{ nodeId.slice(0, 8) }}</span>
Node Detail <span>{{ fleetNodeDisplayName(node) }}</span>
</h3>
<button class="glass-button text-xs px-3 py-1" @click="$emit('close')">Close</button>
</div>
<!-- Node Info Summary -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Hostname</p>
<p class="text-lg font-bold text-white truncate">{{ node.hostname || 'Unknown' }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Address</p>
<p class="text-lg font-bold text-white truncate">{{ node.server_url || nodeId.slice(0, 8) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
<p class="text-lg font-bold text-white">v{{ node.version }}</p>
@ -17,10 +25,6 @@
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
<p class="text-lg font-bold text-white">{{ formatUptime(node.uptime_secs) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">CPU Cores</p>
<p class="text-lg font-bold text-white">{{ node.cpu_cores }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Federation Peers</p>
<p class="text-lg font-bold text-white">{{ node.federation_peers }}</p>
@ -119,7 +123,7 @@
<script setup lang="ts">
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
import { type FleetNode, formatUptime, alertSeverityDot, formatTimestamp } from './useFleetData'
import { type FleetNode, formatUptime, alertSeverityDot, formatTimestamp, fleetNodeDisplayName } from './useFleetData'
defineProps<{
node: FleetNode | null

View File

@ -34,10 +34,13 @@
class="fleet-status-dot"
:class="isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
></span>
<span class="text-sm font-mono text-white">{{ node.node_id.slice(0, 8) }}</span>
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
</div>
<span class="fleet-version-badge">v{{ node.version }}</span>
</div>
<div class="mb-3 truncate text-xs text-white/40">
{{ fleetNodeSubtitle(node) }}
</div>
<div class="space-y-2 mb-3">
<div class="fleet-metric-row">
@ -91,7 +94,7 @@
<script setup lang="ts">
import {
type FleetNode, type SortOption, SORT_OPTIONS,
isOnline, healthBarClass, formatUptime, timeAgo,
isOnline, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
} from './useFleetData'
defineProps<{

View File

@ -1,9 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import { isOnline, normalizeFleetNode, normalizeNodeHistoryResponse, sortFleetNodes, type FleetNode } from '../useFleetData'
import {
fleetNodeDisplayName,
fleetNodeSubtitle,
isOnline,
normalizeFleetNode,
normalizeNodeHistoryResponse,
sortFleetNodes,
type FleetNode,
} from '../useFleetData'
function node(id: string, reportedAt: string): FleetNode {
return {
node_id: id,
node_name: null,
hostname: null,
server_url: null,
version: '1.8-alpha',
uptime_secs: 60,
cpu_cores: 4,
@ -49,10 +60,12 @@ describe('fleet data helpers', () => {
})
it('sorts by name alphabetically', () => {
expect(sortFleetNodes([
node('zulu', '2026-06-10T11:59:00Z'),
node('alpha', '2026-06-10T11:59:00Z'),
], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
const zulu = node('zulu', '2026-06-10T11:59:00Z')
zulu.node_name = 'Workshop'
const alpha = node('alpha', '2026-06-10T11:59:00Z')
alpha.node_name = 'Kitchen'
expect(sortFleetNodes([zulu, alpha], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
})
it('normalizes older telemetry reports with missing metric and container fields', () => {
@ -63,6 +76,9 @@ describe('fleet data helpers', () => {
})
expect(normalized.node_id).toBe('legacy-node')
expect(normalized.node_name).toBeNull()
expect(normalized.hostname).toBeNull()
expect(normalized.server_url).toBeNull()
expect(normalized.cpu_pct).toBe(0)
expect(normalized.mem_pct).toBe(0)
expect(normalized.disk_pct).toBe(0)
@ -70,6 +86,28 @@ describe('fleet data helpers', () => {
expect(normalized.recent_alerts).toEqual([])
})
it('uses node name, hostname, then node id for fleet display labels', () => {
const named = normalizeFleetNode({
node_id: 'abcdef123456',
node_name: 'Kitchen Node',
hostname: 'kitchen-node',
server_url: 'https://192.168.1.20',
})
const hostOnly = normalizeFleetNode({
node_id: '123456abcdef',
hostname: 'workshop-node',
server_url: 'https://192.168.1.21',
})
const idOnly = normalizeFleetNode({ node_id: 'feedfacecafebeef' })
expect(fleetNodeDisplayName(named)).toBe('Kitchen Node')
expect(fleetNodeSubtitle(named)).toBe('kitchen-node')
expect(fleetNodeDisplayName(hostOnly)).toBe('workshop-node')
expect(fleetNodeSubtitle(hostOnly)).toBe('https://192.168.1.21')
expect(fleetNodeDisplayName(idOnly)).toBe('feedface')
expect(fleetNodeSubtitle(idOnly)).toBe('feedfacecafebeef')
})
it('normalizes node history responses from backend entries or legacy history fields', () => {
const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }

View File

@ -8,6 +8,9 @@ import type { ChartDataset } from '@/components/LineChart.vue'
export interface FleetNode {
node_id: string
node_name?: string | null
hostname?: string | null
server_url?: string | null
version: string
uptime_secs: number
cpu_cores: number
@ -112,6 +115,17 @@ export function getContainerState(node: FleetNode, appId: string): string | null
return container.state
}
export function fleetNodeDisplayName(node: FleetNode): string {
const name = node.node_name?.trim() || node.hostname?.trim()
return name || node.node_id.slice(0, 8)
}
export function fleetNodeSubtitle(node: FleetNode): string {
const host = node.hostname?.trim()
if (host && host !== fleetNodeDisplayName(node)) return host
return node.server_url?.trim() || node.node_id
}
export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
{ label: 'Status', value: 'status' },
{ label: 'Last Seen', value: 'last-seen' },
@ -133,7 +147,7 @@ export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNod
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
break
case 'name':
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
sorted.sort((a, b) => fleetNodeDisplayName(a).localeCompare(fleetNodeDisplayName(b)))
break
}
return sorted
@ -146,6 +160,9 @@ function numberOrZero(value: unknown): number {
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
return {
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
node_name: typeof node.node_name === 'string' ? node.node_name : null,
hostname: typeof node.hostname === 'string' ? node.hostname : null,
server_url: typeof node.server_url === 'string' ? node.server_url : null,
version: typeof node.version === 'string' ? node.version : 'unknown',
uptime_secs: numberOrZero(node.uptime_secs),
cpu_cores: numberOrZero(node.cpu_cores),

View File

@ -7,6 +7,8 @@ const apps: MarketplaceApp[] = [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'bitcoin:latest' },
{ id: 'homeassistant', title: 'Home Assistant', dockerImage: 'homeassistant:latest' },
{ id: 'mempool', title: 'Mempool', dockerImage: 'mempool:latest' },
{ id: 'thunderhub', title: 'ThunderHub', dockerImage: 'thunderhub:latest' },
{ id: 'dwn', title: 'DWN', dockerImage: 'dwn:latest' },
{ id: 'website-only', title: 'Website Only', webUrl: 'https://example.com' },
]
@ -22,16 +24,18 @@ describe('homeRecommendations', () => {
expect(recommended.map((app) => app.id)).toEqual([
'bitcoin-knots',
'vaultwarden',
'homeassistant',
])
})
it('returns no recommendations once matching apps are installed', () => {
it('fills from optional apps once core and recommended apps are installed', () => {
const recommended = getHomeRecommendedApps(apps, {
'bitcoin-knots': {},
'mempool-web': {},
vaultwarden: {},
})
expect(recommended).toEqual([])
expect(recommended.map((app) => app.id)).toEqual(['homeassistant'])
expect(recommended.some((app) => app.id === 'dwn' || app.id === 'thunderhub')).toBe(false)
})
})

View File

@ -16,18 +16,23 @@ export function getHomeRecommendedApps(
installedPackages: InstalledPackageMap,
limit = 3,
): MarketplaceApp[] {
return apps
const candidates = apps
.filter((app) => app.id !== 'dwn' && app.id !== 'thunderhub')
.filter((app) => {
if (!app.dockerImage) return false
if (isMarketplaceAppInstalled(app.id, installedPackages)) return false
const tier = getAppTier(app.id)
return tier === 'core' || tier === 'recommended'
return true
})
.sort((a, b) => {
const tierRank = (app: MarketplaceApp) => getAppTier(app.id) === 'core' ? 0 : 1
const tierRank = (app: MarketplaceApp) => {
const tier = getAppTier(app.id)
if (tier === 'core') return 0
if (tier === 'recommended') return 1
return 2
}
const tierDiff = tierRank(a) - tierRank(b)
if (tierDiff !== 0) return tierDiff
return (a.title || a.id).localeCompare(b.title || b.id)
})
.slice(0, limit)
return candidates.slice(0, limit)
}

View File

@ -50,6 +50,10 @@
{{ typeof app.description === 'object' ? app.description.short : (app.description || 'No description available') }}
</p>
<p v-if="!installed && installBlockedReason" class="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-100">
Requires a full archive Bitcoin node before install.
</p>
<div class="flex gap-2 mt-auto">
<!-- Installed & starting up (transitional state) -->
<span

View File

@ -15,7 +15,7 @@ const app: MarketplaceApp = {
source: 'community',
}
function mountCard(installed: boolean) {
function mountCard(installed: boolean, installBlockedReason?: string) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@ -39,6 +39,7 @@ function mountCard(installed: boolean) {
startingUp: false,
containersScanned: true,
tierLabel: 'recommended',
installBlockedReason,
},
global: {
plugins: [i18n],
@ -58,4 +59,10 @@ describe('MarketplaceAppCard', () => {
expect(wrapper.find('.tier-badge').exists()).toBe(false)
expect(wrapper.text()).not.toContain('recommended')
})
it('explains archive-node-only install blocks on cards', () => {
const wrapper = mountCard(false, 'You need a full archival bitcoin node before downloading ElectrumX')
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
expect(wrapper.text()).toContain('Bitcoin Pruned')
})
})

View File

@ -72,8 +72,8 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
/** Get app tier classification (matches backend get_app_tier) */
export function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'
@ -174,17 +174,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/lightningnetwork/lnd'
},
{
id: 'thunderhub',
title: 'ThunderHub',
version: '0.13.31',
description: 'Lightning node management UI. Manage channels, send and receive payments, view routing fees, and monitor your Lightning node.',
icon: '/assets/img/app-icons/thunderhub.svg',
author: 'Anthony Potdevin',
dockerImage: 'docker.io/apotdevin/thunderhub:v0.13.31',
manifestUrl: undefined,
repoUrl: 'https://github.com/apotdevin/thunderhub'
},
{
id: 'mempool',
title: 'Mempool Explorer',
@ -405,17 +394,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/indeedhub/indeedhub'
},
{
id: 'dwn',
title: 'Decentralized Web Node',
version: '0.4.0',
description: 'Store and sync your personal data across devices using decentralized web node protocols. Own your data with DID-based access control.',
icon: '/assets/img/app-icons/dwn.svg',
author: 'TBD',
dockerImage: `${REGISTRY}/dwn-server:main`,
manifestUrl: undefined,
repoUrl: 'https://github.com/TBD54566975/dwn-server'
},
{
id: 'nostrudel',
title: 'noStrudel',

View File

@ -98,12 +98,15 @@
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
<div class="flex items-center gap-2">
<button @click="$emit('scanWifi')" :disabled="wifiScanning" class="text-xs text-white/50 hover:text-white disabled:opacity-40 transition-colors">Refresh</button>
<button @click="$emit('closeWifi')" class="text-white/40 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<template v-if="wifiScanning">
<div class="space-y-3">
<div v-for="i in 4" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-12"></div>
@ -115,7 +118,7 @@
v-for="net in wifiNetworks"
:key="net.ssid"
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
@click="$emit('selectWifi', net.ssid)"
@click="$emit('selectWifi', net)"
>
<div>
<p class="text-sm font-medium text-white">{{ net.ssid }}</p>
@ -130,6 +133,12 @@
</button>
</div>
</template>
<template v-else-if="wifiScanError">
<div class="rounded-lg border border-red-400/20 bg-red-500/10 p-4 text-sm text-red-200">
<p>{{ wifiScanError }}</p>
<button @click="$emit('scanWifi')" class="mt-3 text-white/80 hover:text-white underline underline-offset-4">Try again</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/50 text-center py-8">No networks found</p>
</template>
@ -232,6 +241,7 @@ defineProps<{
wifiSubmitting: boolean
wifiSelectedSsid: string
wifiError: string
wifiScanError: string
dnsSelectedProvider: string
dnsServers: string[]
dnsApplying: boolean
@ -244,8 +254,9 @@ defineEmits<{
createServiceForApp: [appId: string]
createService: [name: string, port: number | null]
closeWifi: []
selectWifi: [ssid: string]
selectWifi: [network: { ssid: string; signal: number; security: string }]
connectWifi: [password: string]
scanWifi: []
cancelWifiConnect: []
closeDns: []
selectDnsProvider: [provider: string]

View File

@ -105,7 +105,6 @@ import Web5NodeVisibility from './Web5NodeVisibility.vue'
import Web5ConnectedNodes from './Web5ConnectedNodes.vue'
// import Web5SharedContent from './Web5SharedContent.vue' // hidden for now
import Web5Identities from './Web5Identities.vue'
// import Web5DWN from './Web5DWN.vue' // hidden for now
// import Web5CredentialsSummary from './Web5CredentialsSummary.vue' // hidden for now
import Web5Monitoring from './Web5Monitoring.vue'
import Web5Federation from './Web5Federation.vue'
@ -122,7 +121,6 @@ const nostrRelaysRef = ref<InstanceType<typeof Web5NostrRelays> | null>(null)
const nodeVisibilityRef = ref<InstanceType<typeof Web5NodeVisibility> | null>(null)
const connectedNodesRef = ref<InstanceType<typeof Web5ConnectedNodes> | null>(null)
const identitiesRef = ref<InstanceType<typeof Web5Identities> | null>(null)
// const dwnRef = ref(null) // hidden for now
// const credentialsRef = ref(null) // hidden for now
// const sharedContentRef = ref(null) // hidden for now
// const sendReceiveRef = ref(null) // wallet hidden
@ -393,8 +391,6 @@ onMounted(() => {
nodeVisibilityRef.value?.loadVisibility()
// domainsRef.value?.loadDomainNames() // hidden for now
nostrRelaysRef.value?.loadNostrRelays()
// dwnRef.value?.loadDwnStatus() // hidden for now
// dwnRef.value?.loadDwnProtocols() // hidden for now
// credentialsRef.value?.loadCredentials() // hidden for now
// sharedContentRef.value?.loadContentItems() // hidden for now

View File

@ -1,284 +0,0 @@
<template>
<!-- Decentralized Web Node (DWN) -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.decentralizedWebNode') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.dwnDescription') }}</p>
</div>
</div>
<router-link v-if="dwnInstalled && dwnRunning" to="/apps/dwn" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
{{ t('web5.manageDwn') }}
</router-link>
</div>
<!-- DWN not installed or not running -->
<div v-if="!dwnInstalled || !dwnRunning" class="py-6 text-center">
<p class="text-white/60 text-sm mb-4">
{{ !dwnInstalled ? 'The DWN container is not installed.' : 'The DWN container is not running.' }}
{{ !dwnInstalled ? 'Install it from the App Store to enable decentralized data storage and sync.' : 'Start it from the App Store to enable decentralized data storage and sync.' }}
</p>
<router-link to="/dashboard/marketplace" class="glass-button px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
</svg>
Open App Store
</router-link>
</div>
<!-- Status (only shown when DWN is installed and running) -->
<template v-if="dwnInstalled && dwnRunning">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">{{ t('common.status') }}</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="dwnStatus?.running ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white font-medium">{{ dwnStatus?.running ? t('common.running') : t('common.stopped') }}</span>
</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Sync</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="{
'bg-green-400': dwnSyncStatus === 'synced',
'bg-yellow-400 animate-pulse': dwnSyncStatus === 'syncing',
'bg-red-400': dwnSyncStatus === 'error',
'bg-white/30': dwnSyncStatus === 'idle'
}"></div>
<span class="text-sm text-white font-medium capitalize">{{ dwnSyncStatus }}</span>
</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Storage</div>
<span class="text-sm text-white font-medium">{{ formatDwnStorage }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Messages</div>
<span class="text-sm text-white font-medium">{{ dwnStatus?.message_count ?? 0 }}</span>
</div>
</div>
<!-- Protocols -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-white/50">Registered Protocols ({{ dwnProtocols.length }})</div>
<button @click="showRegisterProtocol = !showRegisterProtocol" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
{{ showRegisterProtocol ? 'Cancel' : '+ Register' }}
</button>
</div>
<div v-if="showRegisterProtocol" class="bg-white/5 rounded-lg p-3 mb-3">
<div class="flex gap-2 items-end">
<div class="flex-1">
<label class="text-xs text-white/50 block mb-1">Protocol URI</label>
<input v-model="newProtocolUri" type="text" placeholder="https://example.com/protocol" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
<label class="flex items-center gap-1.5 text-xs text-white/60 cursor-pointer whitespace-nowrap pb-1.5">
<input v-model="newProtocolPublished" type="checkbox" class="rounded bg-black/30 border-white/20" />
Published
</label>
<button @click="registerDwnProtocol" :disabled="registeringProtocol || !newProtocolUri.trim()" class="glass-button glass-button-sm px-3 rounded-lg text-xs font-medium disabled:opacity-50 whitespace-nowrap">
{{ registeringProtocol ? 'Registering...' : 'Register' }}
</button>
</div>
</div>
<div v-if="dwnProtocols.length" class="flex flex-wrap gap-2">
<div v-for="proto in dwnProtocols" :key="proto.protocol" class="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/15 border border-blue-500/20 text-xs text-blue-300 group">
<span>{{ proto.protocol }}</span>
<span v-if="proto.published" class="text-green-400/60" title="Published">&#x2022;</span>
<button @click="removeDwnProtocol(proto.protocol)" :disabled="removingProtocol === proto.protocol" class="opacity-0 group-hover:opacity-100 text-red-400/60 hover:text-red-400 transition-all ml-1" title="Remove">
&times;
</button>
</div>
</div>
<div v-else class="text-xs text-white/30 italic">No protocols registered</div>
</div>
<!-- Sync Targets -->
<div v-if="dwnStatus?.peer_sync_targets?.length" class="mb-4">
<div class="text-xs text-white/50 mb-2">Peer Sync Targets</div>
<div class="space-y-1">
<div v-for="target in dwnStatus.peer_sync_targets" :key="target" class="flex items-center gap-2 text-xs text-white/70 bg-white/5 rounded-lg px-3 py-2">
<svg class="w-3 h-3 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /></svg>
<span class="truncate font-mono">{{ target }}</span>
</div>
</div>
</div>
<!-- Messages Browser -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-white/50">Messages</div>
<button @click="toggleDwnMessages" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
{{ showDwnMessages ? 'Hide' : 'Browse' }}
</button>
</div>
<div v-if="showDwnMessages">
<div v-if="loadingDwnMessages && dwnMessages.length === 0" class="text-xs text-white/40 py-4 text-center">Loading messages...</div>
<div v-else-if="dwnMessages.length === 0" class="text-xs text-white/30 italic py-2">No messages stored</div>
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<div v-if="loadingDwnMessages" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing messages...
</div>
<div v-for="msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
<span class="text-xs text-white/40">{{ new Date(msg.date_created).toLocaleString() }}</span>
</div>
<div class="flex flex-wrap gap-2 text-xs">
<span class="text-white/70">{{ msg.author }}</span>
<span v-if="msg.descriptor.protocol" class="text-blue-300/80">{{ msg.descriptor.protocol }}</span>
<span v-if="msg.descriptor.schema" class="text-purple-300/80">{{ msg.descriptor.schema }}</span>
</div>
<div v-if="msg.data" class="mt-1 text-xs text-white/40 font-mono truncate">{{ JSON.stringify(msg.data).slice(0, 120) }}</div>
</div>
</div>
</div>
</div>
<!-- Last Sync & Actions -->
<div class="flex items-center justify-between pt-3 border-t border-white/10">
<div class="text-xs text-white/40">
{{ dwnStatus?.last_sync ? `Last sync: ${new Date(dwnStatus.last_sync).toLocaleString()}` : 'Never synced' }}
</div>
<button @click="syncDWNs" :disabled="syncingDWNs || !dwnStatus?.running" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50">
<svg class="w-4 h-4" :class="{ 'animate-spin': syncingDWNs }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ syncingDWNs ? t('web5.syncing') : t('web5.syncNow') }}
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
import { PackageState } from '@/types/api'
import type { DwnStatusData, DwnProtocol, DwnMessageEntry } from './types'
const { t } = useI18n()
const appStore = useAppStore()
const dwnStatus = ref<DwnStatusData | null>(null)
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error' | 'idle'>('idle')
const dwnInstalled = computed(() => !!appStore.packages['dwn'])
const dwnRunning = computed(() => appStore.packages['dwn']?.state === PackageState.Running)
const syncingDWNs = ref(false)
const dwnProtocols = ref<DwnProtocol[]>([])
const dwnMessages = ref<DwnMessageEntry[]>([])
const showDwnMessages = ref(false)
const loadingDwnMessages = ref(false)
const showRegisterProtocol = ref(false)
const newProtocolUri = ref('')
const newProtocolPublished = ref(false)
const registeringProtocol = ref(false)
const removingProtocol = ref<string | null>(null)
const formatDwnStorage = computed(() => {
if (!dwnStatus.value) return '0 B'
const bytes = dwnStatus.value.storage_bytes
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
})
async function loadDwnStatus() {
try {
const res = await rpcClient.call<DwnStatusData>({ method: 'dwn.status' })
dwnStatus.value = res
dwnSyncStatus.value = (res.sync_status as 'synced' | 'syncing' | 'error' | 'idle') || 'idle'
} catch {
dwnStatus.value = null
dwnSyncStatus.value = 'idle'
}
}
async function syncDWNs() {
syncingDWNs.value = true
dwnSyncStatus.value = 'syncing'
try {
const res = await rpcClient.call<{ sync_status: string; last_sync: string; messages_synced: number }>({ method: 'dwn.sync' })
dwnSyncStatus.value = (res.sync_status as 'synced' | 'syncing' | 'error' | 'idle') || 'synced'
await loadDwnStatus()
} catch {
dwnSyncStatus.value = 'error'
} finally {
syncingDWNs.value = false
}
}
async function loadDwnProtocols() {
try {
const res = await rpcClient.call<{ protocols: DwnProtocol[] }>({ method: 'dwn.list-protocols' })
dwnProtocols.value = res.protocols || []
} catch {
dwnProtocols.value = []
}
}
async function registerDwnProtocol() {
if (registeringProtocol.value || !newProtocolUri.value.trim()) return
registeringProtocol.value = true
try {
await rpcClient.call({ method: 'dwn.register-protocol', params: { protocol: newProtocolUri.value.trim(), published: newProtocolPublished.value } })
newProtocolUri.value = ''
newProtocolPublished.value = false
showRegisterProtocol.value = false
await loadDwnProtocols()
await loadDwnStatus()
} catch {
if (import.meta.env.DEV) console.error('Failed to register protocol')
} finally {
registeringProtocol.value = false
}
}
async function removeDwnProtocol(protocol: string) {
removingProtocol.value = protocol
try {
await rpcClient.call({ method: 'dwn.remove-protocol', params: { protocol } })
await loadDwnProtocols()
await loadDwnStatus()
} catch {
if (import.meta.env.DEV) console.error('Failed to remove protocol')
} finally {
removingProtocol.value = null
}
}
async function toggleDwnMessages() {
showDwnMessages.value = !showDwnMessages.value
if (showDwnMessages.value) {
await loadDwnMessages()
}
}
async function loadDwnMessages() {
const hadMessages = dwnMessages.value.length > 0
loadingDwnMessages.value = true
try {
const res = await rpcClient.call<{ messages: DwnMessageEntry[]; count: number }>({ method: 'dwn.query-messages', params: { limit: 50 } })
dwnMessages.value = res.messages || []
} catch {
if (!hadMessages) dwnMessages.value = []
} finally {
loadingDwnMessages.value = false
}
}
defineExpose({ loadDwnStatus, loadDwnProtocols, loadDwnMessages, dwnMessages, showDwnMessages })
</script>

View File

@ -1,94 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Web5DWN from '../Web5DWN.vue'
import { rpcClient } from '@/api/rpc-client'
import { useSyncStore } from '@/stores/sync'
import { PackageState } from '@/types/api'
import type { DwnMessageEntry } from '../types'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function makeMessage(recordId: string): DwnMessageEntry {
return {
record_id: recordId,
author: 'did:key:alice',
date_created: '2026-06-10T10:00:00Z',
descriptor: {
interface: 'Records',
method: 'Write',
protocol: 'https://example.com/protocol',
},
data: { title: recordId },
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('Web5DWN', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
useSyncStore().data = {
'package-data': {
dwn: {
state: PackageState.Running,
manifest: { id: 'dwn', title: 'DWN' },
'static-files': {},
},
},
} as never
})
it('keeps stored messages visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
messages: [makeMessage('record-one')],
count: 1,
})
const wrapper = mount(Web5DWN, {
global: {
stubs: { RouterLink: true },
},
})
;(wrapper.vm as unknown as { showDwnMessages: boolean }).showDwnMessages = true
await (wrapper.vm as unknown as { loadDwnMessages: () => Promise<void> }).loadDwnMessages()
await flushPromises()
expect(wrapper.text()).toContain('record-o')
const pending = deferred<{ messages: DwnMessageEntry[]; count: number }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadDwnMessages: () => Promise<void> }).loadDwnMessages()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('record-o')
expect(wrapper.text()).toContain('Refreshing messages...')
expect(wrapper.text()).not.toContain('Loading messages...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('record-o')
expect(wrapper.text()).not.toContain('Refreshing messages...')
})
})

View File

@ -3,7 +3,8 @@
"release_date": "2026-06-11",
"changelog": [
"Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.",
"Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and sendrawtransaction-focused whitelist.",
"Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.",
"The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.",
"The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.",
"Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.",
"Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path."

View File

@ -180,7 +180,7 @@ load_spec_bitcoin-knots() {
local btc_rpc_headroom="-rpcthreads=16 -rpcworkqueue=256"
local btc_txrelay_flags="-rpcwhitelistdefault=0"
if [ -f "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth" ]; then
btc_txrelay_flags="$btc_txrelay_flags -rpcauth=$(cat "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth") -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee"
btc_txrelay_flags="$btc_txrelay_flags -rpcauth=$(cat "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth") -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"
fi
# Dynamic: prune on small disk
if [ "${DISK_GB:-0}" -lt 1000 ]; then

View File

@ -298,6 +298,32 @@ deploy_node() {
' 2>/dev/null || true
fi
step "Syncing kiosk display helpers"
KIOSK_LAUNCHER="$PROJECT_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh"
if [ -f "$KIOSK_LAUNCHER" ]; then
scp $SSH_OPTS "$KIOSK_LAUNCHER" "$TARGET:/tmp/archipelago-kiosk-launcher" 2>/dev/null || true
ssh $SSH_OPTS "$TARGET" '
sudo install -m 755 /tmp/archipelago-kiosk-launcher /usr/local/bin/archipelago-kiosk-launcher
rm -f /tmp/archipelago-kiosk-launcher
echo " Kiosk launcher updated"
' 2>/dev/null || true
fi
for unit in archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
KIOSK_UNIT="$PROJECT_DIR/image-recipe/configs/$unit"
[ -f "$KIOSK_UNIT" ] || continue
scp $SSH_OPTS "$KIOSK_UNIT" "$TARGET:/tmp/$unit" 2>/dev/null || true
ssh $SSH_OPTS "$TARGET" "
if ! diff -q '/tmp/$unit' '/etc/systemd/system/$unit' >/dev/null 2>&1; then
sudo install -m 644 '/tmp/$unit' '/etc/systemd/system/$unit'
sudo systemctl daemon-reload
echo ' $unit updated'
else
echo ' $unit unchanged'
fi
rm -f '/tmp/$unit'
" 2>/dev/null || true
done
# ── Step 13: Rootless podman prereqs ─────────────────────────────
step "Setting up rootless podman prerequisites"
ssh $SSH_OPTS "$TARGET" '

View File

@ -703,6 +703,33 @@ if [ "$LIVE" = true ]; then
rm -f /tmp/archipelago.service
' 2>/dev/null || true
fi
# Sync kiosk display helpers and units for HDMI/TV nodes. Existing nodes may
# not have a git checkout, so live deploy must carry these outside OTA too.
KIOSK_LAUNCHER="$PROJECT_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh"
if [ -f "$KIOSK_LAUNCHER" ]; then
scp $SSH_OPTS "$KIOSK_LAUNCHER" "$TARGET_HOST:/tmp/archipelago-kiosk-launcher" 2>/dev/null || true
ssh $SSH_OPTS "$TARGET_HOST" '
sudo install -m 755 /tmp/archipelago-kiosk-launcher /usr/local/bin/archipelago-kiosk-launcher
rm -f /tmp/archipelago-kiosk-launcher
echo " Kiosk launcher updated"
' 2>/dev/null || true
fi
for unit in archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
KIOSK_UNIT="$PROJECT_DIR/image-recipe/configs/$unit"
[ -f "$KIOSK_UNIT" ] || continue
scp $SSH_OPTS "$KIOSK_UNIT" "$TARGET_HOST:/tmp/$unit" 2>/dev/null || true
ssh $SSH_OPTS "$TARGET_HOST" "
if ! diff -q '/tmp/$unit' '/etc/systemd/system/$unit' >/dev/null 2>&1; then
sudo install -m 644 '/tmp/$unit' '/etc/systemd/system/$unit'
sudo systemctl daemon-reload
echo ' $unit updated'
else
echo ' $unit unchanged'
fi
rm -f '/tmp/$unit'
" 2>/dev/null || true
done
if [ -n "${TELEMETRY_COLLECTOR_URL:-}" ]; then
progress "Syncing telemetry collector config"
TMP_TELEMETRY_ENV="$(mktemp)"

View File

@ -44,7 +44,7 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:latest"
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
@ -90,7 +90,6 @@ INDEEDHUB_REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8-alpine"
GITEA_IMAGE="docker.io/gitea/gitea:1.23"
# DWN (Decentralized Web Node)
DWN_SERVER_IMAGE="$ARCHY_REGISTRY/dwn-server:main"
# Immich stack
IMMICH_POSTGRES_IMAGE="$ARCHY_REGISTRY/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0"

View File

@ -312,9 +312,16 @@ if [ -n "$UI_REBUILD_LIST" ]; then
fi
fi
# Update kiosk display helpers used by HDMI/TV installs.
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" ]; then
sudo install -m 755 "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" \
/usr/local/bin/archipelago-kiosk-launcher
ok "Updated archipelago-kiosk-launcher"
fi
# Update systemd services if changed
SYSTEMD_UNITS_CHANGED=false
for unit in archipelago.service archipelago-fips.service; do
for unit in archipelago.service archipelago-fips.service archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
src="$REPO_DIR/image-recipe/configs/$unit"
dst="/etc/systemd/system/$unit"
[ -f "$src" ] || continue

View File

@ -155,7 +155,6 @@ image_for() {
electrumx) echo "146.59.87.168:3000/lfg2025/electrumx:v1.18.0" ;;
fedimint) echo "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0" ;;
indeedhub) echo "146.59.87.168:3000/lfg2025/indeedhub:1.0.0" ;;
dwn) echo "146.59.87.168:3000/lfg2025/dwn-server:main" ;;
botfights) echo "146.59.87.168:3000/lfg2025/botfights:1.1.0" ;;
gitea) echo "docker.io/gitea/gitea:1.23" ;;
meshtastic) echo "docker.io/meshtastic/meshtasticd:daily-alpine" ;;