From cc2c06c6dcc950755e9aadc8d76934c1ffadbece Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 04:54:20 -0400 Subject: [PATCH] fix(neode-ui): companion app opens every app in the native WebView, never an iframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/stores/__tests__/appLauncher.test.ts | 36 ++++++++++++++++++- neode-ui/src/stores/appLauncher.ts | 24 ++++++++++++- neode-ui/src/utils/openExternal.ts | 10 ++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 6d3f99c4..1e26c8b3 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -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() diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 21084b38..d01e59f6 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -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 diff --git a/neode-ui/src/utils/openExternal.ts b/neode-ui/src/utils/openExternal.ts index ae541e68..e2d12efc 100644 --- a/neode-ui/src/utils/openExternal.ts +++ b/neode-ui/src/utils/openExternal.ts @@ -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()