fix(neode-ui): companion app opens every app in the native WebView, never an iframe
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m42s

Regression: only X-Frame-Options apps were routed to the companion's
in-app WebView; iframeable apps fell through to the iframe session, which
is slower on the phone and loses the native back/forward/reload controls.
Now any launch inside the companion (ArchipelagoNative.openInApp bridge
present) goes to the WebView — both openSession and the legacy open()
overlay fallback. Mobile web/PWA keeps the iframe session. With regression
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 04:54:20 -04:00
parent 97fbaf8818
commit cc2c06c6dc
3 changed files with 68 additions and 2 deletions

View File

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// vi.hoisted runs before vi.mock hoisting
@ -43,6 +43,40 @@ describe('useAppLauncherStore', () => {
expect(store.title).toBe('')
})
describe('companion app (native bridge)', () => {
const openInApp = vi.fn()
beforeEach(() => {
;(window as any).ArchipelagoNative = { openInApp, openExternal: vi.fn() }
openInApp.mockClear()
})
afterEach(() => {
delete (window as any).ArchipelagoNative
})
it('openSession hands iframeable apps to the native WebView, never the iframe session', () => {
const store = useAppLauncherStore()
store.openSession('filebrowser')
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining(':8083'))
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('openSession carries deep-link paths into the WebView URL', () => {
const store = useAppLauncherStore()
store.openSession('mempool', { path: '/tx/abc123' })
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining('/tx/abc123'))
expect(store.panelAppId).toBeNull()
})
it('open() never falls through to the iframe overlay', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown app' })
expect(openInApp).toHaveBeenCalledWith('http://192.168.1.228:9999')
expect(store.isOpen).toBe(false)
})
})
it('routes known port apps to full-page session', () => {
const store = useAppLauncherStore()

View File

@ -3,7 +3,9 @@ import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab } from '@/utils/openExternal'
import { openInAppOrNewTab, isCompanionApp } from '@/utils/openExternal'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
/**
@ -215,6 +217,19 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
recordAppLaunch(appId)
const mobile = isMobileViewport()
// Companion app: EVERY app opens in the native in-app WebView — never an
// iframe. The WebView is more performant on the phone and carries the
// native back/forward/reload/close controls. Plain mobile browsers (PWA)
// keep the iframe session below.
if (!IS_DEMO && isCompanionApp()) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) {
openInAppOrNewTab(launchUrl)
return
}
}
// Demo: apps backed by a real external site that blocks iframing open
// externally; everything else demoable renders in the in-app session.
if (IS_DEMO && isDemoExternal(appId)) {
@ -309,6 +324,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
return
}
// Companion app: never fall through to the iframe overlay — hand the URL
// to the native in-app WebView instead (see openSession).
if (!IS_DEMO && isCompanionApp()) {
openInAppOrNewTab(launchUrl)
return
}
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = launchUrl
title.value = payload.title

View File

@ -18,6 +18,16 @@ function nativeBridge(): ArchipelagoNativeBridge | undefined {
return (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge }).ArchipelagoNative
}
/**
* True when running inside the Android companion app (native WebView shell).
* The shell injects `window.ArchipelagoNative`; a plain mobile browser / PWA
* never has it.
*/
export function isCompanionApp(): boolean {
const native = nativeBridge()
return !!native && typeof native.openInApp === 'function'
}
export function openExternalUrl(url: string): void {
if (!url) return
const native = nativeBridge()