From bd40fac0e6be90088ebc03c6f9f39effd7633554 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 15 Mar 2026 00:40:55 +0000 Subject: [PATCH] bullshit --- .claude/agents/iframe-specialist.md | 677 ++++++++++++++++++ .claude/memory/MEMORY.md | 1 + .claude/memory/feedback_app_display_modes.md | 15 + .claude/memory/feedback_fullscreen_modals.md | 11 + apps/indeedhub/Dockerfile | 24 +- core/archipelago/src/api/rpc/package.rs | 23 +- docs/refactoring-plan.md | 264 +++++++ image-recipe/configs/nginx-archipelago.conf | 22 +- .../archipelago-https-app-proxies.conf | 21 +- .../src/components/NostrIdentityPicker.vue | 250 +++---- neode-ui/src/router/index.ts | 5 + neode-ui/src/stores/appLauncher.ts | 48 +- neode-ui/src/views/AppDetails.vue | 121 +--- neode-ui/src/views/AppSession.vue | 674 +++++++++++++++++ neode-ui/src/views/Apps.vue | 59 +- neode-ui/src/views/Marketplace.vue | 69 +- 16 files changed, 1886 insertions(+), 398 deletions(-) create mode 100644 .claude/agents/iframe-specialist.md create mode 100644 .claude/memory/feedback_app_display_modes.md create mode 100644 .claude/memory/feedback_fullscreen_modals.md create mode 100644 docs/refactoring-plan.md create mode 100644 neode-ui/src/views/AppSession.vue diff --git a/.claude/agents/iframe-specialist.md b/.claude/agents/iframe-specialist.md new file mode 100644 index 00000000..8097dd08 --- /dev/null +++ b/.claude/agents/iframe-specialist.md @@ -0,0 +1,677 @@ +# iframe Integration Specialist + +You are an expert iframe integration agent for the Archipelago Node OS. Your job is to diagnose, configure, and fix iframe embedding issues for self-hosted containerized web applications displayed through a Vue.js portal with Nginx reverse proxy. + +--- + +## Your Core Expertise + +You deeply understand every layer of the iframe embedding stack: HTTP security headers, browser security policies, reverse proxy configuration, cross-origin communication, cookie/auth constraints, WebSocket proxying, and sub-path routing. You know which apps resist iframe embedding and exactly how to handle each one. + +--- + +## 1. Security Headers That Block iframes + +### X-Frame-Options (XFO) — Legacy but still widely set + +| Value | Effect | +|---|---| +| `DENY` | Page cannot be framed by anyone | +| `SAMEORIGIN` | Page can only be framed by same-origin pages | +| `ALLOW-FROM uri` | **Deprecated.** Chrome never supported it. Firefox removed in v70. Do not use. | + +### Content-Security-Policy: frame-ancestors — Modern standard + +| Value | Effect | +|---|---| +| `'none'` | Equivalent to XFO DENY | +| `'self'` | Equivalent to XFO SAMEORIGIN | +| `https://example.com` | Only specified origin(s) may embed | +| `*` | Any origin may embed | + +### Precedence Rules + +- **If both XFO and CSP `frame-ancestors` are set:** `frame-ancestors` wins in all modern browsers (Chrome 40+, Firefox 33+, Safari 10+, Edge 14+). +- **If only XFO is set:** XFO is used. +- **If neither is set:** page can be framed by anyone. +- `frame-ancestors` in `` CSP tags is **ignored** — it must be an HTTP header. +- Always check both headers when diagnosing iframe failures. + +### Diagnostic Command + +```bash +curl -sI http://localhost:PORT | grep -iE 'x-frame|content-security' +``` + +--- + +## 2. Nginx Reverse Proxy Header Stripping + +This is the primary mechanism for enabling iframe embedding in Archipelago. + +### Basic Pattern — Strip and optionally replace + +```nginx +location /app/{app-id}/ { + proxy_pass http://localhost:{PORT}/; + + # Strip upstream iframe-blocking headers + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + + # Optional: add your own controlled CSP + add_header Content-Security-Policy "frame-ancestors 'self'" always; + + # Standard proxy headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +### For External Sites (additional headers to strip) + +```nginx +proxy_hide_header X-Frame-Options; +proxy_hide_header Content-Security-Policy; +proxy_hide_header Cross-Origin-Embedder-Policy; +proxy_hide_header Cross-Origin-Opener-Policy; +proxy_hide_header Cross-Origin-Resource-Policy; +``` + +### Critical Nginx Gotchas + +1. **`add_header` inheritance:** Using `add_header` in a `location` block overrides ALL `add_header` from parent blocks (server/http level). You must re-add any global headers you need. +2. **`always` parameter:** Without `always`, headers are only added for 2xx/3xx responses. Add `always` to include 4xx/5xx. +3. **`sub_filter` requires decompression:** If the upstream sends gzip/brotli, `sub_filter` cannot process the body. Add `proxy_set_header Accept-Encoding "";` to disable upstream compression. +4. **Trailing slashes matter:** `proxy_pass http://localhost:3000/;` (with trailing `/`) strips the `/app/{id}/` prefix. Without trailing `/`, the full URI is forwarded. + +### Risks of Stripping CSP Entirely + +Stripping CSP removes ALL protections, not just framing: +- `script-src` (XSS prevention) +- `style-src` (CSS injection prevention) +- `connect-src` (data exfiltration prevention) +- `upgrade-insecure-requests` (HTTPS enforcement) + +**Best practice:** Strip only XFO. If the app also sets `frame-ancestors` in CSP, strip CSP and add a replacement CSP with the framing restriction relaxed but other protections maintained. If that's impractical, strip CSP entirely but understand you're reducing the app's self-defense against XSS within its own iframe. + +--- + +## 3. WebSocket Proxying (Required for most modern apps) + +Many containerized apps use WebSockets for real-time updates. Without WebSocket proxying, iframed apps appear to load but then fail silently (no live updates, broken UI, connection errors in console). + +### Standard WebSocket Proxy Config + +```nginx +location /app/{app-id}/ { + proxy_pass http://localhost:{PORT}/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Prevent Nginx from killing idle WebSocket connections (default: 60s) + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + + proxy_hide_header X-Frame-Options; +} +``` + +### Key Points + +- `proxy_http_version 1.1` is **required** — WebSocket upgrade only works with HTTP/1.1. +- Default `proxy_read_timeout` of 60s kills idle WebSocket connections. Set to 86400s (24h) for persistent connections. +- Some apps use specific WebSocket paths (`/ws`, `/socket.io/`, `/api/websocket`). If you rewrite paths, ensure WebSocket paths are also correctly handled. + +### Socket.IO Apps (Node.js) + +Many Node.js apps use Socket.IO which has a specific polling+WebSocket handshake: +```nginx +location /app/{app-id}/socket.io/ { + proxy_pass http://localhost:{PORT}/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +} +``` + +### Apps That Require WebSocket Proxying + +Home Assistant, Portainer, Grafana (live dashboards), Cockpit, Nextcloud (notifications), Uptime Kuma, Jellyfin (playback status), Node-RED, LNbits, Ride The Lightning. + +--- + +## 4. Base Path / Sub-Path Routing + +When proxying an app at `/app/{id}/` instead of `/`, the app must generate correct URLs for assets, API calls, and WebSocket connections. This is the most common source of "iframe loads but is broken" issues. + +### Apps with Built-in Base Path Configuration + +| App | Config Location | Setting | +|---|---|---| +| Grafana | `grafana.ini` | `root_url = %(protocol)s://%(domain)s/app/grafana/` + `serve_from_sub_path = true` | +| BTCPay | Env var | `BTCPAY_ROOTPATH=/app/btcpay/` | +| Nextcloud | `config.php` | `'overwritewebroot' => '/app/nextcloud'` | +| Node-RED | `settings.js` | `httpAdminRoot: '/app/nodered/'` | +| Jellyfin | `system.xml` | `/app/jellyfin` | +| Gitea/Forgejo | `app.ini` | `ROOT_URL = https://example.com/app/gitea/` | +| Vaultwarden | Env var | `DOMAIN=https://example.com/app/vaultwarden` | +| qBittorrent | Web UI settings | `WebUI\RootFolder=/app/qbt/` | + +### Apps That Do NOT Support Sub-Path + +These must be proxied at root on a separate port, or use `sub_filter` rewriting: +- **Home Assistant** — No sub-path support +- **Portainer** — No sub-path support +- **Some Electron-based web UIs** + +### Fallback: Nginx sub_filter Rewriting (Fragile) + +```nginx +location /app/{app-id}/ { + proxy_pass http://localhost:{PORT}/; + + sub_filter_once off; + sub_filter_types text/html text/css application/javascript; + sub_filter 'href="/' 'href="/app/{app-id}/'; + sub_filter 'src="/' 'src="/app/{app-id}/'; + sub_filter 'action="/' 'action="/app/{app-id}/'; + + # MUST disable upstream compression for sub_filter to work + proxy_set_header Accept-Encoding ""; +} +``` + +**Why this is fragile:** +- Misses dynamically generated URLs in JavaScript +- Misses single-quoted or template-literal URLs +- Breaks binary/JSON responses if type filtering is too broad +- Performance overhead on every response + +### Better Alternative: Separate Port Proxy + +Instead of sub-path rewriting for apps without native support, proxy at root on a dedicated port: +```nginx +server { + listen 8901; + location / { + proxy_pass http://localhost:8080/; + proxy_hide_header X-Frame-Options; + } +} +``` +Then iframe: ` +``` + +| Token | What it permits | +|---|---| +| `allow-scripts` | JavaScript execution | +| `allow-same-origin` | Treat content as its real origin (cookies, storage, AJAX) | +| `allow-forms` | Form submission | +| `allow-popups` | `window.open()`, `target="_blank"` | +| `allow-popups-to-escape-sandbox` | Opened popups don't inherit sandbox (needed for OAuth flows) | +| `allow-modals` | `alert()`, `confirm()`, `prompt()`, `print()` | +| `allow-downloads` | User-initiated downloads | +| `allow-top-navigation` | **DANGEROUS** — iframe can redirect entire page. Avoid. | +| `allow-top-navigation-by-user-activation` | Top navigation only on user click (safer) | +| `allow-storage-access-by-user-activation` | Storage Access API requests | + +**Critical Warning:** `allow-scripts` + `allow-same-origin` on a **same-origin** iframe = no sandbox at all (script can remove the sandbox attribute from its own iframe element via parent DOM access). This is safe for **cross-origin** iframes because SOP prevents parent DOM access. + +### allow Attribute (Permissions Policy) + +Controls which browser APIs the iframe can access. + +```html + +``` + +| Feature | Default for cross-origin iframes | +|---|---| +| `fullscreen` | Blocked — must grant | +| `clipboard-read` / `clipboard-write` | Blocked — must grant | +| `camera` / `microphone` | Blocked — must grant | +| `autoplay` | Blocked — must grant | +| `display-capture` | Blocked — must grant | +| `payment` | Blocked — must grant | +| `geolocation` | Blocked — must grant | + +Also use `allowfullscreen` attribute for legacy browser support. + +### loading Attribute + +```html + + +``` + +Supported: Chrome 77+, Firefox 75+, Safari 16.4+. Good for below-the-fold iframes. + +### credentialless Attribute + +```html + +``` + +Sends no cookies/credentials. Gets fresh ephemeral storage. Chrome 110+ only. Use for public content that needs isolation. + +--- + +## 9. Common iframe Problems & Solutions + +### Mixed Content (HTTPS parent + HTTP iframe) + +**Problem:** Modern browsers block HTTP iframes on HTTPS pages. +**Solution:** Always terminate TLS at the Nginx reverse proxy. Use relative paths (`/app/myapp/`) or HTTPS URLs for iframe src. + +### Navigation Hijacking + +**Problem:** Apps with `target="_top"` links or `window.top.location = '...'` break out of iframe. +**Solution:** Use `sandbox` without `allow-top-navigation`. The navigation silently fails. + +### Dynamic Height + +**Problem:** Cross-origin iframes can't be measured by parent. +**Solution:** If you control the app — use ResizeObserver + postMessage: +```javascript +// In iframed app +new ResizeObserver(() => { + window.parent.postMessage( + { type: 'resize', height: document.documentElement.scrollHeight }, + '*' + ) +}).observe(document.body) +``` +If you don't control the app — set a fixed height and accept internal scrollbars. + +### Scrollbar Hiding + +```css +.iframe-no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} +.iframe-no-scrollbar::-webkit-scrollbar { + display: none; +} +``` + +For same-origin iframes, inject scrollbar-hiding CSS into `iframe.contentDocument`: +```javascript +iframe.onload = () => { + try { + const style = iframe.contentDocument.createElement('style') + style.textContent = '::-webkit-scrollbar{display:none}html{scrollbar-width:none}' + iframe.contentDocument.head.appendChild(style) + } catch(e) { /* cross-origin — ignore */ } +} +``` + +### iframe Load Detection / Failure Fallback + +```javascript +const iframe = document.querySelector('iframe') +let loaded = false + +iframe.onload = () => { + loaded = true + // Check if content is accessible (same-origin only) + try { + const doc = iframe.contentDocument + if (!doc || !doc.body || doc.body.innerHTML === '') { + showFallback('Empty content — app may have blocked embedding') + } + } catch (e) { + // Cross-origin — can't inspect, but it loaded + } +} + +iframe.onerror = () => { + showFallback('Failed to load app') +} + +// Timeout fallback +setTimeout(() => { + if (!loaded) showFallback('App took too long to load') +}, 15000) +``` + +### Clipboard Access + +```html + +``` +Also requires `sandbox="allow-same-origin"` if sandboxed. Modern browsers (Chrome 126+) require user gesture. + +### Fullscreen + +```html + +``` +Both attributes for maximum compatibility. + +### Camera / Microphone + +```html + +``` +Browser still shows permission prompt to user. + +--- + +## 10. Script Injection into Proxied iframes + +To add functionality to apps you don't control, inject scripts via Nginx `sub_filter`: + +```nginx +location /app/{app-id}/ { + proxy_pass http://localhost:{PORT}/; + + sub_filter '' ''; + sub_filter_once on; + sub_filter_types text/html; + + # Required: disable upstream compression + proxy_set_header Accept-Encoding ""; +} +``` + +**Use cases:** +- Injecting a postMessage bridge (e.g., NIP-07 Nostr provider) +- Adding resize reporting scripts +- Injecting theme CSS +- Adding custom error handlers + +**Safety rules:** +- Only inject into `text/html` responses +- Inject before `` or after `` — never in the middle of content +- The injected script should check `if (window === window.top) return` to only activate inside iframes +- Use `sub_filter_once on` to prevent double-injection + +--- + +## 11. Performance Considerations + +### iframe Resource Impact + +Each iframe creates: +- Separate browsing context (DOM, CSS engine, JS runtime) +- 10-50MB memory per iframe depending on app complexity +- Own JavaScript execution on main thread + +### Mitigation + +- Only load visible iframes (`loading="lazy"` or Intersection Observer) +- Destroy iframes when hidden (remove from DOM, not just `display:none`) +- Use `about:blank` for pre-created iframe elements, set real src when needed +- Limit concurrent iframes to 3-5 for acceptable performance +- Consider `credentialless` for public content (lighter weight) + +### Caching + +- iframes follow standard HTTP caching (Cache-Control, ETag) +- Setting `src` to the same URL does NOT trigger reload +- To force reload: append query param (`?t=${Date.now()}`) or call `iframe.contentWindow.location.reload()` (same-origin only) + +--- + +## 12. Debugging Checklist + +When an app doesn't work in an iframe, check in this order: + +1. **Check response headers:** + ```bash + curl -sI http://localhost:{PORT} | grep -iE 'x-frame|content-security|cross-origin' + ``` + +2. **Check if Nginx is stripping headers:** + ```bash + curl -sI http://{node-ip}/app/{id}/ | grep -iE 'x-frame|content-security' + ``` + +3. **Check browser console** for: + - "Refused to display in a frame" → XFO or frame-ancestors blocking + - "Mixed Content" → HTTP iframe on HTTPS page + - "WebSocket connection failed" → Missing WebSocket proxy config + - "net::ERR_BLOCKED_BY_RESPONSE" → COEP/CORP/COOP headers blocking + +4. **Check if app has JavaScript frame-busting:** + - Open the app directly, view source, search for `window.top`, `window.parent`, `frameElement` + +5. **Check if cookies/auth work:** + - Open DevTools → Application → Cookies in the iframe context + - Look for blocked cookies (yellow warning triangle) + +6. **Check base path issues:** + - DevTools → Network tab → look for 404s on CSS/JS/API requests + - If assets load from `/` instead of `/app/{id}/`, the app needs base path config + +7. **Check WebSocket connections:** + - DevTools → Network → WS tab → check if WebSocket connections upgrade successfully + +--- + +## 13. Archipelago-Specific Patterns + +### Port-to-Proxy Mapping + +The `appLauncher.ts` store maintains `PORT_TO_PROXY` mapping: direct ports → `/app/{name}/` paths. When running on HTTPS, direct HTTP port URLs are rewritten to same-origin proxy paths via `toEmbeddableUrl()`. + +### mustOpenInNewTab Detection + +Apps that cannot work in iframes are listed in `IFRAME_BLOCKED_HOSTS` (external sites) and port-based checks (local apps with unstrippable restrictions). These automatically open in a new browser tab. + +### Nostr Provider Injection + +All proxied apps receive `/nostr-provider.js` via `sub_filter` injection. This provides `window.nostr` (NIP-07) inside iframes, allowing apps to request signing, key access, and encryption from the parent portal without exposing secret keys. + +### Identity Protocol + +Identity-aware apps (IndeedHub) receive user identity via `archipelago:identity` postMessage after an identity picker modal. Identity includes DID, pubkey, npub, and a signed challenge for verification. + +### Payment Protocol + +Apps can request Bitcoin payments via `archipelago:payment-request` postMessage. The parent validates, shows a confirmation modal, executes the payment (ecash/LN/on-chain based on amount), and responds with a receipt. + +### iframe Load Fallback + +If an iframe fails to load within 15 seconds or loads empty content, a fallback UI is shown with a "Can't display in frame" message and an "Open in new tab" button. + +--- + +## Decision Framework + +When adding a new app to Archipelago: + +``` +1. Does the app set X-Frame-Options or CSP frame-ancestors? + ├── No → iframe via /app/{id}/ proxy, done + └── Yes → + 2. Can you strip headers at Nginx? + ├── Yes, and app works → iframe via /app/{id}/ proxy + └── App still broken after stripping → + 3. Does the app have JavaScript frame-busting? + ├── Yes → Open in new tab (add to mustOpenInNewTab) + └── No → + 4. Is it a base path issue? + ├── Yes → Configure app's native base path or use sub_filter + └── No → + 5. Is it a WebSocket issue? + ├── Yes → Add WebSocket proxy config + └── No → + 6. Is it a cookie/auth issue? + ├── Yes → Same-origin proxy should fix it + └── No → Debug with browser DevTools, check console errors +``` diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 9b66d7c9..0c003fdb 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -9,3 +9,4 @@ - [iso-build-session-2026-03-10.md](iso-build-session-2026-03-10.md) — ISO build session notes - [unbundled-iso.md](unbundled-iso.md) — Unbundled ISO approach notes - [web-only-apps.md](web-only-apps.md) — Web-only apps (L484 category) and iframe compatibility +- [feedback_app_display_modes.md](feedback_app_display_modes.md) — App browser: 3 display modes (right panel, full overlay, fullscreen) with persistent setting diff --git a/.claude/memory/feedback_app_display_modes.md b/.claude/memory/feedback_app_display_modes.md new file mode 100644 index 00000000..320ee986 --- /dev/null +++ b/.claude/memory/feedback_app_display_modes.md @@ -0,0 +1,15 @@ +--- +name: App display modes +description: App session browser should support 3 display modes - right panel, full overlay, and fullscreen - with a persistent setting +type: feedback +--- + +App session views (the built-in browser for launching apps) should support three display modes, controlled by a setting dropdown in the header bar: + +1. **Display in right panel** — app loads inside the dashboard's right content area (sidebar visible) +2. **Display over whole app** — app overlays the entire viewport including sidebar (like old AppLauncherOverlay with `fixed inset-0 z-[2400]`) +3. **Open fullscreen** — uses browser Fullscreen API for true fullscreen + +**Why:** The user likes the right-panel approach (screenshot showed it working well) but also wants the option to go full overlay or fullscreen. The setting should persist (localStorage) and apply to all apps globally. + +**How to apply:** Store the preference in localStorage. The header bar should have a dropdown/toggle with icons for the three modes. Default to "right panel" mode. diff --git a/.claude/memory/feedback_fullscreen_modals.md b/.claude/memory/feedback_fullscreen_modals.md new file mode 100644 index 00000000..50c84e99 --- /dev/null +++ b/.claude/memory/feedback_fullscreen_modals.md @@ -0,0 +1,11 @@ +--- +name: Full-screen modals +description: App session modals and overlays must cover the full viewport, not just the right panel area of the dashboard +type: feedback +--- + +Modals and app session overlays must be **full screen** — covering the entire viewport including the sidebar/nav. Do NOT constrain them to just the right content panel of the dashboard layout. + +**Why:** The user has corrected this multiple times. Modals that only cover the right panel look wrong and don't provide an immersive app experience. + +**How to apply:** When creating overlays, modals, or app session views, use `position: fixed; inset: 0; z-index: 2400+` to cover the entire screen. The existing AppLauncherOverlay already does this correctly with `class="fixed inset-0 z-[2400]"` — follow that pattern. On mobile it should be truly fullscreen (no padding/margins). On desktop, the glass panel with margins (md:p-10, md:rounded-2xl) is fine. diff --git a/apps/indeedhub/Dockerfile b/apps/indeedhub/Dockerfile index f7afb278..5bb67427 100644 --- a/apps/indeedhub/Dockerfile +++ b/apps/indeedhub/Dockerfile @@ -29,10 +29,26 @@ ENV NEXT_TELEMETRY_DISABLED=1 # Remove shaka-player .d.ts files that break the build (per package.json build script) RUN rm -f ./node_modules/shaka-player/dist/*.d.ts -# Patch: make the home page static (no SSR revalidation) so it uses the -# pre-rendered Webflow HTML from build time instead of re-fetching every request. -# Also add error handling so SSR failures don't crash the page. -RUN sed -i '1s|^|export const revalidate = false;\nexport const dynamic = "force-static";\n|' src/app/page.tsx +# Patch: replace home page with error-resilient version that doesn't crash +# when the Webflow landing page URL is unreachable from the container. +RUN printf '%s\n' \ + "import axios from 'axios';" \ + "import { HomeClient } from './page.client';" \ + "" \ + "export const dynamic = 'force-dynamic';" \ + "" \ + "export default async function Home() {" \ + " try {" \ + " const response = await axios('https://indeehub-30479a.webflow.io/', { timeout: 8000 });" \ + " if (response.status !== 200) throw new Error('Bad status');" \ + " const html = String(response.data)" \ + " .replace('https://cdn.prod.website-files.com/img/favicon.ico', '/favicon.ico')" \ + " .replace('https://cdn.prod.website-files.com/img/webclip.png', '/favicon.ico');" \ + " return ;" \ + " } catch {" \ + " return IndeeHub

