Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb808df89a | ||
|
|
c800293f1f | ||
|
|
340b981b79 | ||
|
|
c49e8fcacd | ||
|
|
495b90782a | ||
|
|
0cfb4dc81c | ||
|
|
b8ac68d844 | ||
|
|
eaf13effd5 | ||
|
|
0339268c43 | ||
|
|
6fd1cf9ba7 | ||
|
|
8d4b309753 | ||
|
|
b11c6c17d1 | ||
|
|
e474a2b4c9 | ||
|
|
00c32688f8 | ||
|
|
d6f108d818 | ||
|
|
6a30ff11bd |
@@ -137,7 +137,11 @@ fun WebViewScreen(
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
).apply {
|
||||
// Required when launching from a non-Activity/binder
|
||||
// thread (the JS bridge below runs off the UI thread).
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
@@ -169,8 +173,29 @@ fun WebViewScreen(
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
// chain; without this, window.open() from a Vue click
|
||||
// handler silently no-ops and "Open in new tab" dies.
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
}
|
||||
|
||||
// Deterministic bridge for "open in the phone's browser".
|
||||
// The web UI calls window.ArchipelagoNative.openExternal(url)
|
||||
// when present (companion app), falling back to window.open
|
||||
// in a plain mobile browser. This avoids relying on the
|
||||
// window.open → onCreateWindow path, which noopener/noreferrer
|
||||
// can suppress in the WebView.
|
||||
val webViewRef = this
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
webViewRef.post { openExternalUrl(url) }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.90-alpha (2026-06-13)
|
||||
|
||||
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
|
||||
- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.
|
||||
- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.
|
||||
- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.
|
||||
- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.
|
||||
- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.
|
||||
|
||||
## v1.7.89-alpha (2026-06-12)
|
||||
|
||||
- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.
|
||||
- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.
|
||||
- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.
|
||||
- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.
|
||||
- The Lightning wallet now recovers and unlocks itself properly after restarts.
|
||||
|
||||
## v1.7.88-alpha (2026-06-12)
|
||||
|
||||
- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.
|
||||
- Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.
|
||||
- Validation pending on the AIUI rollback; the rest of the release train remains unchanged.
|
||||
|
||||
## v1.7.87-alpha (2026-06-12)
|
||||
|
||||
- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.
|
||||
- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.
|
||||
- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`.
|
||||
|
||||
## v1.7.86-alpha (2026-06-12)
|
||||
|
||||
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
|
||||
- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.
|
||||
- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.
|
||||
- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.
|
||||
- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.
|
||||
- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
## v1.7.83-alpha (2026-06-11)
|
||||
|
||||
- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces.
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-4
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,10 +26,19 @@ app:
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
fi;
|
||||
if [ "${DISK_GB:-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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
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,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";
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@@ -37,6 +46,8 @@ app:
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
|
||||
secret_file: bitcoin-rpc-txrelay-rpcauth
|
||||
data_uid: "100101:100101"
|
||||
|
||||
dependencies:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'}`);
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
@@ -57,6 +57,7 @@ app:
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10m
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
Generated
-2747
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'}`);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.83-alpha"
|
||||
version = "1.7.89-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.83-alpha"
|
||||
version = "1.7.90-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::container::docker_packages;
|
||||
use crate::data_model::{Notification, NotificationLevel};
|
||||
use crate::{bitcoin_status, identity, peers};
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::ContainerState;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
@@ -164,7 +165,11 @@ impl RpcHandler {
|
||||
update_endpoint(¶ms, "tor_endpoint", &mut state.settings.tor_endpoint)?;
|
||||
|
||||
if state.settings.enabled_for_peers {
|
||||
let credentials_were_ready = txrelay_credentials_available(&self.config.data_dir).await;
|
||||
ensure_txrelay_credentials(&self.config.data_dir).await?;
|
||||
if !credentials_were_ready {
|
||||
self.restart_bitcoin_backends_for_txrelay().await;
|
||||
}
|
||||
}
|
||||
|
||||
if params.get("selected_peer_pubkey").is_some() {
|
||||
@@ -354,7 +359,11 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
let credentials = if status == RelayRequestStatus::Approved {
|
||||
Some(ensure_txrelay_credentials(&self.config.data_dir).await?)
|
||||
let credentials = ensure_txrelay_credentials(&self.config.data_dir).await?;
|
||||
if request_direction == RelayRequestDirection::Incoming {
|
||||
self.restart_bitcoin_backends_for_txrelay().await;
|
||||
}
|
||||
Some(credentials)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -476,6 +485,34 @@ impl RpcHandler {
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
async fn restart_bitcoin_backends_for_txrelay(&self) {
|
||||
let Some(orchestrator) = self.orchestrator.as_ref().cloned() else {
|
||||
tracing::debug!("Skipping txrelay backend restart; orchestrator unavailable");
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
for app_id in ["bitcoin-knots", "bitcoin-core"] {
|
||||
let Ok(status) = orchestrator.status(app_id).await else {
|
||||
continue;
|
||||
};
|
||||
if status.state != ContainerState::Running {
|
||||
continue;
|
||||
}
|
||||
match orchestrator.restart(app_id).await {
|
||||
Ok(()) => tracing::info!(
|
||||
app_id,
|
||||
"Restarted Bitcoin backend to load txrelay RPC credentials"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
app_id,
|
||||
error = %e,
|
||||
"Failed to restart Bitcoin backend after txrelay credential update"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_incoming_relay_message(
|
||||
@@ -588,21 +625,29 @@ fn trusted_relay_peers(
|
||||
}
|
||||
|
||||
async fn txrelay_credential_status(data_dir: &Path) -> serde_json::Value {
|
||||
let credentials_available = txrelay_credentials_available(data_dir).await;
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
let password_available = fs::metadata(&password_path).await.is_ok();
|
||||
let rpcauth_available = fs::metadata(&rpcauth_path).await.is_ok();
|
||||
let client_env_available = fs::metadata(&client_env_path).await.is_ok();
|
||||
json!({
|
||||
"username": TXRELAY_USER,
|
||||
"available": password_available && rpcauth_available && client_env_available,
|
||||
"available": credentials_available,
|
||||
"password_available": password_available,
|
||||
"rpcauth_available": rpcauth_available,
|
||||
"client_env_available": client_env_available,
|
||||
"client_env_path": client_env_path.display().to_string(),
|
||||
"restart_hint": "If this was just generated, restart Bitcoin Core/Knots so bitcoind loads the txrelay rpcauth whitelist.",
|
||||
"restart_hint": "Archipelago restarts the active Bitcoin backend after generating txrelay credentials so bitcoind loads the restricted rpcauth whitelist.",
|
||||
})
|
||||
}
|
||||
|
||||
async fn txrelay_credentials_available(data_dir: &Path) -> bool {
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
fs::metadata(&password_path).await.is_ok()
|
||||
&& fs::metadata(&rpcauth_path).await.is_ok()
|
||||
&& fs::metadata(&client_env_path).await.is_ok()
|
||||
}
|
||||
|
||||
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
let password = match read_trimmed(&password_path).await {
|
||||
@@ -614,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
|
||||
@@ -684,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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,27 +343,28 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let mut args = vec![
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
&conn_name,
|
||||
"ifname",
|
||||
"*",
|
||||
"ssid",
|
||||
ssid,
|
||||
"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([
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
&conn_name,
|
||||
"ifname",
|
||||
"*",
|
||||
"ssid",
|
||||
ssid,
|
||||
"wifi-sec.key-mgmt",
|
||||
"wpa-psk",
|
||||
"wifi-sec.psk",
|
||||
password,
|
||||
"ipv4.method",
|
||||
"auto",
|
||||
"ipv6.method",
|
||||
"auto",
|
||||
])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi profile create")?;
|
||||
|
||||
@@ -38,6 +38,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -180,6 +181,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
|
||||
@@ -63,6 +63,7 @@ impl RpcHandler {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
||||
@@ -13,25 +13,49 @@ impl RpcHandler {
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
// LND's REST gateway parses `type` as the AddressType enum by its
|
||||
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
|
||||
// valid enum value and returns 400 "parsing field type"; the native
|
||||
// SegWit (bech32) variant is WITNESS_PUBKEY_HASH (= 0).
|
||||
.query(&[("type", "WITNESS_PUBKEY_HASH")])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
let status = resp.status();
|
||||
let raw_body = resp
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
.context("LND address response could not be read")?;
|
||||
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
|
||||
serde_json::json!({
|
||||
"raw": raw_body,
|
||||
})
|
||||
});
|
||||
|
||||
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!(
|
||||
"Bitcoin address generation failed ({}): {}",
|
||||
status,
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(error) = body
|
||||
.get("error")
|
||||
.or_else(|| body.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
anyhow::bail!("Bitcoin address generation failed: {}", error);
|
||||
}
|
||||
|
||||
let address = body
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|addr| !addr.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin address generation failed: LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({ "address": address }))
|
||||
@@ -510,6 +534,7 @@ impl RpcHandler {
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -548,3 +573,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Failed to start",
|
||||
"Container",
|
||||
"Image",
|
||||
"Bitcoin address",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
|
||||
@@ -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;
|
||||
(
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
if file_exists_as_root(admin_macaroon).await {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
@@ -127,6 +127,7 @@ async fn unlock_existing_wallet() -> Result<()> {
|
||||
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -204,6 +205,7 @@ struct InitWalletRequest {
|
||||
|
||||
async fn init_wallet_via_rest() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -305,12 +307,12 @@ async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
|
||||
anyhow::bail!("LND REST {path} returned {status}: {text}")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
||||
@@ -3220,11 +3220,11 @@ app:
|
||||
- /data/.filebrowser.json
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /tmp/filebrowser-srv
|
||||
source: /var/lib/archipelago/filebrowser-srv
|
||||
target: /srv
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /tmp/filebrowser-data
|
||||
source: /var/lib/archipelago/filebrowser-data
|
||||
target: /data
|
||||
options: [rw]
|
||||
"#;
|
||||
@@ -3244,7 +3244,7 @@ app:
|
||||
secret_file: bitcoin-rpc-password
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /tmp/lnd
|
||||
source: /var/lib/archipelago/lnd
|
||||
target: /root/.lnd
|
||||
"#;
|
||||
AppManifest::parse(yaml).unwrap()
|
||||
|
||||
@@ -800,7 +800,7 @@ mod tests {
|
||||
QuadletUnit {
|
||||
name: "archy-bitcoin-ui".into(),
|
||||
description: "Bitcoin RPC UI proxy".into(),
|
||||
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:latest".into(),
|
||||
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha".into(),
|
||||
network: NetworkMode::Host,
|
||||
user: Some("0:0".into()),
|
||||
memory_mb: Some(128),
|
||||
@@ -828,7 +828,7 @@ mod tests {
|
||||
let s = sample_unit().render();
|
||||
assert!(s.contains("[Container]"));
|
||||
assert!(s.contains("ContainerName=archy-bitcoin-ui"));
|
||||
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:latest"));
|
||||
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha"));
|
||||
assert!(s.contains("Pull=never"));
|
||||
assert!(s.contains("Network=host"));
|
||||
assert!(s.contains("DropCapability=ALL"));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,6 +731,89 @@ async fn restart_container(name: &str, state: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX/electrs on-disk data dir. Wiped to force a clean resync when its
|
||||
/// LevelDB is detected corrupt (see `maybe_recover_corrupt_electrumx`).
|
||||
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
|
||||
/// Restart attempt at which we check for — and recover from — a corrupt
|
||||
/// ElectrumX database. Late enough that a transient restart won't trigger a
|
||||
/// destructive resync, early enough to self-heal before MAX_RESTART_ATTEMPTS.
|
||||
const ELECTRUMX_DB_RESET_ATTEMPT: u32 = 3;
|
||||
|
||||
fn is_electrumx(name: &str) -> bool {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
matches!(id, "electrumx" | "electrs" | "mempool-electrs")
|
||||
}
|
||||
|
||||
/// True when a container's logs show the specific corrupt-LevelDB signature.
|
||||
/// ElectrumX exit-loops with a plyvel error when its `hist`/`utxo` LevelDB
|
||||
/// loses its CURRENT/MANIFEST pointer — typically after an unclean SIGKILL
|
||||
/// (e.g. the cgroup cascade on a service restart). We match the exact failure
|
||||
/// so a normal restart never triggers the destructive resync below.
|
||||
fn looks_like_corrupt_electrumx_db(logs: &str) -> bool {
|
||||
logs.contains("create_if_missing is false")
|
||||
|| logs.contains("Corruption:")
|
||||
|| (logs.contains("plyvel") && logs.contains("does not exist"))
|
||||
}
|
||||
|
||||
async fn electrumx_db_corrupt(name: &str) -> bool {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", "60", name])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match out {
|
||||
Ok(Ok(output)) => {
|
||||
let logs = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
looks_like_corrupt_electrumx_db(&logs)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe the ElectrumX LevelDB stores so the next start resyncs from scratch.
|
||||
/// Files are owned by the container's mapped UID, so removal needs host sudo.
|
||||
/// The mount point itself is preserved (the container expects it to exist).
|
||||
/// Returns true if the reset succeeded.
|
||||
async fn reset_electrumx_data() -> bool {
|
||||
let rm = format!(
|
||||
"rm -rf {dir}/hist {dir}/utxo {dir}/meta {dir}/COIN",
|
||||
dir = ELECTRUMX_DATA_DIR,
|
||||
);
|
||||
matches!(
|
||||
crate::update::host_sudo(&["sh", "-c", &rm]).await,
|
||||
Ok(status) if status.success()
|
||||
)
|
||||
}
|
||||
|
||||
/// Self-heal a wedged ElectrumX: if it's exit-looping on a corrupt database,
|
||||
/// wipe its data dir once (at `ELECTRUMX_DB_RESET_ATTEMPT`) so the impending
|
||||
/// restart resyncs cleanly. Bounded to electrs containers, gated on the exact
|
||||
/// corruption signature, and fired once per failure streak — when the resync
|
||||
/// stabilises the restart tracker clears, so a future corruption can heal too.
|
||||
async fn maybe_recover_corrupt_electrumx(name: &str, attempt: u32) {
|
||||
if attempt != ELECTRUMX_DB_RESET_ATTEMPT || !is_electrumx(name) {
|
||||
return;
|
||||
}
|
||||
if !electrumx_db_corrupt(name).await {
|
||||
return;
|
||||
}
|
||||
warn!(
|
||||
"ElectrumX {} is exit-looping on a corrupt database — resetting its data dir to force a clean resync",
|
||||
name
|
||||
);
|
||||
if reset_electrumx_data().await {
|
||||
info!("ElectrumX data dir reset; a fresh resync will begin on restart");
|
||||
} else {
|
||||
warn!("Failed to reset ElectrumX data dir (host sudo rm failed) — manual recovery may be needed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the health monitor background task.
|
||||
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
@@ -993,6 +1076,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
.unwrap_or(&90)
|
||||
);
|
||||
|
||||
// Before restarting, self-heal a corrupt ElectrumX DB so
|
||||
// the restart resyncs cleanly instead of crash-looping.
|
||||
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
|
||||
|
||||
let restarted = restart_container(&container.name, &container.state).await;
|
||||
|
||||
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
||||
@@ -1471,4 +1558,36 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_electrumx_matches_electrs_variants_only() {
|
||||
assert!(is_electrumx("electrumx"));
|
||||
assert!(is_electrumx("archy-electrumx"));
|
||||
assert!(is_electrumx("electrs"));
|
||||
assert!(is_electrumx("mempool-electrs"));
|
||||
assert!(!is_electrumx("bitcoin-knots"));
|
||||
assert!(!is_electrumx("lnd"));
|
||||
assert!(!is_electrumx("electrs-ui")); // the web UI, not the indexer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_db_detection_matches_plyvel_signature() {
|
||||
// The exact line ElectrumX exit-loops on (observed on .116).
|
||||
let corrupt = "plyvel._plyvel.Error: b'Invalid argument: hist: does not exist (create_if_missing is false)'";
|
||||
assert!(looks_like_corrupt_electrumx_db(corrupt));
|
||||
assert!(looks_like_corrupt_electrumx_db(
|
||||
"Corruption: bad block in hist"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_db_detection_ignores_healthy_logs() {
|
||||
let healthy = "INFO:BlockProcessor:our height: 117,009 daemon: 953,480 UTXOs 28MB\n\
|
||||
INFO:SessionManager:RPC server listening on 0.0.0.0:8000";
|
||||
assert!(!looks_like_corrupt_electrumx_db(healthy));
|
||||
// "catching up" / normal restart noise must not trigger a destructive wipe.
|
||||
assert!(!looks_like_corrupt_electrumx_db(
|
||||
"Prefetcher:catching up to daemon height 953,480"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -15,6 +15,7 @@ use hyper::server::conn::Http;
|
||||
use hyper::service::service_fn;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
@@ -28,6 +29,25 @@ pub struct Server {
|
||||
_state_manager: Arc<StateManager>,
|
||||
}
|
||||
|
||||
struct ContainerScanGuard<'a> {
|
||||
scanning: &'a AtomicBool,
|
||||
}
|
||||
|
||||
impl<'a> ContainerScanGuard<'a> {
|
||||
fn try_acquire(scanning: &'a AtomicBool) -> Option<Self> {
|
||||
scanning
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.ok()
|
||||
.map(|_| Self { scanning })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ContainerScanGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.scanning.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn new(
|
||||
config: Config,
|
||||
@@ -362,7 +382,7 @@ impl Server {
|
||||
// Skip missed ticks instead of catching up — prevents burst of scans
|
||||
// after a slow podman response (which causes DB lock storms)
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let scanning = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let scanning = std::sync::Arc::new(AtomicBool::new(false));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {}
|
||||
@@ -377,13 +397,12 @@ impl Server {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if scanning.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
let Some(_scan_guard) = ContainerScanGuard::try_acquire(&scanning) else {
|
||||
debug!("Skipping container scan — previous scan still in progress");
|
||||
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
|
||||
continue;
|
||||
}
|
||||
scanning.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
if let Err(e) = scan_and_update_packages(
|
||||
};
|
||||
let scan_result = scan_and_update_packages(
|
||||
&scanner,
|
||||
&state,
|
||||
identity_clone.as_ref(),
|
||||
@@ -391,8 +410,8 @@ impl Server {
|
||||
&mut absence_tracker,
|
||||
&mut transitional_since,
|
||||
)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
if let Err(e) = scan_result {
|
||||
error!("Failed to update containers: {}", e);
|
||||
if is_podman_scan_timeout(&e) {
|
||||
scan_backoff_until = Some(Instant::now() + Duration::from_secs(30));
|
||||
@@ -402,7 +421,6 @@ impl Server {
|
||||
scan_backoff_until = None;
|
||||
}
|
||||
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
|
||||
scanning.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+157
-50
@@ -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
|
||||
@@ -66,10 +66,6 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
/// Secondary mirror on tx1138 gitea — independent network path so a
|
||||
/// single-provider outage doesn't knock out both mirrors.
|
||||
const DEFAULT_SECONDARY_MIRROR_URL: &str =
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
/// Marker written by apply_update() just before the service restart and
|
||||
@@ -107,16 +103,10 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
},
|
||||
UpdateMirror {
|
||||
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
|
||||
label: "Server 2 (tx1138)".to_string(),
|
||||
},
|
||||
]
|
||||
vec![UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Load the operator-configured mirror list. Returns defaults if the
|
||||
@@ -144,14 +134,17 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
return Ok(default_mirrors());
|
||||
}
|
||||
|
||||
// One-time migration: the Hetzner VPS at 23.182.128.160 was
|
||||
// decommissioned 2026-04-23. Existing nodes have it baked into their
|
||||
// saved mirror list (was the original Server 1). Strip it on load so
|
||||
// we don't spend seconds per install timing out against a dead host.
|
||||
// Exception to the usual "explicit removals stick" rule: the user
|
||||
// never chose to add this — it was a default.
|
||||
// One-time migrations: drop decommissioned release servers that may be
|
||||
// baked into existing nodes' saved mirror lists. Strip them on load so
|
||||
// we don't spend seconds per install timing out against a dead/stale host.
|
||||
// - 23.182.128.160: Hetzner VPS, decommissioned 2026-04-23.
|
||||
// - git.tx1138.com: retired as a release server 2026-06-13 — its main
|
||||
// branch had diverged and stopped receiving releases, so it only
|
||||
// ever served a stale manifest as the secondary mirror.
|
||||
// Exception to the usual "explicit removals stick" rule: the user never
|
||||
// chose to add these — they were defaults.
|
||||
let before = list.len();
|
||||
list.retain(|m| !m.url.contains("23.182.128.160"));
|
||||
list.retain(|m| !m.url.contains("23.182.128.160") && !m.url.contains("git.tx1138.com"));
|
||||
let mut changed = list.len() != before;
|
||||
|
||||
// Merge in any default URLs the saved config is missing.
|
||||
@@ -182,17 +175,13 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Server 1 (OVH)".to_string();
|
||||
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
mirror.label = "Server 2 (tx1138)".to_string();
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|m| {
|
||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
0
|
||||
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
1
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -367,12 +356,20 @@ async fn probe_frontend_once() -> Result<()> {
|
||||
.context("build probe client")?;
|
||||
// Prefer HTTPS since that's the failure mode we're catching (nginx
|
||||
// 500 on the PWA). HTTP usually redirects to HTTPS and would mask
|
||||
// the bug.
|
||||
let resp = client
|
||||
.get("https://127.0.0.1/")
|
||||
.send()
|
||||
.await
|
||||
.context("probe GET https://127.0.0.1/")?;
|
||||
// the bug. BUT not every node binds 443 on loopback (.116 serves
|
||||
// plain HTTP; 443 there belongs to tailscale) — on a *connect*
|
||||
// error, fall back to HTTP so a healthy node isn't "verified" into
|
||||
// a rollback. An HTTP error status stays fatal on whichever scheme
|
||||
// answered.
|
||||
let resp = match client.get("https://127.0.0.1/").send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) if e.is_connect() => client
|
||||
.get("http://127.0.0.1/")
|
||||
.send()
|
||||
.await
|
||||
.context("probe GET http://127.0.0.1/ (https not bound on loopback)")?,
|
||||
Err(e) => return Err(e).context("probe GET https://127.0.0.1/"),
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.is_redirection() {
|
||||
return Ok(());
|
||||
@@ -502,6 +499,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 +516,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)?;
|
||||
@@ -728,10 +752,15 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
.context("Failed to create staging dir")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
// Per-request budget; each attempt gets the full hour. A retry
|
||||
// restarts the budget cleanly.
|
||||
.timeout(std::time::Duration::from_secs(3600))
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
// Per-request budget; each attempt gets the full window, and a retry
|
||||
// resumes via Range from the partial file (download_component_resumable),
|
||||
// so this is an upper bound per attempt, not the whole download. Sized
|
||||
// generously for slow machines on slow links: a ~200MB release at a
|
||||
// crawling ~50KB/s is ~70min, which the old 1h budget could cut off
|
||||
// mid-attempt. 3h leaves ample headroom; raising it cannot slow down or
|
||||
// break a fast download (those finish well inside the old limit).
|
||||
.timeout(std::time::Duration::from_secs(10800))
|
||||
.connect_timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
@@ -1051,6 +1080,17 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let current_binary = Path::new("/usr/local/bin/archipelago");
|
||||
if current_binary.exists() {
|
||||
let backup_path = backup_dir.join("archipelago");
|
||||
// A leftover backup from an earlier rollback can be root-owned
|
||||
// (rollback used to chown it in place), and fs::copy O_TRUNCs the
|
||||
// existing file — EACCES as the service user, wedging every apply
|
||||
// (seen on .116, v1.7.86 OTA). Unlink first; the dir is
|
||||
// service-owned so unlink works even when the file isn't ours.
|
||||
if backup_path.exists() {
|
||||
if let Err(e) = fs::remove_file(&backup_path).await {
|
||||
tracing::warn!(error = %e, "unlink of stale binary backup failed, retrying via host_sudo");
|
||||
let _ = host_sudo(&["rm", "-f", &backup_path.to_string_lossy()]).await;
|
||||
}
|
||||
}
|
||||
fs::copy(current_binary, &backup_path)
|
||||
.await
|
||||
.context("Failed to backup current binary")?;
|
||||
@@ -1388,21 +1428,38 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
||||
|
||||
let backup_binary = backup_dir.join("archipelago");
|
||||
if backup_binary.exists() {
|
||||
// Use host_sudo + mv so we escape the archipelago service's
|
||||
// ProtectSystem=strict mount namespace. A plain fs::copy or
|
||||
// `sudo cp` from inside the service hits EROFS on /usr/local/bin,
|
||||
// which would silently orphan the rollback — exactly the
|
||||
// opposite of what auto-rollback is for. Pattern matches
|
||||
// apply_update()'s binary swap above.
|
||||
// Same two namespace gotchas as apply_update()'s binary swap:
|
||||
// `cp` straight onto the running binary is O_TRUNC and fails
|
||||
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
|
||||
// plain sudo inherits ProtectSystem=strict, so everything goes
|
||||
// through host_sudo. Copy to a temp name on the same filesystem,
|
||||
// fix ownership on the TEMP file (never the stored backup — an
|
||||
// in-place chown is what later wedged apply_update), then mv,
|
||||
// which is an atomic rename and tolerates a busy destination.
|
||||
let backup_str = backup_binary.to_string_lossy().to_string();
|
||||
let _ = host_sudo(&["chmod", "0755", &backup_str]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &backup_str]).await;
|
||||
let status = host_sudo(&["cp", &backup_str, "/usr/local/bin/archipelago"])
|
||||
let tmp = format!(
|
||||
"/usr/local/bin/.archipelago.rollback.{}",
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
);
|
||||
let copy = host_sudo(&["cp", &backup_str, &tmp])
|
||||
.await
|
||||
.context("Failed to stage backup binary via host_sudo")?;
|
||||
if !copy.success() {
|
||||
anyhow::bail!(
|
||||
"cp backup binary to {} failed (exit {:?})",
|
||||
tmp,
|
||||
copy.code()
|
||||
);
|
||||
}
|
||||
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
|
||||
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.context("Failed to restore backup binary via host_sudo")?;
|
||||
if !status.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!(
|
||||
"cp backup binary into /usr/local/bin failed (exit {:?})",
|
||||
"mv backup binary into /usr/local/bin failed (exit {:?})",
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
@@ -1620,9 +1677,38 @@ mod tests {
|
||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(list[0].url.contains("146.59.87.168"));
|
||||
assert!(list[1].url.contains("git.tx1138.com"));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"tx1138 was retired as a release server and must not be a default mirror"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_mirrors_strips_retired_tx1138_mirror() {
|
||||
// A node that was running before tx1138 was retired has it baked
|
||||
// into its saved mirror list. load_mirrors must strip it on load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let saved = vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
},
|
||||
UpdateMirror {
|
||||
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
.to_string(),
|
||||
label: "Server 2 (tx1138)".to_string(),
|
||||
},
|
||||
];
|
||||
save_mirrors(dir.path(), &saved).await.unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"retired tx1138 mirror should be stripped on load; got {:?}",
|
||||
list
|
||||
);
|
||||
assert!(list.iter().any(|m| m.url.contains("146.59.87.168")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1636,7 +1722,7 @@ mod tests {
|
||||
let back = load_mirrors(dir.path()).await.unwrap();
|
||||
// load_mirrors merges in any missing default mirrors so a node
|
||||
// that explicitly added a single custom mirror still gets the
|
||||
// built-in OVH + tx1138 fallbacks. The custom mirror is preserved.
|
||||
// built-in OVH default. The custom mirror is preserved.
|
||||
assert!(
|
||||
back.iter().any(|m| m.url == "https://example.com/m.json"),
|
||||
"custom mirror should round-trip; got {:?}",
|
||||
@@ -1764,6 +1850,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 +1891,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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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! 🎨⚡
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.83-alpha",
|
||||
"version": "1.7.90-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.83-alpha",
|
||||
"version": "1.7.90-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.83-alpha",
|
||||
"version": "1.7.90-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { isTextField, typeKeyIntoField } from '../remote-relay'
|
||||
|
||||
/**
|
||||
* Companion-cursor text entry. Synthetic KeyboardEvents do NOT mutate input
|
||||
* values in the browser, so the relay edits `.value` at the caret directly and
|
||||
* fires an `input` event. These tests lock in that behaviour so a regression
|
||||
* (like the old "type goes to document, nothing happens" bug) is caught before
|
||||
* release rather than by a user with a companion controller.
|
||||
*/
|
||||
describe('isTextField', () => {
|
||||
it('accepts text-like inputs and textareas', () => {
|
||||
const text = document.createElement('input')
|
||||
text.type = 'text'
|
||||
const search = document.createElement('input')
|
||||
search.type = 'search'
|
||||
const area = document.createElement('textarea')
|
||||
expect(isTextField(text)).toBe(true)
|
||||
expect(isTextField(search)).toBe(true)
|
||||
expect(isTextField(area)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-text controls and null', () => {
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
expect(isTextField(checkbox)).toBe(false)
|
||||
expect(isTextField(document.createElement('button'))).toBe(false)
|
||||
expect(isTextField(document.createElement('div'))).toBe(false)
|
||||
expect(isTextField(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('typeKeyIntoField', () => {
|
||||
let input: HTMLInputElement
|
||||
let inputEvents: number
|
||||
|
||||
beforeEach(() => {
|
||||
input = document.createElement('input')
|
||||
input.type = 'search'
|
||||
document.body.appendChild(input)
|
||||
inputEvents = 0
|
||||
input.addEventListener('input', () => { inputEvents++ })
|
||||
})
|
||||
|
||||
it('inserts printable characters at the caret and fires input', () => {
|
||||
typeKeyIntoField(input, 'b')
|
||||
typeKeyIntoField(input, 't')
|
||||
typeKeyIntoField(input, 'c')
|
||||
expect(input.value).toBe('btc')
|
||||
expect(input.selectionStart).toBe(3)
|
||||
expect(inputEvents).toBe(3)
|
||||
})
|
||||
|
||||
it('inserts a character in the middle of existing text', () => {
|
||||
input.value = 'bc'
|
||||
input.selectionStart = input.selectionEnd = 1
|
||||
typeKeyIntoField(input, 't')
|
||||
expect(input.value).toBe('btc')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('backspace deletes the char before the caret', () => {
|
||||
input.value = 'btc'
|
||||
input.selectionStart = input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'Backspace')
|
||||
expect(input.value).toBe('bt')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('backspace removes the active selection', () => {
|
||||
input.value = 'bitcoin'
|
||||
input.selectionStart = 0
|
||||
input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'Backspace')
|
||||
expect(input.value).toBe('coin')
|
||||
expect(input.selectionStart).toBe(0)
|
||||
})
|
||||
|
||||
it('arrow keys move the caret without changing the value', () => {
|
||||
input.value = 'abc'
|
||||
input.selectionStart = input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'ArrowLeft')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
expect(input.value).toBe('abc')
|
||||
})
|
||||
|
||||
it('Enter on a single-line input is left for the app to handle', () => {
|
||||
input.value = 'query'
|
||||
input.selectionStart = input.selectionEnd = 5
|
||||
const consumed = typeKeyIntoField(input, 'Enter')
|
||||
expect(consumed).toBe(false)
|
||||
expect(input.value).toBe('query')
|
||||
})
|
||||
|
||||
it('Enter inserts a newline in a textarea', () => {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = 'a'
|
||||
area.selectionStart = area.selectionEnd = 1
|
||||
expect(typeKeyIntoField(area, 'Enter')).toBe(true)
|
||||
expect(area.value).toBe('a\n')
|
||||
})
|
||||
|
||||
it('non-text keys are not consumed as editing', () => {
|
||||
input.value = 'x'
|
||||
input.selectionStart = input.selectionEnd = 1
|
||||
expect(typeKeyIntoField(input, 'Escape')).toBe(false)
|
||||
expect(typeKeyIntoField(input, 'Tab')).toBe(false)
|
||||
expect(input.value).toBe('x')
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,103 @@ function mapKey(xdotoolKey: string): string {
|
||||
return KEY_MAP[xdotoolKey] ?? xdotoolKey
|
||||
}
|
||||
|
||||
/** <input> types that accept free-text entry (so we should type into them). */
|
||||
const TEXT_INPUT_TYPES = new Set([
|
||||
'text', 'search', 'url', 'tel', 'password', 'email', 'number', '',
|
||||
])
|
||||
|
||||
export function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
|
||||
if (!el) return false
|
||||
if (el.tagName === 'TEXTAREA') return true
|
||||
if (el.tagName === 'INPUT') {
|
||||
const type = ((el as HTMLInputElement).type || 'text').toLowerCase()
|
||||
return TEXT_INPUT_TYPES.has(type)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* elementFromPoint that descends through SAME-ORIGIN iframes, so the cursor
|
||||
* can target elements *inside* embedded apps (gitea, uptime-kuma, AIUI — any
|
||||
* app served same-origin via /app/… or /aiui/). Cross-origin iframes (apps on
|
||||
* direct ports) are opaque to the parent by browser security policy, so the
|
||||
* deepest reachable element there is the <iframe> itself.
|
||||
*/
|
||||
function deepElementFromPoint(x: number, y: number): Element | null {
|
||||
let cx = x
|
||||
let cy = y
|
||||
let el = document.elementFromPoint(cx, cy)
|
||||
let guard = 0
|
||||
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
|
||||
let doc: Document | null = null
|
||||
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
|
||||
if (!doc) break
|
||||
const rect = el.getBoundingClientRect()
|
||||
cx -= rect.left
|
||||
cy -= rect.top
|
||||
const inner = doc.elementFromPoint(cx, cy)
|
||||
if (!inner || inner === el) break
|
||||
el = inner
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/** The actually-focused element, descending through same-origin iframes. */
|
||||
function deepActiveElement(): Element | null {
|
||||
let el: Element | null = document.activeElement
|
||||
let guard = 0
|
||||
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
|
||||
let doc: Document | null = null
|
||||
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
|
||||
if (!doc || !doc.activeElement || doc.activeElement === doc.body) break
|
||||
el = doc.activeElement
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a key to a focused text field. Synthetic KeyboardEvents do NOT mutate
|
||||
* input values (browser security), so we edit `.value` at the caret directly
|
||||
* and fire an `input` event so Vue v-model / reactive search pick it up.
|
||||
* Returns true if the key was consumed as text editing.
|
||||
*/
|
||||
export function typeKeyIntoField(el: HTMLInputElement | HTMLTextAreaElement, key: string): boolean {
|
||||
const value = el.value
|
||||
const start = el.selectionStart ?? value.length
|
||||
const end = el.selectionEnd ?? value.length
|
||||
const setCaret = (pos: number) => { try { el.selectionStart = el.selectionEnd = pos } catch { /* e.g. number inputs */ } }
|
||||
const replaceSelection = (text: string) => {
|
||||
el.value = value.slice(0, start) + text + value.slice(end)
|
||||
setCaret(start + text.length)
|
||||
}
|
||||
|
||||
if (key === 'Backspace') {
|
||||
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
|
||||
else if (start > 0) { el.value = value.slice(0, start - 1) + value.slice(end); setCaret(start - 1) }
|
||||
else return true
|
||||
} else if (key === 'Delete') {
|
||||
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
|
||||
else { el.value = value.slice(0, start) + value.slice(start + 1); setCaret(start) }
|
||||
} else if (key === 'ArrowLeft') {
|
||||
setCaret(Math.max(0, start - 1))
|
||||
} else if (key === 'ArrowRight') {
|
||||
setCaret(Math.min(value.length, end + 1))
|
||||
} else if (key === 'Home') {
|
||||
setCaret(0)
|
||||
} else if (key === 'End') {
|
||||
setCaret(value.length)
|
||||
} else if (key === 'Enter') {
|
||||
if (el.tagName === 'TEXTAREA') replaceSelection('\n')
|
||||
else return false // let the app's keydown handler act (e.g. search submit)
|
||||
} else if (key.length === 1) {
|
||||
replaceSelection(key) // printable character
|
||||
} else {
|
||||
return false // Tab / Escape / F-keys / etc. — not text editing
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
}
|
||||
|
||||
function handleMessage(data: string) {
|
||||
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
|
||||
try {
|
||||
@@ -125,9 +222,17 @@ function handleMessage(data: string) {
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
|
||||
}
|
||||
// Keep existing keydown/keyup for backward compat with non-arcade UI navigation
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
// Deliver the key to the actually-focused element (descending into
|
||||
// same-origin iframes) so it reaches embedded-app inputs and search
|
||||
// boxes, not just the top-level document.
|
||||
const focused = deepActiveElement()
|
||||
const keyTarget: EventTarget = focused ?? document
|
||||
keyTarget.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
// Synthetic key events never insert text, so edit the field directly.
|
||||
if (isTextField(focused)) {
|
||||
typeKeyIntoField(focused, key)
|
||||
}
|
||||
keyTarget.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
break
|
||||
}
|
||||
case 'm': {
|
||||
@@ -135,16 +240,29 @@ function handleMessage(data: string) {
|
||||
break
|
||||
}
|
||||
case 'c': {
|
||||
const target = document.elementFromPoint(cursorX, cursorY)
|
||||
const target = deepElementFromPoint(cursorX, cursorY)
|
||||
if (target) {
|
||||
if (cursorEl) {
|
||||
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
|
||||
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, cancelable: true,
|
||||
const eventInit: MouseEventInit = {
|
||||
bubbles: true, cancelable: true, view: window,
|
||||
clientX: cursorX, clientY: cursorY,
|
||||
}))
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('mousedown', eventInit))
|
||||
target.dispatchEvent(new MouseEvent('mouseup', eventInit))
|
||||
target.dispatchEvent(new MouseEvent('click', eventInit))
|
||||
// A synthetic click does NOT move keyboard focus the way a real click
|
||||
// does, so the app-store search box (and any input) would stay
|
||||
// unfocused and untypable. Explicitly focus the nearest focusable
|
||||
// element — for same-origin iframe targets this focuses inside the app.
|
||||
const focusable = (target.closest?.(
|
||||
'input, textarea, select, button, a[href], [contenteditable], [tabindex]',
|
||||
) ?? target) as HTMLElement
|
||||
if (typeof focusable.focus === 'function') {
|
||||
focusable.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
<Transition name="app-launcher">
|
||||
<div
|
||||
v-if="store.isOpen"
|
||||
class="fixed inset-0 z-[2400] flex items-center justify-center p-0 md:p-10"
|
||||
class="fixed inset-0 z-[2400] flex items-stretch justify-stretch p-0"
|
||||
@click.self="store.close()"
|
||||
>
|
||||
<!-- Backdrop - blur like spotlight -->
|
||||
<div class="app-launcher-backdrop absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div class="app-launcher-backdrop absolute inset-0 bg-black/75 backdrop-blur-md"></div>
|
||||
|
||||
<!-- Panel - inset with margins, glass style like spotlight -->
|
||||
<!-- Panel - full-screen overlay -->
|
||||
<div
|
||||
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none md:rounded-2xl shadow-2xl"
|
||||
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none shadow-2xl"
|
||||
:class="panelClasses"
|
||||
style="border-radius: 0;"
|
||||
>
|
||||
<!-- Header bar - sticky on mobile -->
|
||||
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
|
||||
@@ -69,7 +70,7 @@
|
||||
<!-- Loading indicator -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="animate-spin h-8 w-8 text-white/70" xmlns="http://www.w3.org/2000/svg" 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>
|
||||
@@ -398,7 +399,7 @@ function injectScrollbarHideIfSameOrigin() {
|
||||
const panelClasses = [
|
||||
'glass-card',
|
||||
'w-full h-full',
|
||||
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
|
||||
'max-w-none max-h-none',
|
||||
]
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
interface MapNode {
|
||||
@@ -36,6 +36,11 @@ type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; tar
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const graphSignature = computed(() => JSON.stringify({
|
||||
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
|
||||
links: props.links.map(l => [l.source, l.target]),
|
||||
}))
|
||||
|
||||
function trustColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'trusted': return '#4ade80'
|
||||
@@ -50,6 +55,7 @@ function nodeRadius(n: MapNode): number {
|
||||
}
|
||||
|
||||
function render() {
|
||||
simulation?.stop()
|
||||
const svg = d3.select(svgRef.value!)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
@@ -160,7 +166,7 @@ onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(() => [props.nodes, props.links], () => render(), { deep: true })
|
||||
watch(graphSignature, () => render())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
/** Shape of GET /electrs-status (see core electrs_status.rs ElectrsSyncStatus). */
|
||||
export interface ElectrsSyncStatus {
|
||||
indexed_height: number
|
||||
bitcoin_height: number
|
||||
network_height: number
|
||||
progress_pct: number
|
||||
status: string // "starting" | "waiting" | "syncing" | "indexing" | "synced" | "error"
|
||||
stale: boolean
|
||||
error: string | null
|
||||
index_size: string | null
|
||||
tor_onion: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls GET /electrs-status while active. Used to show an ElectrumX sync screen
|
||||
* *before* the real Electrum UI — the Electrum server only accepts client
|
||||
* connections (and the UI only works) once the on-chain index is built, which
|
||||
* is a long initial process.
|
||||
*
|
||||
* Fails OPEN: if the status can't be fetched we report not-syncing, so a status
|
||||
* outage never blocks the normal iframe path. We only gate the UI when we
|
||||
* positively know the index is still being built (status !== "synced").
|
||||
*/
|
||||
export function useElectrsSync() {
|
||||
const status = ref<ElectrsSyncStatus | null>(null)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch('/electrs-status', { cache: 'no-store' })
|
||||
if (res.ok) status.value = (await res.json()) as ElectrsSyncStatus
|
||||
} catch {
|
||||
/* keep last known value; fail open */
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer) return
|
||||
void poll()
|
||||
timer = setInterval(() => void poll(), 8000)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** True only when we know ElectrumX is still building its index. */
|
||||
const syncing = computed(() => !!status.value && status.value.status !== 'synced')
|
||||
|
||||
onUnmounted(stop)
|
||||
return { status, syncing, start, stop }
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,4 +17,8 @@ describe('explainReceiveAddressFailure', () => {
|
||||
it('explains lnd transport failures', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
|
||||
})
|
||||
|
||||
it('keeps bitcoin address generation failures visible', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('Bitcoin address generation failed: LND is not ready'))).toContain('Bitcoin address generation failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Open a URL in the device's real browser.
|
||||
*
|
||||
* In a normal mobile/desktop browser this is just `window.open(_blank)`. Inside
|
||||
* the Android companion app the page runs in a WebView where `window.open` is
|
||||
* unreliable (noopener/noreferrer can suppress onCreateWindow), so the native
|
||||
* shell injects a `window.ArchipelagoNative.openExternal(url)` bridge that hands
|
||||
* the URL to an ACTION_VIEW intent. We prefer the bridge when present and fall
|
||||
* back to `window.open` otherwise — so the working mobile-browser path is
|
||||
* untouched.
|
||||
*/
|
||||
interface ArchipelagoNativeBridge {
|
||||
openExternal?: (url: string) => void
|
||||
}
|
||||
|
||||
export function openExternalUrl(url: string): void {
|
||||
if (!url) return
|
||||
const native = (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge })
|
||||
.ArchipelagoNative
|
||||
if (native && typeof native.openExternal === 'function') {
|
||||
native.openExternal(url)
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
<!-- Registry list -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Registries</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors w-full sm:w-auto"
|
||||
@click="openAddRegistry"
|
||||
>+ Add registry</button>
|
||||
</div>
|
||||
@@ -118,7 +118,7 @@
|
||||
<!-- Back link -->
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2"
|
||||
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center justify-center gap-2 sm:w-auto"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
:refresh-key="refreshKey"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
:electrs-sync="electrsSync"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@@ -106,6 +107,8 @@ import {
|
||||
import { launchBlockedReason } from './apps/appsConfig'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl } from '@/utils/openExternal'
|
||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
@@ -155,6 +158,23 @@ const blockedReason = computed(() => launchBlockedReason(appId.value, packageEnt
|
||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
// serves clients once its index is built). Poll /electrs-status while this is
|
||||
// the Electrum app; pass the status to the frame only while still syncing.
|
||||
const isElectrsApp = computed(() =>
|
||||
['electrumx', 'electrs-ui', 'archy-electrs-ui'].includes(appId.value)
|
||||
)
|
||||
const { status: electrsStatus, syncing: electrsSyncing, start: startElectrsPoll, stop: stopElectrsPoll } =
|
||||
useElectrsSync()
|
||||
const electrsSync = computed(() =>
|
||||
isElectrsApp.value && electrsSyncing.value ? electrsStatus.value : null
|
||||
)
|
||||
watch(
|
||||
isElectrsApp,
|
||||
(on) => { if (on) startElectrsPoll(); else stopElectrsPoll() },
|
||||
{ immediate: true }
|
||||
)
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
const screensaverSuppressedApps = new Set([
|
||||
'indeedhub',
|
||||
@@ -294,12 +314,12 @@ function startLoadTimeout() {
|
||||
}
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
}
|
||||
|
||||
function iframeGoBack() {
|
||||
|
||||
+17
-15
@@ -244,10 +244,10 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="credentialModal.show"
|
||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6"
|
||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-stretch justify-stretch bg-black/80 backdrop-blur-md p-0"
|
||||
@click.self="closeCredentialModal"
|
||||
>
|
||||
<div class="sideload-modal credential-modal">
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
@@ -259,7 +259,7 @@
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
@@ -802,15 +802,24 @@ async function submitSideload() {
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
.credential-modal {
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
|
||||
border-radius: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: none;
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -818,11 +827,4 @@ async function submitSideload() {
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+17
-35
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="chat-fullscreen">
|
||||
<!-- Close button (desktop: top-right pill) -->
|
||||
<!-- Close button + connection indicator (desktop: top-right pill) -->
|
||||
<div class="chat-mode-pill hidden md:flex">
|
||||
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
||||
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -8,11 +8,16 @@
|
||||
</svg>
|
||||
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="aiuiConnected"
|
||||
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
|
||||
:title="t('chat.aiuiConnected')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator while checking availability -->
|
||||
<!-- Loading indicator while iframe loads -->
|
||||
<Transition name="fade">
|
||||
<div v-if="aiuiAvailable === null" class="chat-loading" role="status" aria-live="polite">
|
||||
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="chat-loading-spinner" aria-hidden="true" />
|
||||
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
|
||||
@@ -27,24 +32,23 @@
|
||||
:src="aiuiUrl"
|
||||
:title="t('chat.aiAssistant')"
|
||||
class="chat-iframe chat-iframe-mobile"
|
||||
|
||||
allow="microphone"
|
||||
style="background: transparent"
|
||||
/>
|
||||
|
||||
<!-- Fallback when AIUI is not deployed -->
|
||||
<div v-else-if="aiuiAvailable === false" class="chat-placeholder">
|
||||
<div class="chat-placeholder-inner glass-card p-8">
|
||||
<!-- Fallback when no AIUI URL configured -->
|
||||
<div v-else class="chat-placeholder">
|
||||
<div class="chat-placeholder-inner">
|
||||
<div class="chat-placeholder-icon">
|
||||
<svg class="w-8 h-8 text-white/40" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
|
||||
<p class="text-white/80 mb-4 leading-relaxed">
|
||||
<p class="text-white/60 mb-4 leading-relaxed">
|
||||
{{ t('chat.notConfigured') }}
|
||||
</p>
|
||||
<p class="text-sm text-white/50">
|
||||
<p class="text-xs text-white/30">
|
||||
{{ t('chat.deployCta') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,37 +67,16 @@ const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
let broker: ContextBroker | null = null
|
||||
|
||||
const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false = result
|
||||
|
||||
const aiuiUrl = computed(() => {
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
|
||||
// In production, only return the URL if we've confirmed AIUI files exist
|
||||
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&hideClose=true&v=${Date.now()}`
|
||||
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
|
||||
return ''
|
||||
})
|
||||
|
||||
/** Check if AIUI is actually deployed by fetching its index.html */
|
||||
async function checkAiuiAvailable() {
|
||||
if (import.meta.env.VITE_AIUI_URL) {
|
||||
aiuiAvailable.value = true
|
||||
return
|
||||
}
|
||||
if (!import.meta.env.PROD) {
|
||||
aiuiAvailable.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/aiui/', { method: 'HEAD' })
|
||||
// If we get HTML back (200), AIUI is deployed. If 404/403, it's not.
|
||||
aiuiAvailable.value = res.ok
|
||||
} catch {
|
||||
aiuiAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeChat() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
@@ -111,13 +94,12 @@ function onAiuiMessage(event: MessageEvent) {
|
||||
} catch { return }
|
||||
// Listen for ready messages from AIUI iframe
|
||||
if (event.data?.type === 'ready') {
|
||||
// AIUI connected - could use for future features
|
||||
aiuiConnected.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onAiuiMessage)
|
||||
await checkAiuiAvailable()
|
||||
if (aiuiUrl.value) {
|
||||
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
|
||||
broker.start()
|
||||
|
||||
@@ -86,8 +86,8 @@
|
||||
<div
|
||||
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
||||
:class="[
|
||||
'h-full dashboard-scroll-panel mobile-scroll-pad',
|
||||
route.path === '/dashboard/mesh' ? 'mesh-dashboard-panel' : '',
|
||||
'h-full',
|
||||
route.path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '',
|
||||
mobileTabPaddingTop ? 'overflow-y-auto' : ''
|
||||
]"
|
||||
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
||||
|
||||
@@ -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'
|
||||
|
||||
+65
-66
@@ -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"
|
||||
/>
|
||||
<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>
|
||||
<!-- 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"
|
||||
/>
|
||||
|
||||
<!-- 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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -69,12 +69,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<!-- Git path: one-shot pull+rebuild+restart -->
|
||||
<button
|
||||
v-if="updateMethod === 'git' && !applying"
|
||||
@click="requestGitApply"
|
||||
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
||||
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 sm:w-auto"
|
||||
>
|
||||
{{ t('systemUpdate.pullAndRebuild') }}
|
||||
</button>
|
||||
@@ -82,14 +82,14 @@
|
||||
<button
|
||||
v-if="updateMethod !== 'git' && !downloading && !applying && !downloaded"
|
||||
@click="downloadUpdate"
|
||||
class="glass-button rounded-lg px-6 py-2 text-sm font-medium"
|
||||
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium sm:w-auto"
|
||||
>
|
||||
{{ t('systemUpdate.downloadUpdate') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="updateMethod !== 'git' && downloaded && !applying"
|
||||
@click="requestApply"
|
||||
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
||||
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 sm:w-auto"
|
||||
>
|
||||
{{ t('systemUpdate.applyUpdate') }}
|
||||
</button>
|
||||
@@ -145,8 +145,16 @@
|
||||
<!-- Applying -->
|
||||
<div v-if="applying" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
|
||||
<svg
|
||||
class="w-5 h-5 shrink-0 animate-spin text-orange-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.2"></circle>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,22 +270,25 @@
|
||||
<!-- Actions row -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<button
|
||||
@click="checkForUpdates"
|
||||
:disabled="loading"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
|
||||
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40 sm:w-auto"
|
||||
>
|
||||
{{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="rollbackAvailable"
|
||||
@click="requestRollback"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20"
|
||||
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20 sm:w-auto"
|
||||
>
|
||||
{{ t('systemUpdate.rollback') }}
|
||||
</button>
|
||||
<RouterLink to="/dashboard/settings" class="glass-button rounded-lg px-5 py-2 text-sm font-medium text-center">
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium text-center sm:w-auto"
|
||||
>
|
||||
{{ t('systemUpdate.backToSettings') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
@@ -650,12 +661,28 @@ const installStartedAt = ref<number>(0)
|
||||
const installElapsedSec = ref(0)
|
||||
let installPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let installElapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
let installReadyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const installElapsedLabel = computed(() => {
|
||||
const s = installElapsedSec.value
|
||||
if (s < 60) return `Elapsed: ${s}s`
|
||||
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
|
||||
})
|
||||
function clearInstallTimers() {
|
||||
if (installPollTimer) {
|
||||
clearInterval(installPollTimer)
|
||||
installPollTimer = null
|
||||
}
|
||||
if (installElapsedTimer) {
|
||||
clearInterval(installElapsedTimer)
|
||||
installElapsedTimer = null
|
||||
}
|
||||
if (installReadyTimer) {
|
||||
clearTimeout(installReadyTimer)
|
||||
installReadyTimer = null
|
||||
}
|
||||
}
|
||||
function startInstallOverlay(targetVersion: string) {
|
||||
clearInstallTimers()
|
||||
installing.value = true
|
||||
installStage.value = 'applying'
|
||||
installTargetVersion.value = targetVersion
|
||||
@@ -672,7 +699,7 @@ function startInstallOverlay(targetVersion: string) {
|
||||
// Start polling /health after a short delay — the backend restarts 2s
|
||||
// after replying to update.apply, so an immediate poll would see the
|
||||
// old backend and conclude nothing happened.
|
||||
setTimeout(() => {
|
||||
installReadyTimer = setTimeout(() => {
|
||||
installStage.value = 'restarting'
|
||||
installPollTimer = setInterval(pollHealth, 1500)
|
||||
}, 2500)
|
||||
@@ -687,22 +714,37 @@ async function pollHealth() {
|
||||
installStage.value = 'ready'
|
||||
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
|
||||
// Brief pause so the user sees the "Ready" state before the reload.
|
||||
setTimeout(() => { window.location.reload() }, 1200)
|
||||
installReadyTimer = setTimeout(() => { window.location.reload() }, 1200)
|
||||
} else {
|
||||
// Backend is up but still reporting the old version — frontend
|
||||
// and backend are mid-swap. Signal to the user.
|
||||
installStage.value = 'reconnecting'
|
||||
void confirmBackendUpdateSettled()
|
||||
}
|
||||
} catch {
|
||||
// Fetch fails while the server is mid-restart. Stay in 'restarting'.
|
||||
}
|
||||
}
|
||||
async function confirmBackendUpdateSettled() {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
current_version: string
|
||||
update_in_progress: boolean
|
||||
}>({ method: 'update.status' })
|
||||
if (!res.update_in_progress && installStage.value !== 'ready' && installStage.value !== 'stalled') {
|
||||
installStage.value = 'ready'
|
||||
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
|
||||
installReadyTimer = setTimeout(() => { window.location.reload() }, 800)
|
||||
}
|
||||
} catch {
|
||||
// Keep waiting on /health.
|
||||
}
|
||||
}
|
||||
function reloadNow() { window.location.reload() }
|
||||
// Cleanup if the component is torn down mid-install (unlikely but safe).
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
onBeforeUnmount(() => {
|
||||
if (installPollTimer) clearInterval(installPollTimer)
|
||||
if (installElapsedTimer) clearInterval(installElapsedTimer)
|
||||
clearInstallTimers()
|
||||
})
|
||||
|
||||
const lastCheckDisplay = computed(() => {
|
||||
|
||||
@@ -9,8 +9,40 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||
index is still being built (the Electrum server can't serve clients
|
||||
until then). Mirrors the Fedimint Guardian "wait page" design. -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8 w-full max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
|
||||
<p class="text-white/50 text-sm mb-5">
|
||||
ElectrumX is building its index from the blockchain. The UI opens
|
||||
automatically once it's ready — you can keep using the rest of Archipelago.
|
||||
</p>
|
||||
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400/80 transition-all duration-700"
|
||||
:style="{ width: `${Math.min(100, Math.max(2, electrsSync.progress_pct)).toFixed(1)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm font-medium mb-1">{{ electrsSync.progress_pct.toFixed(1) }}%</p>
|
||||
<p class="text-white/40 text-xs">
|
||||
Block {{ electrsSync.indexed_height.toLocaleString() }} of {{ electrsSync.network_height.toLocaleString() }}
|
||||
<template v-if="electrsSync.index_size"> · {{ electrsSync.index_size }} indexed</template>
|
||||
</p>
|
||||
<p v-if="electrsSync.stale" class="text-yellow-400/70 text-xs mt-2">Reconnecting to ElectrumX…</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked"
|
||||
v-if="appUrl && !iframeBlocked && !electrsSync"
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
@@ -79,6 +111,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
@@ -91,6 +124,9 @@ const props = defineProps<{
|
||||
refreshKey: number
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
// Non-null only for ElectrumX while its index is still building — shows the
|
||||
// sync screen and gates the iframe until status flips to "synced".
|
||||
electrsSync?: ElectrsSyncStatus | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -27,7 +27,6 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'tailscale': 8240,
|
||||
'fedimintd': 8175,
|
||||
'fedimint-gateway': 8176,
|
||||
'dwn': 3100,
|
||||
'endurain': 8080,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>([
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -85,8 +85,8 @@
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6" @click.self="closeCredentialModal">
|
||||
<div class="sideload-modal credential-modal">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
@@ -98,7 +98,7 @@
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
@@ -328,24 +328,32 @@ function scrollToPage(index: number) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal {
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
|
||||
border-radius: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal. The body scrolls if content overflows
|
||||
rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('generate-invite', 'trusted')"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="generatingInvite"
|
||||
>
|
||||
{{ generatingInvite && inviteType === 'trusted' ? 'Generating...' : 'Generate Code' }}
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('generate-invite', 'observer')"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="generatingInvite"
|
||||
>
|
||||
{{ generatingInvite && inviteType === 'observer' ? 'Generating...' : 'Generate Code' }}
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('show-join')"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Enter Code
|
||||
</button>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('sync')"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="syncing"
|
||||
>
|
||||
{{ syncing ? 'Syncing...' : 'Sync Now' }}
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
<th
|
||||
v-for="node in sortedNodes"
|
||||
:key="node.node_id"
|
||||
class="fleet-matrix-header-cell font-mono"
|
||||
class="fleet-matrix-header-cell"
|
||||
:title="fleetNodeSubtitle(node)"
|
||||
>
|
||||
{{ node.node_id.slice(0, 6) }}
|
||||
{{ fleetNodeDisplayName(node) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -39,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type FleetNode, getContainerState } from './useFleetData'
|
||||
import { type FleetNode, getContainerState, fleetNodeDisplayName, fleetNodeSubtitle } from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
nodes: FleetNode[]
|
||||
|
||||
@@ -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 || fleetNodeDisplayName(node) }}</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
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -170,21 +187,59 @@ export function normalizeNodeHistoryResponse(data: {
|
||||
return []
|
||||
}
|
||||
|
||||
type FleetCache = {
|
||||
nodes: FleetNode[]
|
||||
fleetAlerts: FleetAlert[]
|
||||
lastRefreshed: string
|
||||
selectedNodeId: string | null
|
||||
sortBy: SortOption
|
||||
}
|
||||
|
||||
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v1'
|
||||
|
||||
function readFleetCache(): Partial<FleetCache> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(FLEET_CACHE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as Partial<FleetCache>
|
||||
return {
|
||||
nodes: Array.isArray(parsed.nodes) ? parsed.nodes.map(normalizeFleetNode) : [],
|
||||
fleetAlerts: Array.isArray(parsed.fleetAlerts) ? parsed.fleetAlerts : [],
|
||||
lastRefreshed: typeof parsed.lastRefreshed === 'string' ? parsed.lastRefreshed : '',
|
||||
selectedNodeId: typeof parsed.selectedNodeId === 'string' ? parsed.selectedNodeId : null,
|
||||
sortBy: parsed.sortBy === 'last-seen' || parsed.sortBy === 'name' ? parsed.sortBy : 'status',
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeFleetCache(state: FleetCache) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.sessionStorage.setItem(FLEET_CACHE_KEY, JSON.stringify(state))
|
||||
} catch {
|
||||
// Cache is opportunistic only.
|
||||
}
|
||||
}
|
||||
|
||||
// --- Composable ---
|
||||
|
||||
export function useFleetData() {
|
||||
const loading = ref(true)
|
||||
const cached = readFleetCache()
|
||||
const loading = ref(!(cached.nodes?.length ?? 0))
|
||||
const errorMessage = ref('')
|
||||
const nodes = ref<FleetNode[]>([])
|
||||
const fleetAlerts = ref<FleetAlert[]>([])
|
||||
const nodes = ref<FleetNode[]>(cached.nodes ?? [])
|
||||
const fleetAlerts = ref<FleetAlert[]>(cached.fleetAlerts ?? [])
|
||||
const refreshing = ref(false)
|
||||
const alertsLoading = ref(false)
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const selectedNodeId = ref<string | null>(cached.selectedNodeId ?? null)
|
||||
const nodeHistory = ref<NodeHistoryEntry[]>([])
|
||||
const nodeHistoryLoading = ref(false)
|
||||
const autoRefresh = ref(true)
|
||||
const lastRefreshed = ref('')
|
||||
const sortBy = ref<SortOption>('status')
|
||||
const lastRefreshed = ref(cached.lastRefreshed ?? '')
|
||||
const sortBy = ref<SortOption>(cached.sortBy ?? 'status')
|
||||
const chartWidth = ref(300)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -267,6 +322,13 @@ export function useFleetData() {
|
||||
if (data?.nodes) {
|
||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||
lastRefreshed.value = new Date().toISOString()
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (loading.value) {
|
||||
@@ -283,6 +345,13 @@ export function useFleetData() {
|
||||
})
|
||||
if (data?.alerts) {
|
||||
fleetAlerts.value = data.alerts
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, retry on next poll
|
||||
@@ -325,6 +394,13 @@ export function useFleetData() {
|
||||
} else {
|
||||
selectedNodeId.value = nodeId
|
||||
}
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
@@ -383,6 +459,23 @@ export function useFleetData() {
|
||||
} else {
|
||||
nodeHistory.value = []
|
||||
}
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
})
|
||||
|
||||
watch(sortBy, () => {
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
})
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -98,11 +98,14 @@
|
||||
<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>
|
||||
<button @click="$emit('closeWifi')" class="text-white/40 hover:text-white transition-colors">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="wifiScanning">
|
||||
<div class="space-y-3">
|
||||
@@ -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]
|
||||
|
||||
@@ -188,6 +188,18 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.84-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.84-alpha</span>
|
||||
<span class="text-xs text-white/40">June 11, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin trusted-node relay approvals now generate restricted txrelay credentials and restart the active Bitcoin backend so the new RPC whitelist is live.</p>
|
||||
<p>Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including txrelay secret injection and sendrawtransaction-focused permissions.</p>
|
||||
<p>The Bitcoin UI companion image is pinned for OTA updates, and container scanning now avoids getting stuck busy after timeout or error paths.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.83-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RouterLink } from 'vue-router'
|
||||
<template>
|
||||
<!-- App Registries Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">App registries</h2>
|
||||
<p class="text-sm text-white/60 mt-1">
|
||||
@@ -14,7 +14,7 @@ import { RouterLink } from 'vue-router'
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/settings/registries"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
>
|
||||
<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"
|
||||
|
||||
@@ -7,12 +7,15 @@ const { t } = useI18n()
|
||||
<template>
|
||||
<!-- System Updates Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/settings/update" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||
<RouterLink
|
||||
to="/dashboard/settings/update"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
>
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user