diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 16e6e5b5..ed9bc7d9 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -163,6 +163,7 @@ impl Server { }); if let Err(e) = Http::new() + .http1_keep_alive(false) .serve_connection(stream, service) .with_upgrades() .await diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 347d5eba..fc049190 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -11,7 +11,7 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' http://127.0.0.1:* http://localhost:*" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always; # AIUI SPA (Chat mode iframe) location /aiui/ { @@ -28,7 +28,7 @@ server { proxy_pass http://127.0.0.1:3141/; proxy_http_version 1.1; proxy_set_header Host $host; - proxy_set_header Connection ""; + # Connection header managed by nginx default proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; proxy_cache off; @@ -45,7 +45,7 @@ server { proxy_pass https://openrouter.ai/api/; proxy_http_version 1.1; proxy_set_header Host openrouter.ai; - proxy_set_header Connection ""; + # Connection header managed by nginx default proxy_ssl_server_name on; proxy_connect_timeout 120s; proxy_read_timeout 120s; @@ -65,7 +65,7 @@ server { proxy_cache off; proxy_connect_timeout 120s; proxy_read_timeout 300s; - proxy_set_header Connection ""; + # Connection header managed by nginx default } # AIUI web search proxy — SearXNG on port 8888 @@ -108,6 +108,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Connection header managed by nginx default # Increase timeout for long-running operations (e.g., Docker image pulls) proxy_connect_timeout 600s; @@ -421,7 +422,7 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' http://127.0.0.1:* http://localhost:*" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always; # AIUI SPA (Chat mode iframe) location /aiui/ { @@ -436,7 +437,7 @@ server { proxy_pass http://127.0.0.1:3141/; proxy_http_version 1.1; proxy_set_header Host $host; - proxy_set_header Connection ""; + # Connection header managed by nginx default proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; proxy_cache off; @@ -456,7 +457,7 @@ server { proxy_cache off; proxy_connect_timeout 120s; proxy_read_timeout 300s; - proxy_set_header Connection ""; + # Connection header managed by nginx default } location /aiui/api/openrouter/ { if ($cookie_session = "") { @@ -465,7 +466,7 @@ server { proxy_pass https://openrouter.ai/api/; proxy_http_version 1.1; proxy_set_header Host openrouter.ai; - proxy_set_header Connection ""; + # Connection header managed by nginx default proxy_ssl_server_name on; proxy_connect_timeout 120s; proxy_read_timeout 120s; @@ -506,6 +507,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Connection header managed by nginx default proxy_connect_timeout 600s; proxy_send_timeout 600s; proxy_read_timeout 600s; diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 38f6ef04..98e826dc 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -77,14 +77,39 @@ + + + + + + + + + + Can't display in frame + This app doesn't support embedded viewing.Please open it in a new tab instead. + + + + + Open in new tab + + + + + @@ -158,6 +183,16 @@ const iframeRef = ref(null) const iframeRefreshKey = ref(0) const isRefreshing = ref(false) const iframeLoading = ref(true) +const iframeBlocked = ref(false) + +// Timers for iframe load detection +let loadTimeoutId: ReturnType | null = null +let contentCheckId: ReturnType | null = null + +function clearTimers() { + if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null } + if (contentCheckId) { clearTimeout(contentCheckId); contentCheckId = null } +} // Wallet connect — payment request state const pendingPayment = ref(null) @@ -168,7 +203,15 @@ const paymentOrigin = ref('') function refreshIframe() { isRefreshing.value = true iframeLoading.value = true + iframeBlocked.value = false + clearTimers() iframeRefreshKey.value++ + loadTimeoutId = setTimeout(() => { + if (iframeLoading.value) { + iframeLoading.value = false + iframeBlocked.value = true + } + }, 15000) } function openInNewTab() { @@ -177,11 +220,44 @@ function openInNewTab() { } } +function openInNewTabAndClose() { + openInNewTab() + store.close() +} + function onIframeLoad() { injectScrollbarHideIfSameOrigin() isRefreshing.value = false iframeLoading.value = false sendIdentityIfSupported() + + // Clear the load timeout + if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null } + + // Check iframe content after a brief delay to let the app render + contentCheckId = setTimeout(checkIframeContent, 2000) +} + +function onIframeError() { + clearTimers() + iframeLoading.value = false + iframeBlocked.value = true +} + +/** Check if the iframe loaded meaningful content (same-origin only) */ +function checkIframeContent() { + try { + const iframe = iframeRef.value + if (!iframe) return + const doc = iframe.contentDocument + if (!doc) return // Cross-origin — can't check, assume OK + const body = doc.body + if (!body || (body.children.length === 0 && body.innerText.trim() === '')) { + iframeBlocked.value = true + } + } catch { + // Cross-origin: can't access, assume working + } } /** Apps that support the Archipelago identity protocol (postMessage) */ @@ -379,10 +455,21 @@ watch( (open) => { if (open) { iframeLoading.value = true + iframeBlocked.value = false + clearTimers() + // Set max load timeout — if iframe never fires load, show fallback + loadTimeoutId = setTimeout(() => { + if (iframeLoading.value) { + iframeLoading.value = false + iframeBlocked.value = true + } + }, 15000) closeBtnRef.value?.focus() } else { isRefreshing.value = false iframeLoading.value = true + iframeBlocked.value = false + clearTimers() // Clear any pending payment when closing if (pendingPayment.value) { rejectPayment() @@ -397,6 +484,7 @@ onMounted(() => { }) onBeforeUnmount(() => { + clearTimers() window.removeEventListener('keydown', onKeyDown, true) window.removeEventListener('message', onMessage) }) diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 65ba6d9c..0e001470 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -1,41 +1,34 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -/** Apps that must open in new tab instead of iframe. - * - DENY apps: always new tab (X-Frame-Options: DENY) - * - Redirect apps: new tab on HTTPS (absolute redirects break subpath proxy in iframe) - * On HTTP, these load via direct port URL so iframe works fine. +/** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding. + * Verified by checking response headers from each app container. + * These always open in a new tab. Other apps load in the iframe overlay. */ function mustOpenInNewTab(url: string): boolean { try { const u = new URL(url) - // Always new tab: X-Frame-Options DENY or subpath fundamentally breaks the app + // External sites — third-party cookie/iframe restrictions + if (u.hostname.includes('indeehub')) return true + // Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes if ( - u.port === '23000' || // BTCPay (X-Frame-Options: DENY) - u.port === '8123' || // Home Assistant (subpath breaks routing) - u.port === '8085' || // Nextcloud (subpath breaks CSS/assets) - u.port === '2283' // Immich (subpath breaks SPA) + u.port === '23000' || // BTCPay — X-Frame-Options: DENY + u.port === '3000' || // Grafana — X-Frame-Options: deny + u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors + u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none' + u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN + u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN + u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN ) { return true } - // On HTTPS, apps with absolute-path redirects break in iframe via proxy - if (window.location.protocol === 'https:') { - return ( - u.port === '8096' || // Jellyfin (redirects to /web/index.html) - u.port === '9000' || // Portainer (redirects to /timeout.html) - u.port === '2342' || // PhotoPrism (redirects to /library/login) - u.port === '9980' || // OnlyOffice (redirects to /welcome/) - u.port === '3001' || // Uptime Kuma (redirects to /dashboard) - u.port === '8175' // Fedimint (redirects to /login) - ) - } return false } catch { return false } } -/** Port → proxy path for apps (nginx strips X-Frame-Options) */ +/** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */ const PORT_TO_PROXY: Record = { '81': '/app/nginx-proxy-manager/', '3000': '/app/grafana/', @@ -65,7 +58,11 @@ const PORT_TO_PROXY: Record = { '18081': '/app/nostr-rs-relay/', } -/** Rewrite to same-origin proxy so iframe can embed (avoids mixed content on HTTPS) */ +/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content. + * On HTTP, direct port URLs are used — they avoid subpath routing issues + * (apps' root-relative asset paths like /static/main.js break under /app/xxx/). + * On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options. + */ function toEmbeddableUrl(url: string): string { try { const u = new URL(url) @@ -73,8 +70,7 @@ function toEmbeddableUrl(url: string): string { const proxyPath = PORT_TO_PROXY[u.port] const sameHost = u.hostname === window.location.hostname const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:' - // Use proxy when: (a) mixed content, or (b) vaultwarden/penpot always (subpath required) - if (proxyPath && sameHost && (needsProxy || u.port === '8082' || u.port === '9001')) { + if (proxyPath && sameHost && needsProxy) { return `${origin}${proxyPath}` } } catch { @@ -90,11 +86,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { let previousActiveElement: HTMLElement | null = null function open(payload: { url: string; title: string; openInNewTab?: boolean }) { - const embeddableUrl = toEmbeddableUrl(payload.url) if (payload.openInNewTab || mustOpenInNewTab(payload.url)) { - window.open(embeddableUrl, '_blank', 'noopener,noreferrer') + // New tab: always use direct port URL so app assets load correctly + window.open(payload.url, '_blank', 'noopener,noreferrer') return } + const embeddableUrl = toEmbeddableUrl(payload.url) previousActiveElement = (document.activeElement as HTMLElement) || null url.value = embeddableUrl title.value = payload.title
This app doesn't support embedded viewing.Please open it in a new tab instead.