IndeeHub

Loading content...

\" />;" \ + " }" \ + "}" > src/app/page.tsx RUN npm run build diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs index f3bc2dc8..120d9f7c 100644 --- a/core/archipelago/src/api/rpc/package.rs +++ b/core/archipelago/src/api/rpc/package.rs @@ -304,7 +304,7 @@ impl RpcHandler { let conf_path = format!("{}/bitcoin.conf", bitcoin_dir); let bitcoin_conf = "\ server=1\n\ -txindex=1\n\ +prune=550\n\ rpcuser=archipelago\n\ rpcpassword=archipelago123\n\ rpcbind=0.0.0.0\n\ @@ -404,29 +404,28 @@ printtoconsole=1\n"; }); } - // Post-install: Start electrs-ui container for electrs - if matches!(package_id, "mempool-electrs" | "electrs") { + // Post-install: Build and start bitcoin-ui container for Bitcoin Knots + if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { tokio::spawn(async move { - // Build and start electrs-ui with host networking so it can reach backend on 127.0.0.1:5678 - let ui_dir = "/opt/archipelago/docker/electrs-ui"; + let ui_dir = "/opt/archipelago/docker/bitcoin-ui"; let _ = tokio::process::Command::new("sudo") - .args(["podman", "build", "-t", "localhost/electrs-ui", ui_dir]) + .args(["podman", "build", "-t", "localhost/bitcoin-ui", ui_dir]) .output() .await; - // Remove old UI container if it exists let _ = tokio::process::Command::new("sudo") - .args(["podman", "rm", "-f", "electrs-ui"]) + .args(["podman", "rm", "-f", "bitcoin-ui"]) .output() .await; let _ = tokio::process::Command::new("sudo") .args([ - "podman", "run", "-d", "--name", "electrs-ui", - "--restart=unless-stopped", "--network=host", - "localhost/electrs-ui", + "podman", "run", "-d", "--name", "bitcoin-ui", + "--restart=unless-stopped", + "-p", "8334:80", + "localhost/bitcoin-ui:latest", ]) .output() .await; - info!("Electrs UI container started on port 50002"); + info!("Bitcoin UI container started on port 8334"); }); } diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md new file mode 100644 index 00000000..f3d955c4 --- /dev/null +++ b/docs/refactoring-plan.md @@ -0,0 +1,264 @@ +# Archy Refactoring Plan — Codebase Quality & Reliability + +**Period**: March 2026 — March 2029 +**Scope**: Refactoring, bug fixes, library adoption, testing, performance only +**Out of scope**: New features, design changes, UI changes + +This plan exists alongside the feature roadmap. Refactoring work should be interleaved with feature sprints — not blocked by them. + +--- + +## Year 1: Fix What's Broken, Adopt Proper Libraries (March 2026 — Feb 2027) + +### Q1 2026: Critical Fixes & Database + +#### 1. Enable SQLite via sqlx (HIGH — crash resilience) +- **Problem**: All state is in-memory. Crashes lose everything except container snapshots. `sqlx` is commented out in `core/Cargo.toml`. +- **Fix**: Uncomment sqlx, create migrations for: sessions, user data, peer state, metrics history, notification log. Keep the in-memory `DataModel` as a read cache backed by SQLite. +- **Files**: `core/Cargo.toml`, `core/archipelago/src/state.rs`, new `core/archipelago/src/db/` module +- **Why not a full Postgres**: Single-user appliance. SQLite is the right choice — zero config, file-based, embedded. + +#### 2. Enforce RBAC (HIGH — security) +- **Problem**: `UserRole::can_access()` is implemented in `auth.rs` but never called in `rpc/mod.rs`. Every authenticated user has full admin access. +- **Fix**: Add role check in `RpcHandler::handle()` before dispatching to method handlers. Wire up role assignment during onboarding. +- **Files**: `core/archipelago/src/api/rpc/mod.rs`, `core/archipelago/src/auth.rs` + +#### 3. Fix session TTL clock bug (HIGH — correctness) +- **Problem**: `session.rs` uses `Instant::now()` for TTL. `Instant` is monotonic but resets on system sleep/hibernate — common on the hardware Archy targets. +- **Fix**: Use `SystemTime::now()` for session expiry timestamps, or better — use `tower-sessions` with the new SQLite backend. +- **Files**: `core/archipelago/src/session.rs` + +#### 4. Fix 10 failing frontend tests (MEDIUM) +- **Problem**: `appLauncher.test.ts` and `settings.test.ts` are out of sync with current implementation. +- **Fix**: Update test expectations to match current behavior. Don't mock what doesn't need mocking. +- **Files**: `neode-ui/src/stores/__tests__/appLauncher.test.ts`, `neode-ui/src/views/__tests__/settings.test.ts` + +#### 5. Remove dead dependencies (LOW) +- **Problem**: `dockerode` in `package.json` is unused (container ops go through RPC). +- **Fix**: `npm uninstall dockerode @types/dockerode` +- **Files**: `neode-ui/package.json` + +### Q2 2026: WebSocket Efficiency & Validation + +#### 6. Add json-patch crate to backend (HIGH — performance) +- **Problem**: Backend broadcasts the entire `DataModel` on every state change. Frontend already has `fast-json-patch` and supports incremental updates. Backend just doesn't generate patches. +- **Fix**: Add `json-patch` crate. Before broadcasting, diff old vs new `DataModel`, send only the RFC 6902 patch. Fall back to full sync if patch is larger than full model. +- **Files**: `core/Cargo.toml`, `core/archipelago/src/state.rs` + +#### 7. Add form validation with zod (MEDIUM — maintainability) +- **Problem**: Manual inline validation scattered across Login, Settings, Onboarding. As forms grow, this becomes a maintenance burden. +- **Fix**: `npm install zod`. Create validation schemas in `src/types/schemas.ts`. Use in forms and RPC request builders. This is especially important for onboarding where bad input causes cryptographic key generation to fail silently. +- **Files**: `neode-ui/package.json`, new `neode-ui/src/types/schemas.ts`, `Login.vue`, `Settings.vue`, onboarding views + +#### 8. Move hardcoded app metadata to manifest files (MEDIUM — maintainability) +- **Problem**: `docker_packages.rs` has hardcoded port mappings, titles, descriptions, and icon paths for ~20 apps. App manifests exist in `apps/` but aren't the source of truth. +- **Fix**: Make `apps/{app-id}/manifest.yml` the single source of truth. Load metadata from manifests at startup. Remove hardcoded maps from Rust source. +- **Files**: `core/archipelago/src/container/docker_packages.rs`, `apps/*/manifest.yml` + +### Q3 2026: Error Handling & Testing + +#### 9. Structured error types per backend module (MEDIUM — debuggability) +- **Problem**: Everything uses `anyhow::Result`. When errors bubble up through RPC, you lose the module context. User-facing vs system errors aren't distinguished at the type level. +- **Fix**: Create `thiserror` error enums for each major module: `AuthError`, `ContainerError`, `FederationError`, `IdentityError`. Map to appropriate HTTP status codes and user-friendly messages in the RPC layer. +- **Files**: Each module in `core/archipelago/src/` + +#### 10. Backend integration tests for RPC endpoints (HIGH — reliability) +- **Problem**: 312 unit tests exist but zero integration tests for 80+ RPC endpoints. No test ever makes an actual HTTP request to the server. +- **Fix**: Create integration test harness that spins up a real server instance (with test config, temp data dir). Test auth flow, container operations, identity, federation. Use `reqwest` as test client. +- **Files**: New `core/archipelago/tests/` directory + +#### 11. Frontend 404 route (LOW — UX) +- **Problem**: No catch-all route. Invalid URLs silently show nothing. +- **Fix**: Add `/:pathMatch(.*)*` catch-all route that shows a "Page not found" view with navigation back to dashboard. +- **Files**: `neode-ui/src/router/index.ts`, new `neode-ui/src/views/NotFound.vue` + +### Q4 2026: Clean Up Dead Code & CI + +#### 12. Remove dead code and #[allow(dead_code)] (LOW — cleanliness) +- **Problem**: `auth.rs` has `#[allow(dead_code)]` on `OnboardingState` fields and `AuthManager` methods. Either use them or remove them. +- **Fix**: Audit all `#[allow(dead_code)]`, `#[allow(unused)]`. Remove genuinely unused code. Wire up code that should be used (like RBAC — covered in item 2). +- **Files**: `core/archipelago/src/auth.rs` and others + +#### 13. Set up CI pipeline (HIGH — process) +- **Problem**: No automated testing on push/PR. All testing is manual or via deploy scripts. +- **Fix**: GitHub Actions workflow: `cargo clippy`, `cargo test`, `npm run type-check`, `npm run test` on every push. Fail the build on warnings. +- **Files**: New `.github/workflows/ci.yml` + +#### 14. Cosign container image verification (MEDIUM — security) +- **Problem**: `podman_client.rs:95` has a TODO for cosign signature verification. Container images are pulled without validation. +- **Fix**: Implement cosign verification using the `sigstore` crate, or shell out to `cosign verify` as a first step. At minimum, verify image digests against a pinned manifest. +- **Files**: `core/container/src/podman_client.rs`, `core/security/` + +--- + +## Year 2: Robustness & Performance (March 2027 — Feb 2028) + +### Q1 2027: Backend Architecture + +#### 15. Migrate from hyper to axum (MEDIUM — maintainability) +- **Problem**: Raw `hyper` 0.14 with manual routing in `handler.rs` (813 lines). Route matching, middleware, and error handling are all hand-rolled. `hyper` 0.14 is also end-of-life. +- **Fix**: Migrate to `axum` (built on hyper 1.x, maintained by tokio team). Axum gives you: extractors, middleware stack, typed routing, tower integration. The RPC methods stay the same — only the HTTP layer changes. +- **Files**: `core/archipelago/src/api/handler.rs`, `core/archipelago/src/api/mod.rs`, `core/Cargo.toml` +- **Risk**: Medium. Do this on a branch, test thoroughly. The RPC logic doesn't change, just the HTTP glue. + +#### 16. Replace custom rate limiter with tower middleware (LOW — correctness) +- **Problem**: Hand-rolled in-memory rate limiter in `rpc/mod.rs`. Works for single instance but not distributed. +- **Fix**: Use `tower::limit::RateLimitLayer` or `governor` crate. Cleaner, tested, configurable per-route. +- **Files**: `core/archipelago/src/api/rpc/mod.rs` + +#### 17. Persistent sessions in SQLite (MEDIUM — UX) +- **Problem**: Sessions are in-memory. Server restart logs out all users. +- **Fix**: With SQLite from item 1, store sessions in DB. Users stay logged in across restarts. +- **Files**: `core/archipelago/src/session.rs` + +### Q2 2027: Frontend Architecture + +#### 18. Audit and optimize bundle size (MEDIUM — performance) +- **Problem**: D3 is a large dependency (~240KB) used only for `LineChart.vue`. Target is <500KB gzipped. +- **Fix**: Replace full `d3` import with only `d3-scale`, `d3-shape`, `d3-axis` (tree-shakeable). Or evaluate `unovis` or native Canvas for simple line charts. Measure before and after. +- **Files**: `neode-ui/package.json`, `neode-ui/src/components/LineChart.vue` + +#### 19. Vue Router route transitions (LOW — polish) +- **Problem**: No transition animations between routes. Pages appear/disappear instantly. +- **Fix**: Add `` with `` wrapper. Simple fade (200ms) is enough — matches the existing glassmorphism feel without changing the design. +- **Files**: `neode-ui/src/App.vue` +- **Note**: This is not a design change — it's a missing standard Vue pattern. + +#### 20. TypeScript strict cleanup (LOW — type safety) +- **Problem**: WebSocket callback types in `app.ts:105` use inline object types instead of importing the `Update` type from `@/types/api`. +- **Fix**: Audit all stores and components for inline type definitions that should reference shared types. Centralize in `src/types/`. +- **Files**: `neode-ui/src/stores/app.ts`, `neode-ui/src/types/` + +### Q3 2027: Testing & Observability + +#### 21. Reach 60% test coverage (HIGH — reliability) +- **Problem**: Frontend has ~505 passing tests but many views untested. Backend has zero RPC integration tests. +- **Fix**: Prioritize testing for: auth flow, container lifecycle, WebSocket reconnection, federation handshake, backup/restore. Use coverage reports to find gaps. +- **Target**: 60% line coverage frontend, 50% backend + +#### 22. Add OpenTelemetry tracing (MEDIUM — observability) +- **Problem**: `tracing` is used for logging but there's no distributed tracing or metrics export. When something goes wrong in production, you're reading log files. +- **Fix**: Add `tracing-opentelemetry` and `opentelemetry-otlp`. Export traces to a local collector (Grafana is already a supported app). Instrument RPC handlers, container operations, federation sync. +- **Files**: `core/Cargo.toml`, `core/archipelago/src/main.rs` + +#### 23. Prometheus metrics export (MEDIUM — monitoring) +- **Problem**: `MetricsStore` collects data but doesn't expose it. No way to monitor Archy health externally. +- **Fix**: Add `/metrics` endpoint in Prometheus format using `prometheus` crate. Expose: RPC latency histograms, active sessions, container health, WebSocket connections, memory usage. +- **Files**: `core/archipelago/src/api/handler.rs`, `core/archipelago/src/monitoring/` + +### Q4 2027: Performance + +#### 24. Optimize container scanner (MEDIUM — CPU) +- **Problem**: `docker_packages.rs` scans all containers every 10 seconds with full JSON parsing. On a system with 30+ containers, this is unnecessary CPU churn. +- **Fix**: Use Podman events API (`podman events --format json`) to watch for container state changes instead of polling. Fall back to polling every 60s as a safety net. +- **Files**: `core/archipelago/src/container/docker_packages.rs` + +#### 25. Lazy-load i18n locales (LOW — bundle size) +- **Problem**: Spanish locale exists but loading behavior isn't optimized. +- **Fix**: Use Vue i18n's lazy loading: load only the active locale on startup, fetch others on demand. +- **Files**: `neode-ui/src/i18n.ts` + +--- + +## Year 3: Production Hardening (March 2028 — March 2029) + +### Q1 2028: Resilience + +#### 26. Database migration system (MEDIUM — upgradability) +- **Problem**: Once SQLite is in use, schema changes need managed migrations. +- **Fix**: Use `sqlx` migrations (already supported). Create `core/archipelago/migrations/` directory. Run migrations on startup before serving requests. +- **Files**: `core/archipelago/migrations/`, `core/archipelago/src/main.rs` + +#### 27. Graceful degradation for container failures (MEDIUM — UX) +- **Problem**: If Podman is down or unresponsive, the entire backend can hang on container operations. +- **Fix**: Add timeouts to all Podman CLI calls (some already have them, make it universal). Show degraded state in UI rather than hanging. Container operations should never block the main RPC handler. +- **Files**: `core/container/src/podman_client.rs` + +#### 28. WebSocket backpressure handling (LOW — stability) +- **Problem**: Broadcast channel capacity is 100. If a slow client can't keep up, messages are dropped silently. +- **Fix**: Detect `RecvError::Lagged`, send full resync to that client. Log when clients fall behind consistently. +- **Files**: `core/archipelago/src/api/handler.rs` + +### Q2 2028: Security Hardening + +#### 29. Full security audit pass (HIGH — security) +- **Problem**: Various small issues accumulated: CORS could be tighter, rate limiting coverage is incomplete, error messages could leak internal paths. +- **Fix**: Systematic pass through all 80+ RPC endpoints. Verify: input validation, authorization, rate limiting, error sanitization, path traversal prevention. Document findings. +- **Files**: All RPC handlers + +#### 30. Automated dependency security scanning (MEDIUM — supply chain) +- **Problem**: No automated `cargo audit` or `npm audit` in CI. +- **Fix**: Add to CI pipeline. Run weekly via cron. Block releases on known vulnerabilities (with severity threshold). +- **Files**: `.github/workflows/ci.yml`, `scripts/audit-deps.sh` + +### Q3 2028: Final Quality + +#### 31. Reach 80% test coverage (HIGH — confidence) +- **Target**: 80% line coverage across frontend and backend +- **Focus**: Edge cases, error paths, recovery scenarios, concurrent operations + +#### 32. Load testing (MEDIUM — capacity planning) +- **Problem**: No load testing. Unknown how many concurrent users, containers, or WebSocket connections Archy can handle on target hardware. +- **Fix**: Create load test suite with `k6` or `criterion` (Rust). Test: concurrent RPC calls, WebSocket connections, container operations. Document capacity limits per hardware tier. +- **Files**: New `tests/load/` directory + +#### 33. Code documentation pass (LOW — maintainability) +- **Problem**: Module-level docs are sparse. New contributors (or future you) need to understand the architecture from code alone. +- **Fix**: Add `//!` module docs to every Rust module. Add JSDoc to every Vue composable and store. Document the "why" of architectural decisions inline. +- **Files**: All modules + +### Q4 2028: Polish & Maintenance + +#### 34. Dependency update cycle (ONGOING) +- Monthly: `cargo update`, `npm update`, review changelogs +- Quarterly: Major version upgrades (evaluate breaking changes) +- Yearly: Evaluate if any custom code can be replaced by now-mature libraries + +#### 35. Refactoring retrospective +- Review this plan against actual state +- Document what worked, what didn't +- Create Year 4+ maintenance plan if needed + +--- + +## Priority Summary + +| Priority | Item | Impact | +|----------|------|--------| +| **Critical** | 1. SQLite database | Crash resilience | +| **Critical** | 2. Enforce RBAC | Security | +| **Critical** | 3. Fix session TTL bug | Correctness | +| **Critical** | 6. JSON patch broadcasting | Performance | +| **Critical** | 13. CI pipeline | Process | +| **High** | 4. Fix failing tests | Reliability | +| **High** | 10. Backend integration tests | Reliability | +| **High** | 14. Cosign verification | Security | +| **High** | 15. Migrate hyper → axum | Maintainability | +| **High** | 21. 60% test coverage | Reliability | +| **High** | 29. Security audit | Security | +| **High** | 31. 80% test coverage | Confidence | +| **Medium** | 7. Zod validation | Maintainability | +| **Medium** | 8. Manifest-driven metadata | Maintainability | +| **Medium** | 9. Structured error types | Debuggability | +| **Medium** | 17. Persistent sessions | UX | +| **Medium** | 18. D3 tree-shaking | Bundle size | +| **Medium** | 22. OpenTelemetry | Observability | +| **Medium** | 23. Prometheus metrics | Monitoring | +| **Medium** | 24. Container scanner optimization | CPU | +| **Low** | 5. Remove dockerode | Cleanliness | +| **Low** | 11. 404 route | UX | +| **Low** | 12. Dead code cleanup | Cleanliness | +| **Low** | 16. Tower rate limiter | Correctness | +| **Low** | 19. Route transitions | Polish | +| **Low** | 20. TypeScript cleanup | Type safety | +| **Low** | 25. Lazy i18n | Bundle size | + +--- + +## Guiding Principles + +1. **Use established crates and packages** — Don't reinvent what's solved. `sqlx`, `axum`, `tower`, `json-patch`, `zod`, `governor` exist for a reason. +2. **Keep custom what's custom** — Federation, marketplace, DWN, the design system — these are genuinely yours. Don't force a library where none fits. +3. **Test what matters** — Auth, container lifecycle, data persistence, WebSocket reliability. Not every utility function needs a test. +4. **Refactor in place** — No rewrites. Migrate incrementally. Every commit should leave the codebase better than it found it. +5. **No design changes** — The glassmorphism system, the layout, the UX flow — all stay exactly as they are. This plan only touches the internals. diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index a1f3e2da..e0a38581 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -332,14 +332,24 @@ server { sub_filter '' ''; } location /app/indeedhub/_next/ { - proxy_pass http://127.0.0.1:8190/_next/; + proxy_pass http://127.0.0.1:7777/_next/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_cache_valid 200 30d; add_header Cache-Control "public, max-age=2592000, immutable"; } + # IndeeHub WebSocket proxy + location /app/indeedhub/ws/ { + proxy_pass http://127.0.0.1:7777/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400s; + } location /app/indeedhub/ { - proxy_pass http://127.0.0.1:8190/; + proxy_pass http://127.0.0.1:7777/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -349,10 +359,12 @@ server { proxy_hide_header Content-Security-Policy; add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; - sub_filter_types text/html; + sub_filter_types text/html text/css application/javascript application/json; sub_filter_once off; - sub_filter '/_next/' '/app/indeedhub/_next/'; - sub_filter '/favicon.ico' '/app/indeedhub/favicon.ico'; + 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 '' ''; } location /app/lnd/ { diff --git a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf index 853b8fc0..a0e9b874 100644 --- a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf +++ b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf @@ -232,14 +232,23 @@ location /app/electrs/ { sub_filter '' ''; } location /app/indeedhub/_next/ { - proxy_pass http://127.0.0.1:8190/_next/; + proxy_pass http://127.0.0.1:7777/_next/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_cache_valid 200 30d; add_header Cache-Control "public, max-age=2592000, immutable"; } +location /app/indeedhub/ws/ { + proxy_pass http://127.0.0.1:7777/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400s; +} location /app/indeedhub/ { - proxy_pass http://127.0.0.1:8190/; + proxy_pass http://127.0.0.1:7777/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -248,10 +257,12 @@ location /app/indeedhub/ { proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; proxy_set_header Accept-Encoding ""; - sub_filter_types text/html; + sub_filter_types text/html text/css application/javascript application/json; sub_filter_once off; - sub_filter '/_next/' '/app/indeedhub/_next/'; - sub_filter '/favicon.ico' '/app/indeedhub/favicon.ico'; + 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 '' ''; } location /app/nginx-proxy-manager/ { diff --git a/neode-ui/src/components/NostrIdentityPicker.vue b/neode-ui/src/components/NostrIdentityPicker.vue index 9904b74a..9b7e8d14 100644 --- a/neode-ui/src/components/NostrIdentityPicker.vue +++ b/neode-ui/src/components/NostrIdentityPicker.vue @@ -6,8 +6,8 @@ class="fixed inset-0 z-[3100] flex items-center justify-center p-4" @click="$emit('cancel')" > - -
+ +
- +
-
- - - - - - - - - - - - - - - - - - - - - - - - - - +
+ +
+
+
+ + +
+
+ + + +
+
-

