feat(demo): real registry UIs, IndeeHub iframe proxy, mempool tab, media Range

- App UIs now use the real registry shells with dummy data: bitcoin-ui for
  Bitcoin Core (Satoshi subversion) and Bitcoin Knots (Knots subversion) via
  per-path /app/bitcoin-{core,knots}/bitcoin-status; the real lnd-ui (mock
  /proxy/lnd/v1/getinfo+channels, /lnd-connect-info, /api/container/logs); the
  static fedimint-ui. ElectrumX already on the real electrs-ui. Custom mock UIs
  dropped — accurate UX.
- IndeeHub loads in the iframe: nginx reverse-proxies /app/indeedhub/ →
  indee.tx1138.com and strips X-Frame-Options/CSP (it blocked framing before).
- Mempool opens in a new tab (mempool.space can't be iframed).
- Cloud media playback: HTTP Range support in the curated-file server so audio/
  video can stream and seek (needs real files dropped into demo/files/).
- Dockerfile/.dockerignore copy docker/lnd-ui + docker/fedimint-ui.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-22 14:19:38 -04:00
parent a0f70b3949
commit cf5f6d021a
6 changed files with 142 additions and 69 deletions

View File

@ -12,6 +12,8 @@
docker/* docker/*
!docker/bitcoin-ui/ !docker/bitcoin-ui/
!docker/electrs-ui/ !docker/electrs-ui/
!docker/lnd-ui/
!docker/fedimint-ui/
# Allow backend source for ISO source builds # Allow backend source for ISO source builds
!core/ !core/

View File

@ -18,6 +18,8 @@ COPY neode-ui/ ./
# the Bitcoin UI mock shell and any curated cloud files dropped into demo/files. # the Bitcoin UI mock shell and any curated cloud files dropped into demo/files.
COPY docker/bitcoin-ui /docker/bitcoin-ui COPY docker/bitcoin-ui /docker/bitcoin-ui
COPY docker/electrs-ui /docker/electrs-ui COPY docker/electrs-ui /docker/electrs-ui
COPY docker/lnd-ui /docker/lnd-ui
COPY docker/fedimint-ui /docker/fedimint-ui
COPY demo/files /demo/files COPY demo/files /demo/files
# Expose port # Expose port

View File

@ -70,6 +70,20 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
# LND UI endpoints (polled by the lnd-ui shell)
location /proxy/ {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /lnd-connect-info {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Proxy FileBrowser API to mock backend (demo mode) # Proxy FileBrowser API to mock backend (demo mode)
location /app/filebrowser/ { location /app/filebrowser/ {
client_max_body_size 10G; client_max_body_size 10G;
@ -80,6 +94,19 @@ http {
proxy_request_buffering off; proxy_request_buffering off;
} }
# IndeeHub: reverse-proxy the real site same-origin and strip its
# X-Frame-Options / CSP so it can load inside the in-app iframe.
location /app/indeedhub/ {
proxy_pass https://indee.tx1138.com/;
proxy_http_version 1.1;
proxy_set_header Host indee.tx1138.com;
proxy_ssl_server_name on;
proxy_set_header Accept-Encoding "";
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
}
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves # Proxy every other app UI (/app/<id>/) to the mock backend, which serves
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the # the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
# generic "Not available in the demo" notice for the rest. # generic "Not available in the demo" notice for the rest.

View File

@ -1087,65 +1087,83 @@ app.get('/electrs-status', (_req, res) => {
}) })
}) })
app.get(['/app/lnd/', '/app/lnd-ui/', '/app/archy-lnd-ui/', '/app/thunderhub/'], (_req, res) => { // Serve a real registry UI shell (docker/<dir>/index.html), filled by the
res.type('html').send(demoAppShell('LND', 'Lightning Network Daemon · signet', '/assets/img/app-icons/lnd.svg', ` // dummy endpoints each shell polls (defined below). This gives accurate UX.
<div class="grid"> function serveDockerUI(dir) {
<div class="card"><div class="k">Node</div><div class="v"><span class="badge"> Synced to chain</span></div></div> return async (_req, res) => {
<div class="card"><div class="k">Channel balance</div><div class="v">8,250,000 sat</div></div> try {
<div class="card"><div class="k">On-chain balance</div><div class="v">2,350,000 sat</div></div> const html = await fs.readFile(path.join(__dirname, '..', 'docker', dir, 'index.html'), 'utf8')
<div class="card"><div class="k">Active channels</div><div class="v">4</div></div> res.type('html').send(html)
<div class="card"><div class="k">Peers</div><div class="v">11</div></div> } catch (e) {
<div class="card"><div class="k">Routing (30d)</div><div class="v">1,284 sat</div></div> res.status(500).type('text/plain').send(`Unable to load ${dir} mock: ${e.message}`)
</div> }
<div class="card"><div class="k" style="margin-bottom:8px">Channels</div> }
<table><thead><tr><th>Peer</th><th>Capacity</th><th>Local / Remote</th><th>State</th></tr></thead><tbody> }
<tr><td>ACINQ</td><td>5,000,000</td><td>2,450,000 / 2,550,000</td><td><span class="badge">active</span></td></tr>
<tr><td>Wallet of Satoshi</td><td>2,000,000</td><td>1,200,000 / 800,000</td><td><span class="badge">active</span></td></tr>
<tr><td>Voltage</td><td>10,000,000</td><td>4,500,000 / 5,500,000</td><td><span class="badge">active</span></td></tr>
<tr><td>Kraken</td><td>3,000,000</td><td>1,800,000 / 1,200,000</td><td><span class="badge">active</span></td></tr>
</tbody></table>
</div>`))
})
app.get(['/app/bitcoin-core/'], (_req, res) => { // ── Bitcoin Core + Knots: the real bitcoin-ui shell, per-implementation status ─
res.type('html').send(demoAppShell('Bitcoin Core', 'Full node · signet', '/assets/img/app-icons/bitcoin-core.png', ` app.get('/app/bitcoin-core/', serveDockerUI('bitcoin-ui'))
<div class="grid"> app.get('/app/bitcoin-knots/', serveDockerUI('bitcoin-ui'))
<div class="card"><div class="k">Status</div><div class="v"><span class="badge"> Synced</span></div></div> function bitcoinStatusPayload(impl) {
<div class="card"><div class="k">Block height</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div> const net = mockBitcoinNetworkInfo()
<div class="card"><div class="k">Chain</div><div class="v">signet</div></div> net.subversion = impl === 'knots' ? '/Satoshi:28.1.0/Knots:20250305/' : '/Satoshi:28.4.0/'
<div class="card"><div class="k">Connections</div><div class="v">18</div></div> return {
<div class="card"><div class="k">Mempool</div><div class="v">12,840 txs</div></div> ok: true, stale: false, updated_at_ms: Date.now(), error: null,
<div class="card"><div class="k">Version</div><div class="v mono">v28.4.0</div></div> blockchain_info: mockBitcoinBlockchainInfo(),
</div> network_info: net,
<div class="card"><div class="k" style="margin-bottom:8px">Peers</div> index_info: {
<table><thead><tr><th>Address</th><th>Type</th><th>Height</th></tr></thead><tbody> txindex: { synced: true, best_block_height: 902418 },
<tr><td>node1.signetonion</td><td>outbound</td><td>902,418</td></tr> blockfilterindex: { synced: true, best_block_height: 902418 },
<tr><td>node2.signetonion</td><td>outbound</td><td>902,418</td></tr> },
<tr><td>192.168.1.42</td><td>inbound</td><td>902,417</td></tr> zmq_notifications: [
</tbody></table> { type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 },
</div>`)) { type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 },
}) ],
}
}
app.get('/app/bitcoin-core/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('core')))
app.get('/app/bitcoin-knots/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('knots')))
app.get(['/app/fedimint/', '/app/fedimintd/'], (_req, res) => { // ── LND: the real lnd-ui shell + the endpoints it polls (same-origin) ────────
res.type('html').send(demoAppShell('Fedimint Guardian', 'Archipelago Federation · 4-of-5 consensus', '/assets/img/app-icons/fedimint.png', ` app.get(['/app/lnd/', '/app/lnd-ui/', '/app/archy-lnd-ui/', '/app/thunderhub/'], serveDockerUI('lnd-ui'))
<div class="grid"> function lndGetinfo() {
<div class="card"><div class="k">Consensus</div><div class="v"><span class="badge"> Online</span></div></div> return {
<div class="card"><div class="k">Epoch</div><div class="v">48,217</div></div> alias: 'archipelago-lnd', identity_pubkey: '02' + randomHex(32),
<div class="card"><div class="k">Guardians online</div><div class="v">5 / 5</div></div> num_active_channels: 4, num_inactive_channels: 0, num_pending_channels: 0, num_peers: 11,
<div class="card"><div class="k">E-cash issued</div><div class="v">12,480,000 sat</div></div> block_height: 902418, block_hash: randomHex(32),
<div class="card"><div class="k">Lightning gateway</div><div class="v"><span class="badge"> Connected</span></div></div> synced_to_chain: true, synced_to_graph: true, version: '0.18.3-beta',
<div class="card"><div class="k">Block sync</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div> chains: [{ chain: 'bitcoin', network: 'signet' }],
</div> }
<div class="card"><div class="k" style="margin-bottom:8px">Guardians</div> }
<table><thead><tr><th>Guardian</th><th>Peer</th><th>Status</th></tr></thead><tbody> function lndChannels() {
<tr><td>Guardian Alpha</td><td>peer-0</td><td><span class="badge">online</span></td></tr> const peers = [['ACINQ', 5_000_000, 2_450_000], ['Wallet of Satoshi', 2_000_000, 1_200_000],
<tr><td>Guardian Beta</td><td>peer-1</td><td><span class="badge">online</span></td></tr> ['Voltage', 10_000_000, 4_500_000], ['Kraken', 3_000_000, 1_800_000]]
<tr><td>Guardian Gamma</td><td>peer-2</td><td><span class="badge">online</span></td></tr> return {
<tr><td>Guardian Delta</td><td>peer-3</td><td><span class="badge">online</span></td></tr> channels: peers.map(([alias, cap, local]) => ({
<tr><td>Guardian Epsilon</td><td>peer-4</td><td><span class="badge">online</span></td></tr> active: true, remote_pubkey: '02' + randomHex(32), peer_alias: alias,
</tbody></table> capacity: String(cap), local_balance: String(local), remote_balance: String(cap - local),
</div>`)) total_satoshis_sent: '12840', total_satoshis_received: '9120', private: false,
}) })),
}
}
app.get('/proxy/lnd/v1/getinfo', (_req, res) => res.json(lndGetinfo()))
app.get('/proxy/lnd/v1/channels', (_req, res) => res.json(lndChannels()))
app.get('/lnd-connect-info', (_req, res) => res.json({
getinfo: lndGetinfo(), channelCount: 4, cert_base: 'demo',
grpcReachable: true, restReachable: true,
tor_onion: 'lnd' + randomHex(16) + '.onion', error: null,
}))
app.get('/api/container/logs', (_req, res) => res.json({
logs: [
'[INF] LND: Version 0.18.3-beta commit=v0.18.3-beta',
'[INF] CHDB: Inserting 4 channel(s) into database',
'[INF] LND: Channel(s) active; synced_to_chain=true',
'[INF] PEER: Connected to 11 peers',
'[INF] RPCS: gRPC proxy started at 0.0.0.0:8080',
].join('\n'),
}))
// ── Fedimint: the real fedimint-ui shell (static) ────────────────────────────
app.get(['/app/fedimint/', '/app/fedimintd/'], serveDockerUI('fedimint-ui'))
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => { app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
res.json({ res.json({
@ -3705,12 +3723,31 @@ app.patch('/app/filebrowser/api/resources/*', (req, res) => {
// FileBrowser raw file content (text reads, blob/stream fetches) // FileBrowser raw file content (text reads, blob/stream fetches)
app.get('/app/filebrowser/api/raw/*', (req, res) => { app.get('/app/filebrowser/api/raw/*', (req, res) => {
const full = fbNormalize(req.params[0] || '') const full = fbNormalize(req.params[0] || '')
// Curated binary files live on disk and stream directly. // Curated binary files live on disk and stream directly, with HTTP Range
// support so audio/video can seek and play in the browser.
if (diskFilePaths[full]) { if (diskFilePaths[full]) {
const abs = diskFilePaths[full]
let stat
try { stat = fsSync.statSync(abs) } catch { return res.status(404).send('File not found') }
res.type(fbContentType(fbBase(full))) res.type(fbContentType(fbBase(full)))
return fsSync.createReadStream(diskFilePaths[full]).on('error', () => { res.setHeader('Accept-Ranges', 'bytes')
if (!res.headersSent) res.status(404).send('File not found') const range = req.headers.range
}).pipe(res) if (range) {
const m = /bytes=(\d*)-(\d*)/.exec(range)
let start = m && m[1] ? parseInt(m[1], 10) : 0
let end = m && m[2] ? parseInt(m[2], 10) : stat.size - 1
if (isNaN(start) || start < 0) start = 0
if (isNaN(end) || end >= stat.size) end = stat.size - 1
if (start > end) { res.status(416).setHeader('Content-Range', `bytes */${stat.size}`); return res.end() }
res.status(206)
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`)
res.setHeader('Content-Length', end - start + 1)
return fsSync.createReadStream(abs, { start, end })
.on('error', () => { if (!res.headersSent) res.status(404).end() }).pipe(res)
}
res.setHeader('Content-Length', stat.size)
return fsSync.createReadStream(abs)
.on('error', () => { if (!res.headersSent) res.status(404).send('File not found') }).pipe(res)
} }
const content = currentStore().files.contents[full] const content = currentStore().files.contents[full]
if (content === undefined) return res.status(404).send('File not found') if (content === undefined) return res.status(404).send('File not found')

