This commit is contained in:
Dorian 2026-03-15 00:40:55 +00:00
parent bf34060f9d
commit bd40fac0e6
16 changed files with 1886 additions and 398 deletions

View File

@ -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 `<meta>` 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` | `<BaseUrl>/app/jellyfin</BaseUrl>` |
| 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: `<iframe src="http://node-ip:8901/">`. This avoids all sub-path issues. The Archipelago project uses this pattern for external sites (BotFights on 8901, 484 Kitchen on 8902, etc.).
---
## 5. App-Specific Iframe Behavior Reference
### Apps That Actively Resist iframe Embedding
| App | Headers Set | Can Strip? | JavaScript Frame-Busting? | Recommendation |
|---|---|---|---|---|
| **BTCPay Server** | XFO: DENY + extensive CSP | Yes, at proxy | Possible — test thoroughly | **New tab** — too many layers of anti-framing |
| **Home Assistant** | XFO: SAMEORIGIN | Yes, at proxy | Yes — detects iframe, shows warnings | **New tab** — actively fights embedding |
| **Grafana** | XFO: deny | Built-in `allow_embedding = true` | No | **iframe** — gold standard, use built-in config |
| **Portainer** | XFO: DENY | Yes, at proxy | No | **iframe via proxy** — works well once headers stripped |
| **Vaultwarden** | XFO: SAMEORIGIN + CSP frame-ancestors | Yes, at proxy | No | **iframe via proxy** — works with both headers stripped |
| **PhotoPrism** | XFO: DENY + CSP frame-ancestors: 'none' | Yes, at proxy | Minimal | **iframe via proxy** — strip both headers |
| **Nextcloud** | XFO: SAMEORIGIN (re-injected by PHP) | Yes, at proxy level | Possible in newer versions | **iframe via proxy** — configure trusted_domains |
| **Uptime Kuma** | XFO: SAMEORIGIN | Yes, at proxy | No | **iframe via proxy** — designed for embedding (status pages) |
### Apps That Work Fine in iframes
No XFO headers or easily proxied under same origin:
- Transmission Web UI, Pi-hole Admin, qBittorrent, Calibre-Web, Mempool.space, LNbits, Ride The Lightning (RTL), Syncthing (via same-origin proxy), FileBrowser
---
## 6. Cross-Origin Communication (postMessage)
### Parent to iframe
```javascript
const iframe = document.getElementById('app-iframe')
iframe.contentWindow.postMessage(
{ type: 'SET_THEME', payload: { theme: 'dark' } },
'https://app.example.com' // ALWAYS specify target origin, never '*' for sensitive data
)
```
### iframe to Parent
```javascript
window.parent.postMessage(
{ type: 'RESIZE', height: document.documentElement.scrollHeight },
'https://portal.example.com' // parent's origin
)
```
### Receiving Messages (both sides)
```javascript
window.addEventListener('message', (event) => {
// ALWAYS validate origin — this is a security boundary
if (event.origin !== 'https://trusted.example.com') return
// ALWAYS validate message structure
if (typeof event.data !== 'object' || !event.data.type) return
switch (event.data.type) {
case 'RESIZE':
iframe.style.height = event.data.height + 'px'
break
}
})
```
### Origin Validation Rules
- **Never use `*` as target origin** when sending sensitive data (tokens, keys, user info).
- **Always check `event.origin`** against an allowlist — do not use substring matching (e.g., `evil-example.com` would match a naive check for `example.com`).
- **Use `event.source`** to reply to the correct sender: `event.source.postMessage(reply, event.origin)`.
- **Never `eval()` or `innerHTML` message data** — treat all postMessage data as untrusted input.
- **Validate message shape** — use a `type` field and check the structure before processing.
### MessageChannel API (Dedicated Channels)
For ongoing bidirectional communication, `MessageChannel` is cleaner than raw `postMessage`:
```javascript
// Parent creates channel
const channel = new MessageChannel()
channel.port1.onmessage = (e) => console.log('From iframe:', e.data)
iframe.contentWindow.postMessage(
{ type: 'INIT_CHANNEL' },
targetOrigin,
[channel.port2] // Transfer port2 to iframe
)
// iframe receives and uses port
window.addEventListener('message', (event) => {
if (event.data.type === 'INIT_CHANNEL') {
const port = event.ports[0]
port.onmessage = (e) => console.log('From parent:', e.data)
port.postMessage({ type: 'READY' })
}
})
```
Advantage: no need to check origin on every message after the initial handshake.
---
## 7. Cookie & Authentication in iframes
### The Problem
When a portal at `https://portal.local` embeds an app at `https://app.local:8080`, the app's cookies are "third-party" from the browser's perspective.
| Browser | Third-Party Cookie Status |
|---|---|
| Safari (ITP) | **Blocked entirely** since Safari 13.1. Even `SameSite=None` blocked. |
| Firefox (ETP strict) | **Blocked** in strict mode. Standard mode allows non-tracking `SameSite=None`. |
| Chrome | Still allows by default but moving toward blocking. Supports CHIPS. |
### SameSite Cookie Values
| Value | Sent in iframe? | Notes |
|---|---|---|
| `Strict` | **Never** | Only sent on direct navigation |
| `Lax` | **No** on initial load | Sent on user-initiated top-level navigation |
| `None` | **Yes** (Chrome/Firefox) / **No** (Safari) | Requires `Secure` flag (HTTPS only) |
### Solutions (Ranked by Reliability)
**1. Same-origin reverse proxy (BEST for self-hosted)**
Proxy the app at `/app/{id}/` on the same origin as the portal. No cross-origin issues at all. This is what Archipelago uses.
**2. Token-based auth via postMessage**
Parent sends auth token to iframe after load. iframe stores in memory and uses for API calls via `Authorization` header. No cookies needed.
**3. Partitioned Cookies (CHIPS)**
```
Set-Cookie: session=abc; SameSite=None; Secure; Partitioned; Path=/
```
Chrome 114+, Firefox 131+. Safari does not support. Cookies are partitioned per top-level site.
**4. Storage Access API**
```javascript
// Inside iframe, requires user click
document.requestStorageAccess().then(() => { /* access granted */ })
```
Safari 16.3+, Firefox 65+, Chrome 119+. Requires user interaction.
### Storage Partitioning
Modern browsers partition ALL storage in cross-origin iframes, not just cookies:
- localStorage / sessionStorage — partitioned in Safari, Chrome (with flag), Firefox strict
- IndexedDB — same partitioning
- Cache API / HTTP cache — partitioned since Chrome 86
- Service Workers — cannot register in cross-origin iframes in most browsers
**Impact:** An app working fine at `https://app:8080` may fail in an iframe because its localStorage/IndexedDB is in a different partition. Same-origin proxying eliminates this entirely.
---
## 8. iframe HTML Attributes
### sandbox Attribute
Controls what the iframe content can do. When present with no value, maximum restrictions apply.
```html
<!-- Full-featured app embedding (most common for Archipelago) -->
<iframe
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
src="/app/myapp/"
></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
<iframe
src="/app/myapp/"
allow="fullscreen; clipboard-write; clipboard-read; camera; microphone; autoplay"
></iframe>
```
| 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
<iframe src="..." loading="lazy"></iframe> <!-- Defer until near viewport -->
<iframe src="..." loading="eager"></iframe> <!-- Load immediately (default) -->
```
Supported: Chrome 77+, Firefox 75+, Safari 16.4+. Good for below-the-fold iframes.
### credentialless Attribute
```html
<iframe src="..." credentialless></iframe>
```
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
<iframe allow="clipboard-read; clipboard-write" src="..."></iframe>
```
Also requires `sandbox="allow-same-origin"` if sandboxed. Modern browsers (Chrome 126+) require user gesture.
### Fullscreen
```html
<iframe allow="fullscreen" allowfullscreen src="..."></iframe>
```
Both attributes for maximum compatibility.
### Camera / Microphone
```html
<iframe allow="camera; microphone" src="..."></iframe>
```
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
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 `</head>` or after `<body>` — 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
```

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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 <HomeClient html={html} />;" \
" } catch {" \
" return <HomeClient html=\"<html><head><title>IndeeHub</title></head><body style='background:#000;color:#fff;font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0'><div style='text-align:center'><h1>IndeeHub</h1><p>Loading content...</p><script>setTimeout(function(){location.reload()},5000)</script></div></body></html>\" />;" \
" }" \
"}" > src/app/page.tsx
RUN npm run build

View File

@ -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");
});
}

264
docs/refactoring-plan.md Normal file
View File

@ -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 `<RouterView v-slot>` with `<Transition>` 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.

View File

@ -332,14 +332,24 @@ server {
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/lnd/ {

View File

@ -232,14 +232,23 @@ location /app/electrs/ {
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/nginx-proxy-manager/ {

View File

@ -6,8 +6,8 @@
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<!-- Backdrop near-black -->
<div class="absolute inset-0 bg-black/90 backdrop-blur-xl"></div>
<!-- Main panel -->
<div
@ -15,97 +15,80 @@
@click.stop
class="relative z-10 w-full max-w-lg"
>
<!-- Header with animated key icon -->
<!-- Header: screensaver-style glass disc + radial viz ring -->
<div class="relative mb-6 flex flex-col items-center">
<div class="key-glow-ring">
<svg viewBox="0 0 120 120" class="w-20 h-20" xmlns="http://www.w3.org/2000/svg">
<!-- Outer ring -->
<circle cx="60" cy="60" r="54" fill="none" stroke="rgba(251,146,60,0.2)" stroke-width="1" />
<circle cx="60" cy="60" r="54" fill="none" stroke="#fb923c" stroke-width="1.5"
stroke-dasharray="8 6" class="ring-spin" />
<!-- Inner ring -->
<circle cx="60" cy="60" r="38" fill="none" stroke="rgba(251,146,60,0.1)" stroke-width="1" />
<circle cx="60" cy="60" r="38" fill="none" stroke="#fb923c" stroke-width="1"
stroke-dasharray="4 8" class="ring-spin-reverse" />
<!-- Key icon -->
<g transform="translate(60,60)" class="key-breathe">
<circle cx="0" cy="-8" r="10" fill="none" stroke="#fb923c" stroke-width="2" />
<circle cx="0" cy="-8" r="4" fill="#fb923c" opacity="0.4" />
<line x1="0" y1="2" x2="0" y2="22" stroke="#fb923c" stroke-width="2" />
<line x1="0" y1="14" x2="6" y2="14" stroke="#fb923c" stroke-width="2" />
<line x1="0" y1="19" x2="4" y2="19" stroke="#fb923c" stroke-width="2" />
</g>
<!-- Network dots -->
<circle cx="16" cy="28" r="2" fill="#fb923c" opacity="0.5" class="node-pulse" style="--pulse-delay: 0s" />
<circle cx="104" cy="32" r="2" fill="#fb923c" opacity="0.5" class="node-pulse" style="--pulse-delay: 0.5s" />
<circle cx="20" cy="92" r="2" fill="#fb923c" opacity="0.5" class="node-pulse" style="--pulse-delay: 1s" />
<circle cx="100" cy="88" r="2" fill="#fb923c" opacity="0.5" class="node-pulse" style="--pulse-delay: 1.5s" />
<!-- Connection lines -->
<line x1="16" y1="28" x2="40" y2="48" stroke="#fb923c" stroke-width="0.5" opacity="0.2" />
<line x1="104" y1="32" x2="80" y2="48" stroke="#fb923c" stroke-width="0.5" opacity="0.2" />
<line x1="20" y1="92" x2="40" y2="72" stroke="#fb923c" stroke-width="0.5" opacity="0.2" />
<line x1="100" y1="88" x2="80" y2="72" stroke="#fb923c" stroke-width="0.5" opacity="0.2" />
</svg>
<div class="nostr-hero">
<!-- Radial viz segments exact screensaver pattern, 48 bars, #FAFAFA -->
<div class="nostr-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="nostr-viz-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<!-- Glass disc exact logo-gradient-border from screensaver -->
<div class="nostr-glass-border">
<div class="nostr-glass-inner">
<svg viewBox="0 0 122.88 88.39" width="42" height="30" xmlns="http://www.w3.org/2000/svg" class="nostr-cinema-svg">
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
</svg>
</div>
</div>
</div>
<h2 class="mt-4 text-lg font-semibold text-white">Select Identity</h2>
<p class="mt-1 text-xs text-white/50">Nostr authentication protocol</p>
<h2 class="mt-5 text-lg font-semibold text-white">Select Identity</h2>
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr authentication protocol</p>
</div>
<!-- Identity list -->
<div class="glass-card p-4 space-y-3 max-h-[50vh] overflow-y-auto">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-orange-400" viewBox="0 0 24 24" fill="none">
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span class="ml-3 text-white/60 text-sm">Loading identities...</span>
</div>
<!-- No identities -->
<div v-else-if="identities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
</div>
<!-- Identity cards -->
<button
v-for="identity in identities"
:key="identity.id"
type="button"
class="w-full text-left p-3 rounded-lg border transition-all duration-200"
:class="selectedId === identity.id
? 'bg-orange-500/10 border-orange-500/30'
: 'bg-white/5 border-white/10 hover:bg-white/8 hover:border-white/15'"
? 'bg-white/8 border-white/25'
: 'bg-white/3 border-white/8 hover:bg-white/6 hover:border-white/15'"
@click="selectedId = identity.id"
>
<div class="flex items-center gap-3">
<!-- Avatar -->
<div
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 border"
:class="avatarClasses(identity.purpose)"
>
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ identity.name }}</span>
<span v-if="identity.is_default" class="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400">default</span>
<span v-if="identity.is_default" class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">default</span>
</div>
<div class="mt-0.5">
<span v-if="identity.nostr_npub" class="text-white/40 text-xs font-mono truncate">{{ truncateNpub(identity.nostr_npub) }}</span>
<span v-if="identity.nostr_npub" class="text-white/35 text-xs font-mono truncate">{{ truncateNpub(identity.nostr_npub) }}</span>
<span v-else class="text-red-400/60 text-xs">No Nostr key</span>
</div>
</div>
<!-- Radio indicator -->
<div class="shrink-0">
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-orange-500/30 border border-orange-400 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-orange-400"></div>
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/20 border border-white/50 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-white/80"></div>
</div>
<div v-else class="w-5 h-5 rounded-full border border-white/20"></div>
<div v-else class="w-5 h-5 rounded-full border border-white/15"></div>
</div>
</div>
</button>
@ -113,7 +96,7 @@
<!-- Actions -->
<div class="flex gap-3 mt-4">
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/80">
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/70">
Cancel
</button>
<button
@ -121,18 +104,15 @@
:disabled="!selectedId || !hasNostrKey"
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="selectedId && hasNostrKey
? 'bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30'
: 'bg-white/5 border border-white/10 text-white/40'"
? 'bg-white/10 border border-white/25 text-white hover:bg-white/15'
: 'bg-white/3 border border-white/8 text-white/40'"
>
<svg class="w-4 h-4 mr-1.5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Authenticate
</button>
</div>
<p class="mt-3 text-center text-[10px] text-white/25 tracking-wider">
NIP-07 · SECP256K1 · Signed locally
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest">
NIP-07 &middot; SECP256K1 &middot; Signed locally
</p>
</div>
</div>
@ -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'
}
}
</script>
<style scoped>
/* Glow ring around key icon */
.key-glow-ring {
/* ── Hero container ── */
.nostr-hero {
position: relative;
padding: 8px;
width: 148px;
height: 148px;
}
.key-glow-ring::before {
content: '';
/* ── Radial viz ring — exact screensaver pattern, #FAFAFA ── */
.nostr-viz-ring {
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(251, 146, 60, 0.12) 0%, transparent 70%);
animation: glow-pulse 3s ease-in-out infinite;
width: 100%;
height: 100%;
pointer-events: none;
}
@keyframes glow-pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.15); }
.nostr-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 2.5px;
height: 14px;
margin-left: -1.25px;
margin-top: -7px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
border-radius: 1.5px;
transform-origin: center center;
transform: rotate(var(--seg-deg)) translateY(-60px);
animation: seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 0.02s);
}
/* Rotating rings */
.ring-spin {
animation: ring-rotate 20s linear infinite;
transform-origin: 60px 60px;
}
.ring-spin-reverse {
animation: ring-rotate 15s linear infinite reverse;
transform-origin: 60px 60px;
/* Exact screensaver keyframes — 5 normal pulses then 1 strong expression, 14s total */
@keyframes seg-pulse {
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
}
@keyframes ring-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
/* ── Glass disc — exact screensaver logo-gradient-border ── */
.nostr-glass-border {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 104px;
height: 104px;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
}
/* Key breathing animation */
.key-breathe {
animation: breathe 4s ease-in-out infinite;
transform-origin: 0 6px;
.nostr-glass-inner {
width: 100%;
height: 100%;
border-radius: 9999px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes breathe {
0%, 100% { opacity: 0.8; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
/* ── Cinema icon — breathing glow ── */
.nostr-cinema-svg {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px rgba(250, 250, 250, 0.12));
animation: cinema-breathe 4s ease-in-out infinite;
}
/* Network node pulse */
.node-pulse {
animation: node-blink 3s ease-in-out infinite;
animation-delay: var(--pulse-delay, 0s);
@keyframes cinema-breathe {
0%, 100% {
opacity: 0.7;
transform: scale(1);
filter: drop-shadow(0 0 8px rgba(250, 250, 250, 0.08));
}
50% {
opacity: 1;
transform: scale(1.08);
filter: drop-shadow(0 0 20px rgba(250, 250, 250, 0.22));
}
}
@keyframes node-blink {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; r: 3; }
}
/* Transition */
/* ── Modal transitions ── */
.identity-picker-enter-active,
.identity-picker-leave-active {
transition: opacity 0.3s ease;
transition: opacity 0.4s ease;
}
.identity-picker-enter-active > .relative {
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
}
.identity-picker-leave-active > .relative {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.identity-picker-enter-from {
opacity: 0;
}
.identity-picker-enter-from > .relative {
transform: translateY(20px) scale(0.96);
opacity: 0;
}
.identity-picker-leave-to {
opacity: 0;
}
.identity-picker-leave-to > .relative {
transform: translateY(10px) scale(0.98);
opacity: 0;
}
.identity-picker-enter-from { opacity: 0; }
.identity-picker-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
.identity-picker-leave-to { opacity: 0; }
.identity-picker-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
</style>

View File

@ -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',

View File

@ -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<string, string> = {
'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<string, string> = {
'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,

View File

@ -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<string, string> = {
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<string, { dev: string, prod: string }> = {
'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() {

View File

@ -0,0 +1,674 @@
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="displayMode === 'panel'">
<div
:class="backdropClasses"
@click.self="goBack"
>
<div
ref="sessionRef"
:class="panelClasses"
@click.stop
>
<!-- Header bar -->
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
<!-- Back / Forward navigation -->
<div class="flex items-center gap-0.5">
<button class="app-session-btn" aria-label="Back" title="Go back" @click="iframeGoBack">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button class="app-session-btn" aria-label="Forward" title="Go forward" @click="iframeGoForward">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ appTitle }}</span>
<button class="app-session-btn" aria-label="Refresh" :disabled="isRefreshing" @click="refresh">
<svg class="w-5 h-5 transition-transform duration-300" :class="{ 'animate-spin': isRefreshing }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- Display mode selector -->
<div class="relative" ref="modeMenuRef">
<button
class="app-session-btn"
aria-label="Display mode"
title="Display mode"
@click="showModeMenu = !showModeMenu"
>
<!-- Panel icon -->
<svg v-if="displayMode === 'panel'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
<!-- Overlay icon -->
<svg v-else-if="displayMode === 'overlay'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
<!-- Fullscreen icon -->
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
</button>
<!-- Dropdown -->
<Transition name="menu-fade">
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 w-48 bg-black/90 border border-white/10 rounded-lg backdrop-blur-xl shadow-2xl overflow-hidden z-50">
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'panel' }"
@click="setMode('panel')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
<span>Right panel</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'overlay' }"
@click="setMode('overlay')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
<span>Over whole app</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'fullscreen' }"
@click="setMode('fullscreen')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
<span>Open fullscreen</span>
</button>
</div>
</Transition>
</div>
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="openNewTab">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button class="app-session-btn" aria-label="Close" @click="closeSession">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<!-- App frame -->
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<Transition name="content-fade">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<svg class="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
</Transition>
<iframe
v-if="appUrl && !iframeBlocked"
ref="iframeRef"
:key="refreshKey"
:src="appUrl"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="onLoad"
@error="onError"
/>
<!-- Iframe blocked fallback -->
<Transition name="content-fade">
<div v-if="iframeBlocked" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">This site blocks embedded viewing</h3>
<p class="text-white/50 text-sm mb-6">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</p>
<button
@click="openNewTabAndBack"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in new tab
</button>
</div>
</div>
</Transition>
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
<div class="text-center px-8">
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
</div>
</div>
</div>
</div>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appTitle"
@select="onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
const route = useRoute()
const router = useRouter()
const sessionRef = ref<HTMLElement | null>(null)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const modeMenuRef = ref<HTMLElement | null>(null)
const loading = ref(true)
const isRefreshing = ref(false)
const iframeBlocked = ref(false)
const refreshKey = ref(0)
const showIdentityPicker = ref(false)
const showModeMenu = ref(false)
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
/** Sites known to block iframes — skip the timeout and go straight to fallback */
const IFRAME_BLOCKED_APPS = new Set<string>([])
// Display mode persisted in localStorage
const displayMode = ref<DisplayMode>(
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
)
function setMode(mode: DisplayMode) {
// Exit fullscreen first if switching away
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
document.exitFullscreen().catch(() => {})
}
displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode)
showModeMenu.value = false
// Enter fullscreen if selected
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
}
// Reactive classes based on display mode
const backdropClasses = computed(() => {
if (displayMode.value === 'overlay' || displayMode.value === 'fullscreen') {
return 'app-session-backdrop-overlay'
}
return 'app-session-backdrop-panel'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (displayMode.value === 'overlay') return `${base} app-session-overlay`
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
return `${base} app-session-inpanel`
})
const appId = computed(() => route.params.appId as string)
const APP_URLS: Record<string, string> = {
// Container apps use nginx proxy paths (strips X-Frame-Options)
'bitcoin-knots': '/app/bitcoin-ui/',
'electrs': '/app/electrs/',
'btcpay-server': '/app/btcpay/',
'lnd': '/app/lnd/',
'mempool': '/app/mempool/',
'homeassistant': '/app/homeassistant/',
'grafana': '/app/grafana/',
'searxng': '/app/searxng/',
'ollama': '/app/ollama/',
'onlyoffice': '/app/onlyoffice/',
'penpot': '/app/penpot/',
'nextcloud': '/app/nextcloud/',
'vaultwarden': '/app/vaultwarden/',
'jellyfin': '/app/jellyfin/',
'photoprism': '/app/photoprism/',
'immich': '/app/immich/',
'filebrowser': '/app/filebrowser/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'indeedhub': '/app/indeedhub/',
'dwn': '/app/dwn/',
'endurain': '/app/endurain/',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
// 'arch-presentation': hidden until X-Frame-Options fixed on present.l484.com
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',
}
const APP_TITLES: Record<string, string> = {
'bitcoin-knots': 'Bitcoin', 'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'botfights': 'BotFights', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
'nginx-proxy-manager': 'Nginx Proxy Manager', 'nostr-rs-relay': 'Nostr Relay',
'call-the-operator': 'Call The Operator', 'syntropy-institute': 'Syntropy Institute',
't-zero': 'T-Zero', 'nostrudel': 'noStrudel',
}
const appTitle = computed(() => APP_TITLES[appId.value] || appId.value.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
const appUrl = computed(() => {
const url = APP_URLS[appId.value]
if (!url) return ''
// Proxy paths same origin
if (url.startsWith('/')) return `${window.location.origin}${url}`
// External HTTPS sites direct
if (url.startsWith('https://')) return url
// Fallback: localhost port URLs (shouldn't reach here normally)
return url.replace('localhost', window.location.hostname)
})
// --- Identity ---
function isIdentityAwareApp(id: string): boolean {
return id === 'indeedhub' || id === 'nostrudel'
}
const IDENTITY_KEY = 'archipelago_app_identity_'
interface SelectedIdentity {
id: string; name: string; did: string; pubkey: string
nostr_pubkey?: string; nostr_npub?: string
}
function getStoredIdentity(): SelectedIdentity | null {
try {
const stored = localStorage.getItem(IDENTITY_KEY + appId.value)
return stored ? JSON.parse(stored) as SelectedIdentity : null
} catch { return null }
}
function storeIdentity(identity: SelectedIdentity) {
try { localStorage.setItem(IDENTITY_KEY + appId.value, JSON.stringify(identity)) } catch {}
}
function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
storeIdentity(identity)
sendIdentity(identity)
}
async function sendIdentity(identity: SelectedIdentity) {
try {
const challenge = `archipelago-identity:${Date.now()}`
const sigRes = await rpcClient.call<{ signature: string }>({ method: 'identity.sign', params: { id: identity.id, message: challenge } })
iframeRef.value?.contentWindow?.postMessage({
type: 'archipelago:identity', did: identity.did, name: identity.name,
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey || null,
nostr_npub: identity.nostr_npub || null, challenge, signature: sigRes.signature
}, '*')
} catch {}
}
// --- Lifecycle ---
function onLoad() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
loading.value = false
isRefreshing.value = false
// Check if iframe actually loaded content (same-origin only)
setTimeout(() => {
try {
const doc = iframeRef.value?.contentDocument
if (doc) {
const body = doc.body
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
iframeBlocked.value = true
}
}
} catch {
// Cross-origin can't check, assume OK
}
}, 1000)
if (isIdentityAwareApp(appId.value)) {
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
else showIdentityPicker.value = true
}
}
function onError() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
loading.value = false
isRefreshing.value = false
iframeBlocked.value = true
}
function refresh() {
isRefreshing.value = true
loading.value = true
iframeBlocked.value = false
refreshKey.value++
startLoadTimeout()
}
function startLoadTimeout() {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
loadTimeoutId = setTimeout(() => {
if (loading.value) {
loading.value = false
iframeBlocked.value = true
}
}, 12000)
}
function openNewTabAndBack() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
goBack()
}
function openNewTab() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
}
function iframeGoBack() {
try { iframeRef.value?.contentWindow?.history.back() } catch {}
}
function iframeGoForward() {
try { iframeRef.value?.contentWindow?.history.forward() } catch {}
}
function goBack() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.back()
}
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.push({ name: 'apps' })
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
else closeSession()
e.preventDefault()
}
}
// Close dropdown on outside click
function onClickOutside(e: MouseEvent) {
if (showModeMenu.value && modeMenuRef.value && !modeMenuRef.value.contains(e.target as Node)) {
showModeMenu.value = false
}
}
function onFullscreenChange() {
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
// User exited fullscreen via browser UI switch to overlay
displayMode.value = 'overlay'
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
}
}
// Enter fullscreen on mount if mode is fullscreen
watch(displayMode, (mode) => {
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
})
// --- NIP-07 ---
function onMessage(e: MessageEvent) {
if (e.data?.type === 'nostr-request') handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') {
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
else showIdentityPicker.value = true
}
}
async function handleNostrRequest(event: MessageEvent) {
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
const identityId = getStoredIdentity()?.id || null
try {
let result: unknown
if (method === 'getPublicKey') {
if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
} else {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
} else {
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
}
} else if (method === 'getRelays') { result = {} }
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
source.postMessage({ type: 'nostr-response', id, result }, '*')
} catch (err) {
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
}
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
document.addEventListener('click', onClickOutside)
document.addEventListener('fullscreenchange', onFullscreenChange)
// Known blocked apps show fallback immediately
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
} else {
startLoadTimeout()
}
// Enter fullscreen if that's the stored mode
if (displayMode.value === 'fullscreen') {
requestAnimationFrame(() => {
sessionRef.value?.requestFullscreen().catch(() => {})
})
}
})
onBeforeUnmount(() => {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
document.removeEventListener('click', onClickOutside)
document.removeEventListener('fullscreenchange', onFullscreenChange)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
})
</script>
<style scoped>
.app-session-root {
width: 100%;
height: 100%;
}
/* Panel mode — edge-to-edge dark overlay with centered glass panel */
.app-session-backdrop-panel {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
padding: 0;
}
.app-session-inpanel {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0;
}
@media (min-width: 768px) {
.app-session-backdrop-panel {
padding: 1.5rem;
}
.app-session-inpanel {
border-radius: 1rem;
max-width: calc(100% - 1rem);
max-height: calc(100vh - 6rem);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
}
/* Overlay mode — covers entire viewport including sidebar */
.app-session-backdrop-overlay {
position: fixed;
inset: 0;
z-index: 2400;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
}
@media (min-width: 768px) {
.app-session-backdrop-overlay {
padding: 2.5rem;
}
}
.app-session-overlay {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
}
@media (min-width: 768px) {
.app-session-overlay {
max-width: calc(100vw - 5rem);
max-height: calc(100vh - 5rem);
border-radius: 1rem;
}
}
/* Fullscreen mode */
.app-session-fullscreen {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0 !important;
max-width: none !important;
max-height: none !important;
}
/* Shared */
.app-session-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.15s ease;
flex-shrink: 0;
}
.app-session-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.app-session-btn:disabled {
opacity: 0.5;
}
/* Mode dropdown */
.mode-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 14px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.15s ease;
text-align: left;
}
.mode-option:hover {
background: rgba(255, 255, 255, 0.08);
color: white;
}
.mode-option-active {
color: #fb923c;
background: rgba(251, 146, 60, 0.08);
}
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.content-fade-enter-active,
.content-fade-leave-active {
transition: opacity 0.2s ease;
}
.content-fade-enter-from,
.content-fade-leave-to {
opacity: 0;
}
</style>

View File

@ -279,7 +279,7 @@ const WEB_ONLY_APP_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
// 'arch-presentation': hidden until X-Frame-Options fixed
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
@ -310,11 +310,12 @@ const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
},
/* arch-presentation hidden until X-Frame-Options fixed
'arch-presentation': {
state: 'running' as PackageState,
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
},
}, */
'syntropy-institute': {
state: 'running' as PackageState,
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
@ -382,59 +383,7 @@ function canLaunch(pkg: PackageDataEntry): boolean {
}
function launchApp(id: string) {
const isDev = import.meta.env.DEV
const pkg = packages.value[id]
// Web-only apps use their external URL directly
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg?.manifest?.title || id })
return
}
// Explicit URLs for apps that need them (checked first to avoid package data issues)
const appUrls: Record<string, { dev: string, prod: string }> = {
'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
},
}
if (appUrls[id]) {
let url = isDev ? appUrls[id].dev : appUrls[id].prod
// Replace localhost with current hostname for remote access (not for external IPs like LoraBell)
if (url.includes('localhost')) {
const currentHost = window.location.hostname
url = url.replace('localhost', currentHost)
}
useAppLauncherStore().open({ url, title: pkg?.manifest?.title || id })
return
}
// Get the LAN address from the package
let lanAddress = pkg?.installed?.['interface-addresses']?.main?.['lan-address']
// Replace localhost with the current hostname (for remote access)
if (lanAddress && lanAddress.includes('localhost')) {
const currentHost = window.location.hostname
lanAddress = lanAddress.replace('localhost', currentHost)
}
if (lanAddress) {
useAppLauncherStore().open({ url: lanAddress, title: pkg?.manifest?.title || id })
return
}
// For other apps, navigate to app details which has launch functionality
router.push(`/dashboard/apps/${id}`).catch(() => {})
useAppLauncherStore().openSession(id)
}
function getStatusClass(state: PackageState): string {

View File

@ -639,75 +639,8 @@ function isInstalled(appId: string): boolean {
return aliases ? aliases.some((a) => a in installedPackages.value) : false
}
/** Web-only apps — external URLs with no container */
const WEB_ONLY_APP_URLS: Record<string, string> = {
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',
}
/** App ID to port-based URL for container apps */
const APP_LAUNCH_URLS: Record<string, string> = {
'bitcoin-knots': 'http://localhost:8334',
'btcpay-server': 'http://localhost:23000',
'lnd': 'http://localhost:8081',
'mempool': 'http://localhost:4080',
'homeassistant': 'http://localhost:8123',
'grafana': 'http://localhost:3000',
'searxng': 'http://localhost:8888',
'ollama': 'http://localhost:11434',
'onlyoffice': 'http://localhost:9980',
'penpot': 'http://localhost:9001',
'nextcloud': 'http://localhost:8085',
'vaultwarden': 'http://localhost:8082',
'jellyfin': 'http://localhost:8096',
'photoprism': 'http://localhost:2342',
'immich': 'http://localhost:2283',
'filebrowser': 'http://localhost:8083',
'nginx-proxy-manager': 'http://localhost:81',
'portainer': 'http://localhost:9000',
'uptime-kuma': 'http://localhost:3001',
'tailscale': 'http://localhost:8240',
'fedimint': 'http://localhost:8175',
'nostr-rs-relay': 'http://localhost:18081',
'dwn': 'http://localhost:3100',
'indeedhub': 'http://localhost:8190',
}
function launchInstalledApp(app: MarketplaceApp) {
const id = app.id
// Web-only apps
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
appLauncher.open({ url: webOnlyUrl, title: app.title || id })
return
}
// Web URL on the marketplace app object (e.g. Nostr-discovered apps)
if (app.webUrl) {
appLauncher.open({ url: app.webUrl, title: app.title || id })
return
}
// Container apps with known ports
const portUrl = APP_LAUNCH_URLS[id]
if (portUrl) {
let url = portUrl
if (url.includes('localhost')) {
url = url.replace('localhost', window.location.hostname)
}
appLauncher.open({ url, title: app.title || id })
return
}
// Fallback: navigate to the app detail page
router.push({ name: 'app-details', params: { id } })
appLauncher.openSession(app.id)
}
// Load community marketplace on mount