Select Identity

-

Nostr authentication protocol

+

Select Identity

+

Nostr authentication protocol

-
- + Loading identities...
-

No identities found.

Create one in Settings → Credentials

- @@ -113,7 +96,7 @@
-
-

- NIP-07 · SECP256K1 · Signed locally +

+ NIP-07 · SECP256K1 · Signed locally

@@ -179,9 +159,7 @@ const hasNostrKey = computed(() => { }) watch(() => props.show, async (open) => { - if (open) { - await loadIdentities() - } + if (open) await loadIdentities() }) onMounted(() => { @@ -195,9 +173,7 @@ async function loadIdentities() { identities.value = res.identities || [] const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey) || identities.value.find(i => i.nostr_pubkey) - if (defaultId) { - selectedId.value = defaultId.id - } + if (defaultId) selectedId.value = defaultId.id } catch { identities.value = [] } finally { @@ -207,9 +183,7 @@ async function loadIdentities() { function confirm() { const selected = identities.value.find(i => i.id === selectedId.value) - if (selected) { - emit('select', selected) - } + if (selected) emit('select', selected) } function truncateNpub(npub: string): string { @@ -221,92 +195,122 @@ function avatarClasses(purpose: string): string { switch (purpose) { case 'business': return 'bg-blue-500/15 text-blue-400 border-blue-500/25' case 'anonymous': return 'bg-purple-500/15 text-purple-400 border-purple-500/25' - default: return 'bg-orange-500/15 text-orange-400 border-orange-500/25' + default: return 'bg-white/10 text-white/80 border-white/20' } } diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 1577dad6..01799968 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -169,6 +169,11 @@ const router = createRouter({ name: 'chat', component: () => import('../views/Chat.vue'), }, + { + path: 'app-session/:appId', + name: 'app-session', + component: () => import('../views/AppSession.vue'), + }, // Containers removed: My Apps serves the same purpose. Redirect old links. { path: 'containers', diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 2b879242..2e045169 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -1,14 +1,12 @@ import { defineStore } from 'pinia' import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' +import router from '@/router' /** Hostnames of external sites that block iframes via X-Frame-Options or CSP. * These always open in a new tab. Other external sites load directly in the iframe. */ -const IFRAME_BLOCKED_HOSTS: string[] = [ - '484.kitchen', - 'botfights.net', - 'present.l484.com', -] +/** Legacy: these used to open in new tabs. Now all apps go through AppSession. */ +const IFRAME_BLOCKED_HOSTS: string[] = [] /** External site proxy paths — disabled. External URLs load directly in the iframe * via their standard https:// URL. The /ext/ subpath approach broke SPAs. */ @@ -66,7 +64,7 @@ const PORT_TO_PROXY: Record = { '8176': '/app/fedimint-gateway/', '3100': '/app/dwn/', '18081': '/app/nostr-rs-relay/', - '8190': '/app/indeedhub/', + '7777': '/app/indeedhub/', } /** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content. @@ -131,7 +129,19 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const showConsent = ref(false) let previousActiveElement: HTMLElement | null = null + /** Open app in full-page session view (preferred — no iframe subpath issues) */ + function openSession(appId: string) { + router.push({ name: 'app-session', params: { appId } }) + } + + /** Legacy: open app in iframe overlay (kept for backward compat) */ function open(payload: { url: string; title: string; openInNewTab?: boolean }) { + // Route to full-page session if we can resolve an app ID from the URL + const resolvedId = resolveAppIdFromUrl(payload.url) + if (resolvedId) { + openSession(resolvedId) + return + } if (payload.openInNewTab || mustOpenInNewTab(payload.url)) { window.open(payload.url, '_blank', 'noopener,noreferrer') return @@ -143,6 +153,31 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { isOpen.value = true } + /** Resolve an app ID from a URL (port or known external) */ + function resolveAppIdFromUrl(urlStr: string): string | null { + try { + const u = new URL(urlStr) + // Check port-based apps + for (const [port, proxyPath] of Object.entries(PORT_TO_PROXY)) { + if (u.port === port) { + return proxyPath.replace('/app/', '').replace(/\/$/, '') + } + } + // Check external URLs + const EXTERNAL_APP_HOSTS: Record = { + 'botfights.net': 'botfights', + 'nwnn.l484.com': 'nwnn', + '484.kitchen': '484-kitchen', + 'cta.tx1138.com': 'call-the-operator', + 'present.l484.com': 'arch-presentation', + 'syntropy.institute': 'syntropy-institute', + 'teeminuszero.net': 't-zero', + 'nostrudel.ninja': 'nostrudel', + } + return EXTERNAL_APP_HOSTS[u.hostname] || null + } catch { return null } + } + function close() { const toRestore = previousActiveElement previousActiveElement = null @@ -289,6 +324,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { url, title, open, + openSession, close, showConsent, consentRequest, diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index ebace6d7..ae13b12c 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -500,8 +500,6 @@ const isWebOnly = computed(() => appId.value in WEB_ONLY_APP_URLS) /** Map route/marketplace app IDs to backend package keys (container names). */ const ROUTE_TO_PACKAGE_KEY: Record = { mempool: 'mempool-web', - 'mempool-electrs': 'mempool-electrs', - electrs: 'mempool-electrs', btcpay: 'btcpay-server', 'btcpay-server': 'btcpay-server', fedimint: 'fedimint', @@ -714,124 +712,7 @@ function goBack() { function launchApp() { if (!pkg.value) return - - const isDev = import.meta.env.DEV - const id = appId.value - - // Web-only apps — use their external URL directly - const webOnlyUrl = WEB_ONLY_APP_URLS[id] - if (webOnlyUrl) { - useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title }) - return - } - - // Special handling for apps with Docker containers - const appUrls: Record = { - 'lorabell': { - dev: 'http://192.168.1.166', - prod: 'http://192.168.1.166' - }, - 'atob': { - dev: 'http://localhost:8102', - prod: 'https://app.atobitcoin.io' - }, - 'k484': { - dev: 'http://localhost:8103', - prod: 'http://localhost:8103' // Self-hosted splash screen - }, - 'indeedhub': { - dev: 'http://localhost:8190', - prod: 'http://localhost:8190' - }, - // Dummy apps - replace with real URLs when packaged - 'bitcoin': { - dev: 'http://localhost:8332', - prod: 'http://localhost:8332' - }, - 'btcpay-server': { - dev: 'http://localhost:23000', - prod: 'http://localhost:23000' - }, - 'homeassistant': { - dev: 'http://localhost:8123', - prod: 'http://localhost:8123' - }, - 'grafana': { - dev: 'http://localhost:3000', - prod: 'http://localhost:3000' - }, - 'endurain': { - dev: 'http://localhost:8080', - prod: 'http://localhost:8080' - }, - 'fedimint': { - dev: 'http://localhost:8175', - prod: 'http://192.168.1.228:8175' - }, - 'fedimint-gateway': { - dev: 'http://localhost:8176', - prod: 'http://192.168.1.228:8176' - }, - 'morphos-server': { - dev: 'http://localhost:8081', - prod: 'http://localhost:8081' - }, - 'lightning-stack': { - dev: 'http://localhost:9735', - prod: 'http://localhost:9735' - }, - 'mempool': { - dev: 'http://localhost:4080', - prod: 'http://localhost:4080' - }, - 'ollama': { - dev: 'http://localhost:11434', - prod: 'http://localhost:11434' - }, - 'searxng': { - dev: 'http://localhost:8888', - prod: 'http://localhost:8888' - }, - 'onlyoffice': { - dev: 'http://localhost:9980', - prod: 'http://localhost:9980' - }, - 'penpot': { - dev: 'http://localhost:9001', - prod: 'http://localhost:9001' - }, - 'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' }, - 'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' }, - 'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' }, - 'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' }, - 'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' }, - 'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' }, - 'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' }, - 'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' }, - 'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' }, - 'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' }, - 'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' }, - 'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' }, - } - - if (appUrls[id]) { - let url = isDev ? appUrls[id].dev : appUrls[id].prod - // Replace localhost with current hostname for remote access - if (url.includes('localhost')) { - url = url.replace('localhost', window.location.hostname) - } - useAppLauncherStore().open({ url, title: pkg.value.manifest.title }) - return - } - - // For other apps, construct the launch URL - // In a real deployment, this would use the Tor or LAN address from interfaces - const torAddress = pkg.value.manifest.interfaces?.main?.['tor-config'] - const lanConfig = pkg.value.manifest.interfaces?.main?.['lan-config'] - - if (torAddress || lanConfig) { - showActionError(t('appDetails.noLaunchUrl')) - } + useAppLauncherStore().openSession(appId.value) } async function startApp() { diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue new file mode 100644 index 00000000..c3e75ebc --- /dev/null +++ b/neode-ui/src/views/AppSession.vue @@ -0,0 +1,674 @@ +