Compare commits

...

16 Commits

Author SHA1 Message Date
archipelago
5b75310e0b docs(demo): comprehensive build info, deploy steps, gotchas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:50:32 -04:00
archipelago
7efebb4a8c feat(demo): per-folder media merge + AIUI seed-chats bootstrap
- Curated files loader now MERGES per top-level folder: dropping real files into
  demo/files/Music/ swaps only Music and keeps the sample Documents/Photos/Videos
  (verified). Media plays with the Range support already in place.
- AIUI index.html: a ?seed bootstrap pre-loads the example "Content Showcase"
  conversation into AIUI's IndexedDB by calling the bundle's own
  seedPromptsToConversation() (identical to its /seed command), so the chat
  history isn't empty when the demo points users to "previous chats". Guarded by
  try/catch + an existence check; no-op without ?seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:45:26 -04:00
archipelago
445f08a5c1 feat(demo): iframe asset-rewrite proxy, AIUI mockArchy, QR 2s, dummy mints
- IndeeHub + Mempool: nginx reverse-proxy + strip X-Frame-Options/CSP + sub_filter
  rewrite of absolute asset paths so the frame-busting SPAs load in the iframe
  (mempool.space remains best-effort — third-party CSP/ws may still limit it).
- AIUI iframe gets ?mockArchy in demo → its built-in mock node data loads.
- Pay-with-mobile QR: invoice settles after ~2s (backend gate keyed by
  payment_hash) and the poll tightened to 1s, so the QR is visible before auto-pay.
- Wallet settings: dummy Cashu mints (4) + Fedimint federations (2, 222,500 sats),
  interactive per session (streaming.list/configure-mints, wallet.fedimint-list/
  join/balance).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:34:12 -04:00
archipelago
1b7335f4ac fix(demo): nostr-rs-relay icon (nostr.svg missing → nostrudel.svg)
The catalog pointed at a non-existent nostr.svg (handleImageError only falls
back .png→.svg, so an .svg miss stays broken). Point it at the existing nostr
icon. fedimint icon already uses fedimint.png (exists); the stale fedimint.jpg
request is resolved by /api/app-catalog now serving the local catalog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:23:25 -04:00
archipelago
c991e61a8f feat(demo): network/wallet dummy data — profits, federation, VPN, nostr, visibility
- wallet.networking-profits = 5,231,978 sats (content 3,180,000 / routing
  1,281,978 / relay 770,000); 6 labelled profit transactions added to the wallet
  history (1-2 per type: content sale, routing fee, file/mesh relay) — labels are
  production-ready.