View File

@ -53,15 +53,16 @@ export function clearDemoIntroSeen(): void {
// external site). Everything else shows "No demo" on a disabled install button // external site). Everything else shows "No demo" on a disabled install button
// and is not launchable. // and is not launchable.
const DEMO_EXTERNAL_URLS: Record<string, string> = { const DEMO_EXTERNAL_URLS: Record<string, string> = {
// Real, public sites loaded in the in-app iframe. // Real, public sites opened in a NEW TAB (they block iframing).
indeedhub: 'https://indee.tx1138.com/',
mempool: 'https://mempool.space/testnet', mempool: 'https://mempool.space/testnet',
'mempool-web': 'https://mempool.space/testnet', 'mempool-web': 'https://mempool.space/testnet',
} }
// Apps with a same-origin mock UI served by the demo backend. // Apps with a same-origin URL loaded in the in-app iframe — either a mock UI
// served by the demo backend, or a header-stripping reverse-proxy (IndeeHub).
const DEMO_MOCK_UI: Record<string, string> = { const DEMO_MOCK_UI: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/', indeedhub: '/app/indeedhub/',
'bitcoin-knots': '/app/bitcoin-knots/',
'bitcoin-core': '/app/bitcoin-core/', 'bitcoin-core': '/app/bitcoin-core/',
bitcoin: '/app/bitcoin-core/', bitcoin: '/app/bitcoin-core/',
'bitcoin-ui': '/app/bitcoin-ui/', 'bitcoin-ui': '/app/bitcoin-ui/',
@ -78,11 +79,11 @@ const DEMO_MOCK_UI: Record<string, string> = {
} }
/** /**
* Whether a demo app opens in a new tab. Nothing does now both real external * Whether a demo app opens in a new tab. Mempool.space sets X-Frame-Options and
* sites (mempool.space, indee.tx1138.com) load in the in-app iframe. * cannot be iframed, so it opens in a new tab; IndeeHub still loads in-iframe.
*/ */
export function isDemoExternal(_appId: string): boolean { export function isDemoExternal(appId: string): boolean {
return false return appId === 'mempool' || appId === 'mempool-web'
} }
/** Can this app be launched/installed in the demo? */ /** Can this app be launched/installed in the demo? */

View File

@ -157,7 +157,11 @@ export default defineConfig({
'/app/lnd': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, '/app/lnd': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/app/fedimint': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, '/app/fedimint': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/app/bitcoin-core': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, '/app/bitcoin-core': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/app/bitcoin-knots': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/electrs-status': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, '/electrs-status': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/proxy': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/lnd-connect-info': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/app/indeedhub': { target: 'https://indee.tx1138.com', changeOrigin: true, secure: false, rewrite: (p: string) => p.replace(/^\/app\/indeedhub/, '') },
// Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/) // Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/)
'/aiui': { '/aiui': {
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80', target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',