fix: disable HTTP keep-alive and update nginx proxy config
- Set http1_keep_alive(false) on hyper server to prevent connection reuse issues with nginx reverse proxy - Clean up nginx proxy config: remove upstream block, use direct proxy_pass to 127.0.0.1:5678 - Update AppLauncherOverlay and appLauncher store with UI fixes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0cf71c4115
commit
0fb373273a
@ -163,6 +163,7 @@ impl Server {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Err(e) = Http::new()
|
if let Err(e) = Http::new()
|
||||||
|
.http1_keep_alive(false)
|
||||||
.serve_connection(stream, service)
|
.serve_connection(stream, service)
|
||||||
.with_upgrades()
|
.with_upgrades()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -11,7 +11,7 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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)
|
# AIUI SPA (Chat mode iframe)
|
||||||
location /aiui/ {
|
location /aiui/ {
|
||||||
@ -28,7 +28,7 @@ server {
|
|||||||
proxy_pass http://127.0.0.1:3141/;
|
proxy_pass http://127.0.0.1:3141/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Connection "";
|
# Connection header managed by nginx default
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
@ -45,7 +45,7 @@ server {
|
|||||||
proxy_pass https://openrouter.ai/api/;
|
proxy_pass https://openrouter.ai/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host openrouter.ai;
|
proxy_set_header Host openrouter.ai;
|
||||||
proxy_set_header Connection "";
|
# Connection header managed by nginx default
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
proxy_connect_timeout 120s;
|
proxy_connect_timeout 120s;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
@ -65,7 +65,7 @@ server {
|
|||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_connect_timeout 120s;
|
proxy_connect_timeout 120s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
proxy_set_header Connection "";
|
# Connection header managed by nginx default
|
||||||
}
|
}
|
||||||
|
|
||||||
# AIUI web search proxy — SearXNG on port 8888
|
# AIUI web search proxy — SearXNG on port 8888
|
||||||
@ -108,6 +108,7 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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)
|
# Increase timeout for long-running operations (e.g., Docker image pulls)
|
||||||
proxy_connect_timeout 600s;
|
proxy_connect_timeout 600s;
|
||||||
@ -421,7 +422,7 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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)
|
# AIUI SPA (Chat mode iframe)
|
||||||
location /aiui/ {
|
location /aiui/ {
|
||||||
@ -436,7 +437,7 @@ server {
|
|||||||
proxy_pass http://127.0.0.1:3141/;
|
proxy_pass http://127.0.0.1:3141/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Connection "";
|
# Connection header managed by nginx default
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
@ -456,7 +457,7 @@ server {
|
|||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_connect_timeout 120s;
|
proxy_connect_timeout 120s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
proxy_set_header Connection "";
|
# Connection header managed by nginx default
|
||||||
}
|
}
|
||||||
location /aiui/api/openrouter/ {
|
location /aiui/api/openrouter/ {
|
||||||
if ($cookie_session = "") {
|
if ($cookie_session = "") {
|
||||||
@ -465,7 +466,7 @@ server {
|
|||||||
proxy_pass https://openrouter.ai/api/;
|
proxy_pass https://openrouter.ai/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host openrouter.ai;
|
proxy_set_header Host openrouter.ai;
|
||||||
proxy_set_header Connection "";
|
# Connection header managed by nginx default
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
proxy_connect_timeout 120s;
|
proxy_connect_timeout 120s;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
@ -506,6 +507,7 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# Connection header managed by nginx default
|
||||||
proxy_connect_timeout 600s;
|
proxy_connect_timeout 600s;
|
||||||
proxy_send_timeout 600s;
|
proxy_send_timeout 600s;
|
||||||
proxy_read_timeout 600s;
|
proxy_read_timeout 600s;
|
||||||
|
|||||||
@ -77,14 +77,39 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
<iframe
|
<iframe
|
||||||
ref="iframeRef"
|
ref="iframeRef"
|
||||||
v-if="store.url"
|
v-if="store.url && !iframeBlocked"
|
||||||
:key="iframeRefreshKey"
|
:key="iframeRefreshKey"
|
||||||
:src="store.url"
|
:src="store.url"
|
||||||
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
|
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
|
||||||
title="App content"
|
title="App content"
|
||||||
@load="onIframeLoad"
|
@load="onIframeLoad"
|
||||||
|
@error="onIframeError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Iframe blocked fallback -->
|
||||||
|
<Transition name="content-fade">
|
||||||
|
<div v-if="iframeBlocked && !iframeLoading" 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="M3 8V6a2 2 0 012-2h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2v-2m0-8h18M3 8v8m18-8v8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-2">Can't display in frame</h3>
|
||||||
|
<p class="text-white/50 text-sm mb-6">This app doesn't support embedded viewing.<br>Please open it in a new tab instead.</p>
|
||||||
|
<button
|
||||||
|
@click="openInNewTabAndClose"
|
||||||
|
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>
|
||||||
|
|
||||||
<!-- Payment Confirmation Dialog -->
|
<!-- Payment Confirmation Dialog -->
|
||||||
<Transition name="content-fade">
|
<Transition name="content-fade">
|
||||||
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
@ -158,6 +183,16 @@ const iframeRef = ref<HTMLIFrameElement | null>(null)
|
|||||||
const iframeRefreshKey = ref(0)
|
const iframeRefreshKey = ref(0)
|
||||||
const isRefreshing = ref(false)
|
const isRefreshing = ref(false)
|
||||||
const iframeLoading = ref(true)
|
const iframeLoading = ref(true)
|
||||||
|
const iframeBlocked = ref(false)
|
||||||
|
|
||||||
|
// Timers for iframe load detection
|
||||||
|
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let contentCheckId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||||
|
if (contentCheckId) { clearTimeout(contentCheckId); contentCheckId = null }
|
||||||
|
}
|
||||||
|
|
||||||
// Wallet connect — payment request state
|
// Wallet connect — payment request state
|
||||||
const pendingPayment = ref<PaymentRequest | null>(null)
|
const pendingPayment = ref<PaymentRequest | null>(null)
|
||||||
@ -168,7 +203,15 @@ const paymentOrigin = ref('')
|
|||||||
function refreshIframe() {
|
function refreshIframe() {
|
||||||
isRefreshing.value = true
|
isRefreshing.value = true
|
||||||
iframeLoading.value = true
|
iframeLoading.value = true
|
||||||
|
iframeBlocked.value = false
|
||||||
|
clearTimers()
|
||||||
iframeRefreshKey.value++
|
iframeRefreshKey.value++
|
||||||
|
loadTimeoutId = setTimeout(() => {
|
||||||
|
if (iframeLoading.value) {
|
||||||
|
iframeLoading.value = false
|
||||||
|
iframeBlocked.value = true
|
||||||
|
}
|
||||||
|
}, 15000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInNewTab() {
|
function openInNewTab() {
|
||||||
@ -177,11 +220,44 @@ function openInNewTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openInNewTabAndClose() {
|
||||||
|
openInNewTab()
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
function onIframeLoad() {
|
function onIframeLoad() {
|
||||||
injectScrollbarHideIfSameOrigin()
|
injectScrollbarHideIfSameOrigin()
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
iframeLoading.value = false
|
iframeLoading.value = false
|
||||||
sendIdentityIfSupported()
|
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) */
|
/** Apps that support the Archipelago identity protocol (postMessage) */
|
||||||
@ -379,10 +455,21 @@ watch(
|
|||||||
(open) => {
|
(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
iframeLoading.value = true
|
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()
|
closeBtnRef.value?.focus()
|
||||||
} else {
|
} else {
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
iframeLoading.value = true
|
iframeLoading.value = true
|
||||||
|
iframeBlocked.value = false
|
||||||
|
clearTimers()
|
||||||
// Clear any pending payment when closing
|
// Clear any pending payment when closing
|
||||||
if (pendingPayment.value) {
|
if (pendingPayment.value) {
|
||||||
rejectPayment()
|
rejectPayment()
|
||||||
@ -397,6 +484,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
clearTimers()
|
||||||
window.removeEventListener('keydown', onKeyDown, true)
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
window.removeEventListener('message', onMessage)
|
window.removeEventListener('message', onMessage)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,41 +1,34 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
/** Apps that must open in new tab instead of iframe.
|
/** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding.
|
||||||
* - DENY apps: always new tab (X-Frame-Options: DENY)
|
* Verified by checking response headers from each app container.
|
||||||
* - Redirect apps: new tab on HTTPS (absolute redirects break subpath proxy in iframe)
|
* These always open in a new tab. Other apps load in the iframe overlay.
|
||||||
* On HTTP, these load via direct port URL so iframe works fine.
|
|
||||||
*/
|
*/
|
||||||
function mustOpenInNewTab(url: string): boolean {
|
function mustOpenInNewTab(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url)
|
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 (
|
if (
|
||||||
u.port === '23000' || // BTCPay (X-Frame-Options: DENY)
|
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
|
||||||
u.port === '8123' || // Home Assistant (subpath breaks routing)
|
u.port === '3000' || // Grafana — X-Frame-Options: deny
|
||||||
u.port === '8085' || // Nextcloud (subpath breaks CSS/assets)
|
u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors
|
||||||
u.port === '2283' // Immich (subpath breaks SPA)
|
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
|
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
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
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<string, string> = {
|
const PORT_TO_PROXY: Record<string, string> = {
|
||||||
'81': '/app/nginx-proxy-manager/',
|
'81': '/app/nginx-proxy-manager/',
|
||||||
'3000': '/app/grafana/',
|
'3000': '/app/grafana/',
|
||||||
@ -65,7 +58,11 @@ const PORT_TO_PROXY: Record<string, string> = {
|
|||||||
'18081': '/app/nostr-rs-relay/',
|
'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 {
|
function toEmbeddableUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
@ -73,8 +70,7 @@ function toEmbeddableUrl(url: string): string {
|
|||||||
const proxyPath = PORT_TO_PROXY[u.port]
|
const proxyPath = PORT_TO_PROXY[u.port]
|
||||||
const sameHost = u.hostname === window.location.hostname
|
const sameHost = u.hostname === window.location.hostname
|
||||||
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
|
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) {
|
||||||
if (proxyPath && sameHost && (needsProxy || u.port === '8082' || u.port === '9001')) {
|
|
||||||
return `${origin}${proxyPath}`
|
return `${origin}${proxyPath}`
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -90,11 +86,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
let previousActiveElement: HTMLElement | null = null
|
let previousActiveElement: HTMLElement | null = null
|
||||||
|
|
||||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||||
const embeddableUrl = toEmbeddableUrl(payload.url)
|
|
||||||
if (payload.openInNewTab || mustOpenInNewTab(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
|
return
|
||||||
}
|
}
|
||||||
|
const embeddableUrl = toEmbeddableUrl(payload.url)
|
||||||
previousActiveElement = (document.activeElement as HTMLElement) || null
|
previousActiveElement = (document.activeElement as HTMLElement) || null
|
||||||
url.value = embeddableUrl
|
url.value = embeddableUrl
|
||||||
title.value = payload.title
|
title.value = payload.title
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user