- federation.list (the Web5 Federation container's method) now returns the 12
  demo nodes (was unhandled → empty).
- vpn.status: connected WireGuard with peers + traffic.
- nostr.list-relays / nostr.get-stats: 5 relays (3 connected).
- network.get/set-visibility: interactive, persisted per demo session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:18:29 -04:00
archipelago
b99c4a604f fix(demo): iframe mempool+indeehub directly, serve real UIs statically, AIUI canned
- Mempool and IndeeHub load their real site directly in the iframe (reverted the
  proxy/new-tab — per request "use https://indee.tx1138.com/").
- Real app UIs now served as whole static dirs under /app/<id>/ (express.static)
  so their bundled assets (qrcode.js, css, bg images) resolve; /app/<id>/assets/*
  redirect to the frontend's shared assets. Fixes the console 404 cascade.
- Bitcoin Core/Knots: register rpc/v1 + bitcoin-rpc on their paths (relay-status
  no longer 404s); per-impl bitcoin-status preserved.
- AIUI chat returns a fixed line in demo ("Not available in demo, check out the
  previous chats to experience AIUI") instead of calling Claude — no key spend.
- Add /api/app-catalog (serves the baked catalog) to stop that 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:45:04 -04:00
archipelago
cf5f6d021a 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>
2026-06-22 14:19:38 -04:00
archipelago
a0f70b3949 feat(demo): black-theme app UIs w/ icons, real ElectrumX UI, Core/Knots split
- Mock app UIs (ElectrumX, LND, Fedimint, Bitcoin Core) + the "Not available"
  notice now use the Archipelago black theme and show the app's My-Apps icon.
- Bitcoin Core gets its own UI (/app/bitcoin-core/) so it no longer shows Bitcoin
  Knots branding; the Knots-branded bitcoin-ui shell is reserved for Bitcoin Knots.
- ElectrumX now serves the real electrs-ui shell (+ qrcode.js + a dummy
  /electrs-status) with the correct ElectrumX icon; "Electrs" renamed to ElectrumX.
- My Apps: pre-install Bitcoin Knots again, drop ThunderHub, rename Electrs→ElectrumX.
- App store no longer shows "Checking…" forever in demo — non-demoable apps show
  "No demo" immediately (skip the container-scan state).
- Relay endpoint no longer reveals a real domain (randomised host).
- Dockerfile/.dockerignore copy docker/electrs-ui into the backend image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:55:50 -04:00
archipelago
4cc808c73e fix(demo): /app proxy (fixes 404s), mempool iframe, LND UI, icons
- nginx-demo.conf + vite proxy now route every /app/<id>/ to the mock backend, so
  the per-app mock UIs and the generic "Not available in the demo" notice render
  (previously only /app/filebrowser was proxied → most apps 404'd).
- Mempool and IndeeHub now load in the in-app iframe (not a new tab).
- Add an LND Lightning mock UI (channels, balances, routing) with dummy data;
  lnd/thunderhub are demoable. Notice page reworded to "Not available in the demo".
- Fix missing icons: Bitcoin Core → bitcoin-core.png, Mempool → mempool.webp.
- Pre-install only Bitcoin Core (drop duplicate Bitcoin Knots; still installable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:39:33 -04:00
archipelago
c9341baa35 fix(demo): un-ignore docker/bitcoin-ui in build context
The backend COPY of docker/bitcoin-ui failed in Portainer because .dockerignore
(* + whitelist) excluded it. Re-include docker/ then exclude its contents except
bitcoin-ui, so the build context contains the Bitcoin UI mock shell. demo/files is
already covered by !demo/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:16:31 -04:00
archipelago
79c3769542 feat(demo): curated cloud files drop-in + fix backend asset copies
- demo/files/<Folder>/<file> becomes the cloud's content for every visitor
  (read-only; "private login" = git/repo access). Text inlined, binaries streamed
  from disk; empty folder falls back to the built-in seeded set.
- Dockerfile.backend now copies docker/bitcoin-ui and demo/files into the image
  (they live outside neode-ui/) — this also fixes the Bitcoin UI mock, which the
  backend reads from /docker/bitcoin-ui and was previously absent in the container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:11:40 -04:00
archipelago
df2ae3d7d8 feat(demo): ground AIUI chat in the node's mock state
The Claude proxy injects a system-prompt describing this node (version, signet
chain + height, wallet balances, installed apps, 5 FIPS peers / 12 trusted nodes)
into every demo chat request. The assistant answers local-node and Bitcoin
questions with the node's real-looking data automatically — no /seed needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:58:58 -04:00
archipelago
3f411c1d10 feat(demo): mock FIPS as active (status, seed anchors, reconnect, install)
fips.status reports installed+active with 5 authenticated peers and an anchor
connection; list/add/remove/apply seed-anchors and reconnect/install all resolve
to working states so the FIPS Mesh + Seed Anchors cards light green in the demo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:55:13 -04:00
archipelago
4d0c2d6717 feat(demo): real testnet tx links + interactive buy-files flow
- Tx/explorer links open mempool.space/testnet/tx/<id>; the backend hydrates the
  wallet's transactions with REAL recent testnet txids at startup (best-effort,
  falls back to mock hashes offline). Mempool app + demo-external apps open in a
  new tab; deep-link paths are carried through.
- Add the content.* paid-download handlers the buy flow needs (owned-list,
  preview-peer, download-peer-{paid,invoice,onchain}, request-invoice,
  invoice-status, request-onchain, onchain-status) — every path resolves to a
  success state with testnet receive addresses / bolt11 invoices so visitors can
  walk the full buy → unlock journey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:53:05 -04:00
archipelago
2cffa79d9d feat(demo): app launch UIs, "No demo" gating, onboarding skip, 12 nodes
App launching (DEMO):
- resolveAppUrl routes every app to its demo target: mock UIs for Bitcoin Core,
  ElectrumX, Fedimint (served by the backend), IndeeHub → iframe indee.tx1138.com,
  Mempool → mempool.space/testnet (new tab); all others → a generic "Demo preview"
  notice page.
- Non-demoable apps show a disabled "No demo" install button (marketplace details,
  app grid, featured apps).

Onboarding:
- Demo treats the visitor as fully set up so the onboarding WIZARD (seed/identity)
  is never forced; the welcome intro still replays per day. Intro CTA goes straight
  to login; wizard entry points + login restart-onboarding link hidden in demo.

Network:
- federation.list-nodes now returns 12 trusted/federated nodes (9 trusted, 3
  observer); transport.peers already at 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:26:35 -04:00
archipelago
2715f2d847 feat(demo): public multi-visitor demo sandbox for Portainer
Turn the mock backend + UI into a public, click-to-play demo deployable as a
Portainer stack, gated behind DEMO=1 (classic single-user mock unchanged when off).

Backend (neode-ui/mock-backend.js):
- Per-session state isolation via AsyncLocalStorage + Proxy: every visitor gets
  an isolated, deep-cloned copy of mockData/walletState/userState/etc., keyed by
  a demo_sid cookie. Per-session WebSocket fan-out, idle reaper, session cap.
- Real per-session file storage (upload/folder/rename/delete) with a 50MB quota,
  replacing the no-op filebrowser handlers; adds the missing app.filebrowser-token RPC.
- Force simulation mode (never touch a host Docker/Podman socket).
- Testnet (signet) flavor; shared login password "entertoexit".
- Report the real app version suffixed with -demo.

Frontend:
- VITE_DEMO build flag (useDemoIntro.ts): replay the intro once per calendar day
  per browser; prefill + show the "entertoexit" login hint.

Deploy:
- docker-compose.demo.yml wired for DEMO, UI on :2100 (build-from-repo).
- demo-deploy/ thin stack (prebuilt :demo image refs + .env.example + README).
- .github/workflows/demo-images.yml builds/pushes archy-demo-{web,backend} images.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:28:05 -04:00
29 changed files with 1598 additions and 281 deletions

View File

@ -7,6 +7,14 @@
# Allow demo assets (AIUI pre-built dist)
!demo/
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
!docker/
docker/*
!docker/bitcoin-ui/
!docker/electrs-ui/
!docker/lnd-ui/
!docker/fedimint-ui/
# Allow backend source for ISO source builds
!core/
!scripts/

67
.github/workflows/demo-images.yml vendored Normal file
View File

@ -0,0 +1,67 @@
name: Demo images
# Builds and pushes the public-demo images on every change to the UI / mock
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
# code (see demo-deploy/ and docs/demo-deployment-design.md).
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
# Optional:
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
on:
push:
branches: [main]
paths:
- 'neode-ui/**'
- 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml'
workflow_dispatch:
jobs:
build:
name: Build & push demo images
runs-on: ubuntu-latest
# Skip cleanly on forks / before registry config is set.
if: ${{ vars.DEMO_REGISTRY != '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
username: ${{ secrets.DEMO_REGISTRY_USER }}
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.backend
push: true
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
- name: Build & push web
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.web
push: true
build-args: |
VITE_DEMO=1
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"

View File

@ -195,7 +195,7 @@
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostr.svg",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",

18
demo-deploy/.env.example Normal file
View File

@ -0,0 +1,18 @@
# Copy to .env and adjust. Used by demo-deploy/docker-compose.yml.
# Registry host + namespace that holds the prebuilt demo images.
REGISTRY=146.59.87.168:3000/lfg2025
# Image tag to deploy (CI publishes :demo and :<git-sha>).
IMAGE_TAG=demo
# Host port for the demo UI.
DEMO_WEB_PORT=2100
# Optional — enables the in-app AI chat panel. Leave blank to disable.
ANTHROPIC_API_KEY=
# Optional sandbox tuning (defaults shown).
DEMO_SESSION_TTL_MS=2700000 # 45 min idle before a visitor session is reaped
DEMO_MAX_SESSIONS=500 # concurrent visitor cap
DEMO_FILE_QUOTA_BYTES=52428800 # 50 MB uploads per visitor

33
demo-deploy/README.md Normal file
View File

@ -0,0 +1,33 @@
# Archipelago — Public Demo deploy
A click-to-play demo of the Archipelago UI, backed entirely by a mock backend.
Every visitor gets an **isolated, ephemeral sandbox** (own apps, wallet, files),
real container runtimes are never touched, and Bitcoin runs on **signet** test
coins. **Login password: `entertoexit`** (shown on the login screen).
This directory is the full contents of the public `archy-demo` repo. It holds no
source — only this compose file that pulls prebuilt `:demo` images.
## Deploy in Portainer
1. **Stacks → Add stack → Repository** (or paste `docker-compose.yml` into the web editor).
2. Set environment variables (see `.env.example`) — at minimum `REGISTRY`, and
`ANTHROPIC_API_KEY` if you want the AI chat panel.
3. Deploy. The UI is served on `:2100` (override with `DEMO_WEB_PORT`).
To pick up a new build, redeploy the stack (or wire the CI Portainer webhook).
## How it stays current
The images are built from the Archipelago monorepo by
`.github/workflows/demo-images.yml` on every change to `neode-ui/`, tagged `:demo`
and `:<git-sha>`, and pushed to `REGISTRY`. Editing the real UI → CI rebuilds →
redeploy here. No source lives in this repo.
## What's mocked
- **Per-visitor isolation** — state keyed by a `demo_sid` cookie, idle-reaped.
- **Apps** — install/uninstall/start/stop are simulated (no real Docker).
- **Wallet/Bitcoin** — signet-flavored; use the in-UI faucet for test sats.
- **Files** — real per-session upload/rename/delete, 50 MB quota, wiped on reap.
- **Intro** — replays once per calendar day per browser.

View File

@ -0,0 +1,49 @@
# Archipelago Public Demo — thin deploy stack
#
# This is the ENTIRE contents intended for the public `archy-demo` repo. It holds
# NO source — it pulls prebuilt `:demo` images that CI builds from the monorepo on
# every neode-ui change (see .github/workflows/demo-images.yml). Deploy this in
# Portainer ("deploy from repository" or paste into the web editor).
#
# Demo login password: entertoexit
# Access on http://<host>:2100
#
# Configure via a .env file (see .env.example):
# REGISTRY registry host/namespace holding the demo images
# IMAGE_TAG image tag to pull (default: demo)
# ANTHROPIC_API_KEY optional — enables the AI chat panel
# DEMO_WEB_PORT host port for the UI (default 2100)
services:
neode-backend:
image: ${REGISTRY:-146.59.87.168:3000/lfg2025}/archy-demo-backend:${IMAGE_TAG:-demo}
container_name: archy-demo-backend
environment:
DEMO: "1"
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
NODE_OPTIONS: "--dns-result-order=ipv4first"
DEMO_SESSION_TTL_MS: ${DEMO_SESSION_TTL_MS:-2700000}
DEMO_MAX_SESSIONS: ${DEMO_MAX_SESSIONS:-500}
DEMO_FILE_QUOTA_BYTES: ${DEMO_FILE_QUOTA_BYTES:-52428800}
expose:
- "5959"
dns:
- 8.8.8.8
- 1.1.1.1
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5959/health"]
interval: 30s
timeout: 10s
retries: 3
neode-web:
image: ${REGISTRY:-146.59.87.168:3000/lfg2025}/archy-demo-web:${IMAGE_TAG:-demo}
container_name: archy-demo-web
ports:
- "${DEMO_WEB_PORT:-2100}:80"
environment:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
depends_on:
- neode-backend
restart: unless-stopped

View File

@ -0,0 +1,22 @@
# Curated demo files
Drop real files into `demo/files/` to make them the cloud's content for **every**
demo visitor (read-only — visitors can browse, download, and "buy" them, but only
maintainers add them). This is the "private login": the only way to add files is
to commit them here, which requires repo access.
```
demo/files/
Documents/whitepaper.pdf
Photos/rig.jpg
Music/track.mp3
```
- Folder structure becomes the cloud's folders.
- Text files (`.md .txt .json .csv …`, < 1 MB) are inlined; everything else is
streamed from disk on download.
- If `demo/files/` is empty, the demo falls back to the built-in seeded set
(Documents/Photos/Music/Videos with sample content).
After adding files, commit and push — CI rebuilds the `:demo` image and Portainer
redeploys. Keep the total modest (these load into the demo image).

View File

@ -14,6 +14,31 @@
<link rel="icon" href="/aiui/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/aiui/apple-touch-icon-180x180.png" />
<title>AIUI</title>
<!-- Demo (?seed): pre-load the example "Content Showcase" conversation into
AIUI's IndexedDB so the chat history isn't empty (live chat is disabled
in the demo and points users to these previous chats). Mirrors the app's
own /seed exactly by calling its seedPromptsToConversation(). -->
<script type="module">
(async () => {
try {
if (!new URLSearchParams(location.search).has('seed')) return;
const db = await new Promise((res, rej) => {
const r = indexedDB.open('aiui-store', 1);
r.onupgradeneeded = (e) => { const d = e.target.result; if (!d.objectStoreNames.contains('conversations')) d.createObjectStore('conversations', { keyPath: 'id' }); };
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
});
const exists = await new Promise((res) => {
try { const q = db.transaction('conversations', 'readonly').objectStore('conversations').getKey('seed-all'); q.onsuccess = () => res(!!q.result); q.onerror = () => res(false); }
catch { res(false); }
});
if (exists) return;
const { seedPromptsToConversation } = await import('/aiui/assets/seedPrompts-CLWaUv28.js');
const conv = seedPromptsToConversation();
await new Promise((res, rej) => { const t = db.transaction('conversations', 'readwrite'); t.objectStore('conversations').put(conv); t.oncomplete = () => res(); t.onerror = () => rej(t.error); });
try { localStorage.setItem('aiui-active-conversation', conv.id); } catch {}
} catch (e) { console.warn('[demo] AIUI seed bootstrap failed', e); }
})();
</script>
<script type="module" crossorigin src="/aiui/assets/index-Lh5NfTCq.js"></script>
<link rel="stylesheet" crossorigin href="/aiui/assets/index-CHQ7uqBj.css">
<link rel="manifest" href="/aiui/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/aiui/registerSW.js"></script></head>

0
demo/files/.gitkeep Normal file
View File

View File

@ -1,6 +1,13 @@
# Archipelago Demo Stack - Mock backend + Vue UI + AIUI Chat
# Deploy via Portainer: Web editor -> paste this, or deploy from repo
# Access at http://localhost:4848
# Archipelago Public Demo Stack - Mock backend + Vue UI + AIUI Chat
# Deploy via Portainer: Web editor -> paste this, or deploy from repo (build).
# Access at http://localhost:2100
#
# This builds the demo images from source. For the separated, auto-updating
# deploy that pulls prebuilt :demo images, see demo-deploy/docker-compose.yml.
#
# DEMO=1 turns on the public multi-visitor sandbox: each visitor gets an
# isolated, ephemeral copy of all state; real container runtimes are never
# touched; the shared login password is "entertoexit".
#
# Required: Set ANTHROPIC_API_KEY in environment or .env file for chat to work
# IndeedHub is deployed as a separate Portainer stack (indee-demo repo)
@ -12,9 +19,13 @@ services:
dockerfile: neode-ui/Dockerfile.backend
container_name: archy-demo-backend
environment:
VITE_DEV_MODE: "existing"
DEMO: "1"
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
NODE_OPTIONS: "--dns-result-order=ipv4first"
# Optional tuning (defaults shown):
# DEMO_SESSION_TTL_MS: "2700000" # 45 min idle before a session is reaped
# DEMO_MAX_SESSIONS: "500" # concurrent visitor cap
# DEMO_FILE_QUOTA_BYTES: "52428800" # 50 MB uploads per visitor
expose:
- "5959"
dns:
@ -31,9 +42,11 @@ services:
build:
context: .
dockerfile: neode-ui/Dockerfile.web
args:
VITE_DEMO: "1"
container_name: archy-demo-web
ports:
- "4848:80"
- "2100:80"
depends_on:
- neode-backend
restart: unless-stopped

109
docs/demo-build-info.md Normal file
View File

@ -0,0 +1,109 @@
# Archipelago Public Demo — build info & status
**Status:** implemented & deployable (2026-06-22)
**Branch:** `demo-build` (worktree `../archy-demo-build`), pushed to
`gitea-vps2` = `http://146.59.87.168:3000/lfg2025/archy.git`.
**Main/prod is untouched** — all demo work lives only on `demo-build`.
A public, click-to-play demo of the Archipelago UI, 100% mock-data driven,
multi-visitor, deployed via Portainer. See also `docs/demo-deployment-design.md`
(original design) and `demo-deploy/` (thin prebuilt-image stack).
---
## Deploy (Portainer)
Build-from-repo (works today, no registry needed):
| Field | Value |
|-------|-------|
| Repository URL | `http://146.59.87.168:3000/lfg2025/archy.git` |
| Reference | `refs/heads/demo-build` |
| Compose path | `docker-compose.demo.yml` |
| Auth | user `lfg2025`, password = Gitea token |
| UI port | **2100** · Login password: **`entertoexit`** |
Redeploy after each push. `docker-compose.demo.yml` builds two images
(`neode-ui/Dockerfile.backend` = mock server, `neode-ui/Dockerfile.web` = nginx+UI).
The thin `demo-deploy/docker-compose.yml` pulls prebuilt `:demo` images instead
(needs the CI image pipeline / registry wired — `.github/workflows/demo-images.yml`).
### Flags / env
- Backend: `DEMO=1` (compose sets it) → multi-session sandbox, no real runtime.
- Web build: `VITE_DEMO=1` (Dockerfile.web ARG, default 1) → inlined demo UI behaviour.
- Optional: `ANTHROPIC_API_KEY` (NOT needed — AIUI chat is canned in demo),
`DEMO_SESSION_TTL_MS` (45m), `DEMO_MAX_SESSIONS` (500), `DEMO_FILE_QUOTA_BYTES` (50MB).
---
## Architecture
Everything is gated behind `DEMO` (off = classic single-user dev mock, unchanged).
- **`neode-ui/mock-backend.js`** — the entire fake backend (Node/Express, ~95+ RPCs).
- **Per-session isolation:** `AsyncLocalStorage` + Proxy. Globals (`mockData`,
`walletState`, `userState`, `mockState`, `bitcoinRelayMockState`) are Proxies
that resolve to the current request's store, keyed by a `demo_sid` cookie.
Deep-cloned from `SEED_*` on first hit; idle-reaped; per-session WS fan-out.
- **Files:** per-session in-memory store + curated disk files (see below).
- Forces simulation mode in DEMO (`docker=null`).
- **`neode-ui/src/composables/useDemoIntro.ts`** — the frontend demo switch
(`IS_DEMO`), per-day intro gate, `DEMO_PASSWORD`, app demoability + launch URLs.
- **`neode-ui/docker/nginx-demo.conf`** — routes `/rpc`, `/ws`, `/app/*`,
`/electrs-status`, `/proxy/`, `/lnd-connect-info`, the IndeeHub/Mempool
reverse-proxies, and the SPA.
- **`docker/{bitcoin-ui,electrs-ui,lnd-ui,fedimint-ui}/`** — the REAL registry app
UIs, served statically under `/app/<id>/` with mocked data endpoints.
- **`demo/aiui/`** — prebuilt AIUI dist (chat is canned; `?mockArchy&seed`).
- **`demo/files/`** — curated cloud files drop-in (see below).
## Demo features (all implemented)
Per-session sandbox · per-session file upload (Range streaming) · testnet/signet
flavor · per-day intro replay · `entertoexit` login (prefilled + hint) · version
`<real>-demo` · onboarding wizard skipped (intro kept) · "No demo" install gating ·
real app UIs (Bitcoin Core vs Knots by subversion, ElectrumX, LND, Fedimint;
Mempool/IndeeHub iframed) · 12 federation nodes / 5 peers · FIPS active · interactive
buy flow (testnet addresses, bolt11, 2s QR) · real testnet tx links (mempool.space) ·
networking profits 5,231,978 sats + labelled wallet txs · VPN · Nostr relays ·
node-visibility toggle · dummy Cashu mints + Fedimint federations · AIUI canned
reply + `?mockArchy` mock data + `?seed` pre-loaded "Content Showcase" chat.
---
## Curated cloud files (`demo/files/`)
Drop real files into `demo/files/<Folder>/<file>` and commit — they become the
cloud content for every visitor (read-only; git access = the "private login").
Loader **merges per top-level folder**: adding `Music/` swaps only Music and keeps
the sample Documents/Photos/Videos. Empty → built-in seeds. Text inlined; binaries
streamed from disk with HTTP Range (seek). Backend reads `/demo/files`
**Dockerfile.backend COPYs it; `.dockerignore` must allow it.**
---
## Gotchas (READ before editing)
- **Sibling dirs need both the Dockerfile COPY and a `.dockerignore` allow.**
`docker/bitcoin-ui`, `docker/electrs-ui`, `docker/lnd-ui`, `docker/fedimint-ui`,
`demo/files` are outside `neode-ui/`; they're copied into the backend image and
un-ignored in `.dockerignore` (`* ` + `!docker/` + `docker/*` + `!docker/<ui>/`).
Forgetting either → Portainer build "not found" or runtime 500/404.
- **Real app UIs assume root-serving** — served via `express.static('/app/<id>')`
+ `/app/<id>/assets/*``/assets/*` redirect + per-path data endpoints
(`bitcoin-status`, `rpc/v1`, `bitcoin-rpc/`, `/proxy/lnd/*`, `/electrs-status`).
- **Uploaded-via-UI files are ephemeral** (per-session, lost on redeploy/reap).
Only `demo/files/` persists.
- **Mempool iframe is best-effort** (third-party CSP/websockets). **IndeeHub** is
reverse-proxied with header-strip + `sub_filter` asset rewrite; if still black,
it's indee's own `X-Frame-Options` (fix on that server).
- **AIUI `?seed` bootstrap hardcodes the current AIUI bundle hash**
(`/aiui/assets/seedPrompts-CLWaUv28.js`) — re-paste if AIUI is rebuilt. Tiny
first-load IndexedDB race (one refresh shows the chat).
- **Running mock-backend.js locally in the sandbox is flaky:** start backgrounded,
`sleep 5+`, then curl; NEVER `pkill -f mock-backend` (it matches & kills the
shell) — use `pkill -x node`.
- **Delete-405** seen pre-redeploy was nginx/stale; backend DELETE returns 200.
---
## Commit trail (demo-build, newest last)
`2715f2d8` sandbox → … → `7efebb4a` media merge + AIUI seed. ~14 commits, all
`feat(demo)/fix(demo)`.

View File

@ -14,6 +14,14 @@ RUN npm install
# Copy application code
COPY neode-ui/ ./
# Sibling assets the mock backend reads relative to /app (../docker, ../demo):
# the Bitcoin UI mock shell and any curated cloud files dropped into demo/files.
COPY docker/bitcoin-ui /docker/bitcoin-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
# Expose port
EXPOSE 5959

View File

@ -20,6 +20,12 @@ RUN find public/assets -name "*backup*" -type f -delete || true && \
ENV DOCKER_BUILD=true
ENV NODE_ENV=production
# Public-demo build flag — inlined into the bundle (import.meta.env.VITE_DEMO).
# Enables the per-day intro replay, the "entertoexit" login hint, and other
# demo-only UI affordances. Override with --build-arg VITE_DEMO=0 for a plain build.
ARG VITE_DEMO=1
ENV VITE_DEMO=$VITE_DEMO
# Use npm script which handles build better
RUN npm run build:docker || (echo "Build failed! Listing files:" && ls -la && echo "Checking vite config:" && cat vite.config.ts && exit 1)

View File

@ -62,6 +62,28 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# ElectrumX UI status (polled by the electrs-ui shell)
location /electrs-status {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
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)
location /app/filebrowser/ {
client_max_body_size 10G;
@ -72,6 +94,59 @@ http {
proxy_request_buffering off;
}
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
# and rewrite its absolute asset paths (/assets, /, src, href) to the
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
location /app/indeedhub/ {
proxy_pass https://indee.tx1138.com/;
proxy_http_version 1.1;
proxy_set_header Host indee.tx1138.com;
proxy_set_header Accept-Encoding "";
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
sub_filter_types text/html text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/indeedhub/';
sub_filter 'src="/' 'src="/app/indeedhub/';
sub_filter "href='/" "href='/app/indeedhub/";
sub_filter "src='/" "src='/app/indeedhub/";
sub_filter 'from"/' 'from"/app/indeedhub/';
sub_filter 'url(/' 'url(/app/indeedhub/';
}
# Mempool: same approach. NOTE mempool.space is a strict third-party app —
# its data/websocket calls may still be blocked; iframe is best-effort.
location /app/mempool/ {
proxy_pass https://mempool.space/;
proxy_http_version 1.1;
proxy_set_header Host mempool.space;
proxy_set_header Accept-Encoding "";
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
sub_filter_types text/html text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/mempool/';
sub_filter 'src="/' 'src="/app/mempool/';
sub_filter "href='/" "href='/app/mempool/";
sub_filter "src='/" "src='/app/mempool/";
sub_filter 'from"/' 'from"/app/mempool/';
sub_filter 'url(/' 'url(/app/mempool/';
}
# 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
# generic "Not available in the demo" notice for the rest.
location /app/ {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Serve AIUI SPA
location /aiui/ {
alias /usr/share/nginx/html/aiui/;

File diff suppressed because it is too large Load Diff

View File

@ -195,7 +195,7 @@
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostr.svg",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",

View File

@ -0,0 +1,96 @@
/**
* Public-demo helpers.
*
* The demo build (VITE_DEMO=1) replays the intro/onboarding on each visit, but
* only once per calendar day per browser tracked in localStorage so it
* survives the short-lived backend session. Also exposes the shared demo
* credentials shown on the login screen.
*/
export const IS_DEMO =
import.meta.env.VITE_DEMO === '1' || import.meta.env.VITE_DEMO === 'true'
/** Memorable shared password for the public demo (must match the mock backend). */
export const DEMO_PASSWORD = 'entertoexit'
const INTRO_DATE_KEY = 'demo_intro_date'
function todayKey(): string {
// Local calendar day, e.g. "2026-06-22".
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** True if this browser already watched the intro earlier today. */
export function demoIntroSeenToday(): boolean {
try {
return localStorage.getItem(INTRO_DATE_KEY) === todayKey()
} catch {
return false
}
}
/** Record that the intro has been seen today, so it won't replay until tomorrow. */
export function markDemoIntroSeen(): void {
try {
localStorage.setItem(INTRO_DATE_KEY, todayKey())
} catch {
/* ignore (private mode / storage disabled) */
}
}
/** Forget today's "seen" marker so the intro plays again (e.g. "Replay Intro"). */
export function clearDemoIntroSeen(): void {
try {
localStorage.removeItem(INTRO_DATE_KEY)
} catch {
/* ignore */
}
}
// ── Demoable apps ───────────────────────────────────────────────────────────
// Only these apps actually do something in the demo (a mock UI or a real
// external site). Everything else shows "No demo" on a disabled install button
// and is not launchable.
const DEMO_EXTERNAL_URLS: Record<string, string> = {}
// Apps loaded in the in-app iframe via a same-origin path. IndeeHub and Mempool
// are reverse-proxied by nginx (X-Frame-Options/CSP stripped + asset paths
// rewritten) so the frame-busting real sites can be embedded.
const DEMO_MOCK_UI: Record<string, string> = {
indeedhub: '/app/indeedhub/',
mempool: '/app/mempool/',
'mempool-web': '/app/mempool/',
'bitcoin-knots': '/app/bitcoin-knots/',
'bitcoin-core': '/app/bitcoin-core/',
bitcoin: '/app/bitcoin-core/',
'bitcoin-ui': '/app/bitcoin-ui/',
electrs: '/app/electrumx/',
electrumx: '/app/electrumx/',
'archy-electrs-ui': '/app/electrumx/',
lnd: '/app/lnd/',
'lnd-ui': '/app/lnd/',
'archy-lnd-ui': '/app/lnd/',
thunderhub: '/app/lnd/',
fedimint: '/app/fedimint/',
fedimintd: '/app/fedimint/',
filebrowser: '/app/filebrowser/',
}
/**
* Whether a demo app opens in a new tab. Nothing does IndeeHub and Mempool
* both load their real site directly in the in-app iframe.
*/
export function isDemoExternal(_appId: string): boolean {
return false
}
/** Can this app be launched/installed in the demo? */
export function isDemoApp(appId: string): boolean {
return appId in DEMO_EXTERNAL_URLS || appId in DEMO_MOCK_UI
}
/** Resolve the demo launch URL for an app, or null if it isn't demoable. */
export function demoAppUrl(appId: string): string | null {
return DEMO_EXTERNAL_URLS[appId] ?? DEMO_MOCK_UI[appId] ?? null
}

View File

@ -4,6 +4,7 @@ import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { IS_DEMO, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
/**
* Open a URL in a new browser tab but if a companion (phone) is currently
@ -222,6 +223,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function openSession(appId: string) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
// Demo: apps backed by a real external site that blocks iframing (mempool.space)
// open in a new tab; everything else demoable renders in the in-app session.
if (IS_DEMO && isDemoExternal(appId)) {
const ext = demoAppUrl(appId)
if (ext) { openExternal(ext); return }
}
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl && !mobile) {
openExternal(launchUrl)

View File

@ -109,6 +109,7 @@ import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl } from '@/utils/openExternal'
import { useElectrsSync } from '@/composables/useElectrsSync'
import { IS_DEMO, isDemoExternal } from '@/composables/useDemoIntro'
const props = defineProps<{
appIdProp?: string
@ -157,7 +158,11 @@ const packageEntry = computed(() => store.data?.['package-data']?.[appId.value]
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
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))
// In the demo, apps backed by a real external site that blocks iframing
// (mempool.space) open in a new tab rather than the in-app session frame.
const mustOpenNewTab = computed(() =>
NEW_TAB_APPS.has(appId.value) || (IS_DEMO && isDemoExternal(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

View File

@ -62,6 +62,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ContextBroker } from '@/services/contextBroker'
import { IS_DEMO } from '@/composables/useDemoIntro'
const { t } = useI18n()
@ -71,9 +72,12 @@ const aiuiConnected = ref(false)
let broker: ContextBroker | null = null
const aiuiUrl = computed(() => {
// Demo: ?mockArchy makes AIUI use its built-in mock node data (apps, system,
// network, wallet, bitcoin, files) and &seed pre-loads the example chats.
const demo = IS_DEMO ? '&mockArchy=1&seed=1' : ''
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
if (envUrl) return `${envUrl}?embedded=true&hideClose=true${demo}`
if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true${demo}`
return ''
})

View File

@ -156,6 +156,11 @@
<!-- Normal Login Mode -->
<template v-else>
<!-- Demo credential hint -->
<div v-if="isDemo" class="mb-4 p-3 bg-orange-500/15 border border-orange-400/30 rounded-lg text-orange-100 text-sm text-center">
🎮 Demo mode Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
</div>
<div class="mb-6">
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
{{ t('login.password') }}
@ -203,14 +208,16 @@
>
{{ t('login.replayIntro') }}
</button>
<span class="text-white/30">|</span>
<button
@click="restartOnboarding"
:disabled="isResettingOnboarding"
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
</button>
<template v-if="!isDemo">
<span class="text-white/30">|</span>
<button
@click="restartOnboarding"
:disabled="isResettingOnboarding"
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
</button>
</template>
</div>
</div>
</div>
@ -228,6 +235,7 @@ const { t } = useI18n()
import { useLoginTransitionStore } from '../stores/loginTransition'
import { rpcClient } from '../api/rpc-client'
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
import { IS_DEMO, DEMO_PASSWORD, clearDemoIntroSeen } from '@/composables/useDemoIntro'
const router = useRouter()
const currentRoute = useRoute()
@ -241,7 +249,8 @@ const loginRedirectTo = computed(() => {
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
const password = ref('')
const isDemo = IS_DEMO
const password = ref(IS_DEMO ? DEMO_PASSWORD : '')
const confirmPassword = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
@ -520,6 +529,8 @@ async function handleTotpVerify() {
function replayIntro() {
// Clear the intro seen flag
localStorage.removeItem('neode_intro_seen')
// Demo: also clear the per-day gate so the intro plays again now.
if (IS_DEMO) clearDemoIntroSeen()
// Navigate to root to trigger splash screen
window.location.href = '/'
}

View File

@ -63,8 +63,8 @@
<button
v-else
@click="installApp"
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="installBlockedReason || undefined"
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@ -74,7 +74,7 @@
<svg v-else 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>
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
</div>
@ -129,8 +129,8 @@
<button
v-else
@click="installApp"
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="installBlockedReason || undefined"
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@ -140,7 +140,7 @@
<svg v-else 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>
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
@ -351,6 +351,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
@ -486,6 +487,9 @@ const installBlockedReason = computed(() => {
return electrumxArchiveWarning
})
// Demo: only demoable apps can be installed; the rest show "No demo".
const demoNoInstall = computed(() => IS_DEMO && !!app.value?.id && !isDemoApp(app.value.id))
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
onMounted(() => {

View File

@ -22,27 +22,30 @@
@click="goToOptions"
class="glass-button px-6 py-3 sm:px-8 sm:py-4 rounded-lg text-base sm:text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
>
Unlock your sovereignty
{{ isDemo ? 'Enter the demo →' : 'Unlock your sovereignty →' }}
</button>
<a
tabindex="0"
role="button"
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
@click="goToRestore"
@keydown.enter="goToRestore"
>
Restore from seed phrase
</a>
<a
tabindex="0"
role="button"
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
@click="goToLogin"
@keydown.enter="goToLogin"
>
Already set up? Log in
</a>
<!-- Onboarding wizard entry points are hidden in the demo (no seed/identity setup) -->
<template v-if="!isDemo">
<a
tabindex="0"
role="button"
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
@click="goToRestore"
@keydown.enter="goToRestore"
>
Restore from seed phrase
</a>
<a
tabindex="0"
role="button"
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
@click="goToLogin"
@keydown.enter="goToLogin"
>
Already set up? Log in
</a>
</template>
</div>
</div>
</div>
@ -53,11 +56,16 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { playNavSound } from '@/composables/useNavSounds'
import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro'
const router = useRouter()
const ctaButton = ref<HTMLButtonElement | null>(null)
const isDemo = IS_DEMO
onMounted(() => {
// Demo: once the visitor has seen the intro today, don't auto-replay it again
// until tomorrow (they can still use "Replay Intro" on the login screen).
if (IS_DEMO) markDemoIntroSeen()
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
setTimeout(() => {
ctaButton.value?.focus({ preventScroll: true })
@ -66,6 +74,13 @@ onMounted(() => {
function goToOptions() {
playNavSound('action')
// Demo: skip the onboarding wizard (seed/identity setup) entirely go straight
// to login, which is prefilled with the demo password.
if (isDemo) {
localStorage.setItem('neode_onboarding_complete', '1')
router.push('/login').catch(() => {})
return
}
router.push('/onboarding/path').catch(() => {})
}

View File

@ -1304,7 +1304,7 @@ async function payWithLightning() {
function scheduleInvoicePoll() {
if (invoicePollTimer) clearTimeout(invoicePollTimer)
invoicePollTimer = setTimeout(pollInvoice, 3000)
invoicePollTimer = setTimeout(pollInvoice, 1000)
}
async function pollInvoice() {

View File

@ -16,11 +16,22 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { isOnboardingComplete } from '@/composables/useOnboarding'
import { IS_DEMO, demoIntroSeenToday } from '@/composables/useDemoIntro'
import BootScreen from '@/components/BootScreen.vue'
const router = useRouter()
const showBootScreen = ref(false)
/**
* Public demo: replay the intro on every visit, but at most once per calendar
* day per browser. If already seen today straight to login; otherwise intro.
*/
function demoRoute() {
const dest = demoIntroSeenToday() ? '/login' : '/onboarding/intro'
log('demoRoute', { dest })
router.replace(dest).catch(() => {})
}
function log(msg: string, data?: unknown) {
const ts = new Date().toISOString()
const entry = `[RootRedirect ${ts}] ${msg}` + (data !== undefined ? ` ${JSON.stringify(data)}` : '')
@ -68,6 +79,10 @@ async function checkOnboarded(): Promise<boolean> {
}
async function proceedToApp() {
if (IS_DEMO) {
demoRoute()
return
}
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
log('proceedToApp devMode', { devMode })
@ -121,6 +136,11 @@ onMounted(async () => {
log('production flow', { isUp })
if (isUp) {
// Demo: per-day intro gate instead of server-side onboarding state.
if (IS_DEMO) {
demoRoute()
return
}
const onboarded = await checkOnboarded()
if (onboarded) {
log('server up + onboarded → proceedToApp')

View File

@ -1,6 +1,7 @@
/** Static configuration maps for app session routing and display */
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
@ -76,6 +77,15 @@ export const IFRAME_BLOCKED_APPS = new Set<string>([])
/** Resolve app URL using direct port mapping (source of truth) */
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
// Demo: route to the app's mock UI or real external site (mempool.space,
// indee.tx1138.com). Carry through a deep-link path (e.g. /tx/<hash> for
// mempool). Non-demoable apps fall through to a generic notice page.
if (IS_DEMO) {
const base = demoAppUrl(id)
if (base) return routeQueryPath ? base + routeQueryPath : base
return `/app/${id}/`
}
// External HTTPS apps
const ext = EXTERNAL_URLS[id]
if (ext) return ext

View File

@ -102,9 +102,9 @@
@click.stop="$emit('launch', app)"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>Launch</button>
<!-- Scanning -->
<!-- Scanning (skipped in demo there are no real containers to scan) -->
<span
v-else-if="!containersScanned && (app.source === 'local' || app.dockerImage)"
v-else-if="!IS_DEMO && !containersScanned && (app.source === 'local' || app.dockerImage)"
class="flex-1 px-4 py-2 rounded-lg text-white/50 text-sm font-medium text-center cursor-default relative overflow-hidden"
>
<span class="discover-shimmer-bg"></span>
@ -116,6 +116,12 @@
Checking...
</span>
</span>
<!-- Demo: app not demoable -->
<button
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
disabled
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
>No demo</button>
<!-- Install button -->
<button
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
@ -158,6 +164,7 @@
<script setup lang="ts">
import type { MarketplaceApp } from './types'
import { handleImageError } from '@/views/apps/appsConfig'
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
defineProps<{
filteredApps: MarketplaceApp[]

View File

@ -64,7 +64,7 @@
Starting...
</span>
<button
v-else-if="!containersScanned && app.dockerImage"
v-else-if="!IS_DEMO && !containersScanned && app.dockerImage"
disabled
class="text-white/40 text-sm flex items-center gap-2"
>
@ -74,6 +74,11 @@
</svg>
Checking...
</button>
<button
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
disabled
class="glass-button glass-button-sm rounded-lg text-sm font-medium opacity-50 cursor-not-allowed"
>No demo</button>
<button
v-else-if="!isInstalled(app.id) && app.dockerImage"
data-controller-install-btn
@ -99,6 +104,7 @@
<script setup lang="ts">
import type { FeaturedApp, MarketplaceApp } from './types'
import { handleImageError } from '@/views/apps/appsConfig'
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
defineProps<{
featuredApps: FeaturedApp[]

View File

@ -151,6 +151,16 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
// Demo mock app UIs (electrumx, lnd, fedimint) + generic notice page.
'/app/electrumx': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/app/electrs': { 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/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 },
'/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 },
// Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/)
'/aiui': {
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',